mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
## What a user gets An agent that sees `stale`, or two copies of one skill with different hashes, in `rig skill audit` learns what that means before it refreshes anything: - **What the audit is:** a date and hash finding, not a judgment of whether the copy is safe to refresh. - **What answers "can I refresh this?":** the existing read-only check, `rig skill loadout` without `--apply`, and what each of its answers means. It also learns where nothing can answer: a global or untracked copy has no install record. So it doesn't overwrite a copy whose history is unknown, and doesn't treat every difference as a deliberate fork. ## The change (text only) **`rig skill audit --help`** now says: - **What the audit reports:** - declared provenance; - whether a verified date is within 90 days and names an evidence source (`stale_verified` and `bare_verified`, which are state `stale` in `--json`); - the content hash; - whether a copy is byte-identical to a bundled plugin skill. - **What it doesn't:** whether a copy is safe to refresh. - **For a managed copy in a working folder, the read-only check:** `rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json`, without `--apply`. Its answers: - `current`; - `stale`: still OpenRig's last projection, so `--apply` can refresh it; - `conflicting`: read the detail. It can also mean the target couldn't be inspected, so any difference is unknown, and a confirmed difference doesn't prove a deliberate edit; - `shadowed`: read the detail; - `missing`. - **`rig skill loadout --seat <session|self> --json`** (0.7.1 and later) lists a seat's own skills and where they're stored, without classifying them. - **A global or untracked copy:** its origin stays unknown. Ask the skill's owner, and don't move global skills into a project just to get a record. **The audit's human output** gains one closing line, on PASS and FAIL: the audit doesn't say whether a refresh is safe, plus the read-only `loadout` command and a pointer to `--help`. **`docs/reference/project-workspace.md`:** one paragraph in its managed-skills section says the same. No new check, refresh, classification or record. ## Checked against the source - **`packages/daemon/src/domain/skill-audit.ts`:** - **freshness:** the 90-day window (`FRESHNESS_WINDOW_DAYS`); `stale` is set for `stale_verified` or `bare_verified`. - **content hash:** `hashSkillFolder` hashes the folder's top-level files. - **bundled match:** requires the plugin's exact file set and bytes. - **refresh state:** the audit reads no ownership record and classifies none. - **`packages/daemon/src/domain/skill-catalog.ts`, `classifySkillProjectionTarget`:** - **`current` / `shadowed`:** an equal digest, owned or not; - **`stale`:** an owned target still at the prior projection, "can be refreshed safely"; - **`conflicting`:** a different digest, and also the `catch` when the target can't be inspected; - **`shadowed` also covers a kept copy:** a same-named unowned skill, or an owned plugin copy edited after projection, as its detail says. - **The read-only `--seat` inspection** (`inspectSeatSkills` in `seat-skill-apply.ts`) lists skills, storage and delivery without per-copy statuses. Those come only with `--apply`. - **`--seat` is first in 0.7.1:** it's in the 0.7.1 line and not in 0.7.0's `skill.ts`. ## Proof - **RED:** the two new cases in `packages/cli/test/skill.test.ts` fail on main's `skill.ts`. - **GREEN:** the file passes, 6/6. - **Not run locally:** the CLI typecheck, which CI covers. ## Cost - **Source:** +21 lines in one file, help text plus one output line. - **Tests:** +21. - **Docs:** +18. - No new option, setting or refusal.
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { skillCommand } from "../src/commands/skill.js";
|
|
|
|
function makeDeps(auditResponse: { status: number; data: unknown }) {
|
|
const client = {
|
|
get: vi.fn(async () => auditResponse),
|
|
post: vi.fn(async () => ({ status: 200, data: {} })),
|
|
};
|
|
return {
|
|
lifecycleDeps: {
|
|
spawn: vi.fn(),
|
|
fetch: vi.fn(async () => ({ ok: true })),
|
|
kill: vi.fn(() => true),
|
|
readFile: vi.fn(() => JSON.stringify({ pid: 1, port: 3000, db: "t.sqlite", startedAt: new Date().toISOString() })),
|
|
writeFile: vi.fn(),
|
|
removeFile: vi.fn(),
|
|
exists: vi.fn(() => true),
|
|
mkdirp: vi.fn(),
|
|
openForAppend: vi.fn(() => 1),
|
|
isProcessAlive: vi.fn(() => true),
|
|
},
|
|
clientFactory: () => client,
|
|
};
|
|
}
|
|
|
|
describe("rig skill audit", () => {
|
|
let logs: string[];
|
|
let errors: string[];
|
|
|
|
beforeEach(() => {
|
|
logs = [];
|
|
errors = [];
|
|
vi.spyOn(console, "log").mockImplementation((...args) => { logs.push(args.join(" ")); });
|
|
vi.spyOn(console, "error").mockImplementation((...args) => { errors.push(args.join(" ")); });
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
it("mirrorDriftError with totalFindings:0 exits nonzero and does NOT print PASS", async () => {
|
|
const deps = makeDeps({
|
|
status: 200,
|
|
data: {
|
|
ok: true,
|
|
entries: [],
|
|
totalFindings: 0,
|
|
mirrorDriftError: "Mirror source not found: /nonexistent/path",
|
|
},
|
|
});
|
|
|
|
const cmd = skillCommand(deps);
|
|
await cmd.parseAsync(["node", "rig", "audit"]);
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(logs.some((l) => l.includes("MIRROR DRIFT CHECK UNAVAILABLE"))).toBe(true);
|
|
expect(logs.some((l) => l.includes("FAIL"))).toBe(true);
|
|
expect(logs.some((l) => l.includes("PASS"))).toBe(false);
|
|
});
|
|
|
|
it("mirrorDriftError in JSON mode exits nonzero", async () => {
|
|
const deps = makeDeps({
|
|
status: 200,
|
|
data: {
|
|
ok: true,
|
|
entries: [],
|
|
totalFindings: 0,
|
|
mirrorDriftError: "rsync not available",
|
|
},
|
|
});
|
|
|
|
const cmd = skillCommand(deps);
|
|
await cmd.parseAsync(["node", "rig", "audit", "--json"]);
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(logs.some((l) => l.includes("mirrorDriftError"))).toBe(true);
|
|
});
|
|
|
|
it("a skipped mirror drift check (installed package) is informational: prints SKIPPED and PASS, exits 0", async () => {
|
|
const deps = makeDeps({
|
|
status: 200,
|
|
data: {
|
|
ok: true,
|
|
entries: [],
|
|
totalFindings: 0,
|
|
mirrorDriftSkipped: "Mirror drift check skipped: no scripts/mirror-skills.mjs under /pkg; it runs only in an OpenRig source checkout",
|
|
},
|
|
});
|
|
|
|
const cmd = skillCommand(deps);
|
|
await cmd.parseAsync(["node", "rig", "audit"]);
|
|
|
|
expect(process.exitCode).toBeUndefined();
|
|
expect(logs.some((l) => l.includes("MIRROR DRIFT CHECK SKIPPED"))).toBe(true);
|
|
expect(logs.some((l) => l.includes("FAIL"))).toBe(false);
|
|
expect(logs.some((l) => l.includes("PASS"))).toBe(true);
|
|
});
|
|
|
|
it("clean audit with no mirrorDriftError prints PASS and exits 0", async () => {
|
|
const deps = makeDeps({
|
|
status: 200,
|
|
data: {
|
|
ok: true,
|
|
entries: [],
|
|
totalFindings: 0,
|
|
},
|
|
});
|
|
|
|
const cmd = skillCommand(deps);
|
|
await cmd.parseAsync(["node", "rig", "audit"]);
|
|
|
|
expect(process.exitCode).toBeUndefined();
|
|
expect(logs.some((l) => l.includes("PASS"))).toBe(true);
|
|
});
|
|
|
|
it("the human output closes by saying the audit doesn't judge refresh safety and naming the read-only loadout check, on PASS and FAIL", async () => {
|
|
for (const data of [{ ok: true, entries: [], totalFindings: 0 }, { ok: true, entries: [], totalFindings: 0, mirrorDriftError: "x" }]) {
|
|
logs.length = 0;
|
|
await skillCommand(makeDeps({ status: 200, data })).parseAsync(["node", "rig", "audit"]);
|
|
expect(logs.at(-1)).toContain("doesn't say whether that's safe");
|
|
expect(logs.at(-1)).toContain("rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json");
|
|
}
|
|
});
|
|
|
|
it("--help says what stale means, that the audit doesn't judge refresh safety, and which read-only check does", () => {
|
|
const audit = skillCommand(makeDeps({ status: 200, data: {} })).commands.find((c) => c.name() === "audit")!;
|
|
let help = "";
|
|
audit.configureOutput({ writeOut: (s) => { help += s; } });
|
|
audit.outputHelp();
|
|
expect(help).toContain('"stale" is a date finding');
|
|
expect(help).toContain("whether a copy is safe to refresh or overwrite");
|
|
expect(help).toContain("rig skill loadout --runtime <claude-code|codex> --cwd <target> --project-root <project> --json");
|
|
expect(help).toContain("which leaves any difference unknown");
|
|
expect(help).toContain("its origin stays unknown");
|
|
});
|
|
});
|