Files
Mike Schwarz 971e57eaed docs(skill): say what rig skill audit's stale and hash findings mean, and which read-only check answers "can I refresh this?" (#1966)
## 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.
2026-10-24 21:29:34 -07:00

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");
});
});