@vegastack/skills 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vegastack/skills",
3
- "version": "0.16.2",
3
+ "version": "0.18.0",
4
4
  "description": "Installer and verifier for VegaStack Agent Skills (Claude Code, Codex, and Hermes)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,18 +17,18 @@ When the operator directly asks in chat for a change, their words are the approv
17
17
 
18
18
  - Run the deterministic guard first: `node <path-to-this-skill>/scripts/preflight.mjs --issue <n> --me $(gh api user -q .login) --json` (add `--repo <o/r> --dev-md <path>` when running outside the project root) — exit 2 stops you with its reasons (open + `ready` state, approval marker, scope label, plan approval on full-plan, Assumptions section, blockers, assignee, repo match); exit 1 passes with warnings — read them into the ledger. Resume and corrections runs pass `--expect working` / `--expect for-operator`.
19
19
  - Then the judgment checks: read the complete brief plus parent issue and milestone for context; re-verify the brief's touch points against the current code (things drift between approval and execution) — including the version-impact line; volatile dependency claims per `dev-architect`'s verify protocol; a full-plan issue's plan still matches reality. A material decision left open — even outside a formal Assumptions section — or reality contradicting brief or plan is a stop: one `handback` comment with the smallest question, `needs-operator`.
20
- - Resuming a dead session's issue: the operator's explicit handover is required; then follow the resume protocol in [ledger-and-resume](references/ledger-and-resume.md) — brief → plan → ledger → `git log`, nothing else.
20
+ - Resuming a dead session's issue: the operator's explicit handover is required; then follow the resume protocol in [ledger-and-resume](references/ledger-and-resume.md) — brief → plan → ledger → `git log`, nothing else. When the claim is instead being abandoned (nothing worth resuming), the operator releases it with `node <path-to-this-skill>/scripts/reclaim.mjs --issue <n>` (`working` → `ready`, unassign; refuses a still-fresh ledger unless `--force`) — never an automatic reclaim.
21
21
 
22
22
  ## Claim
23
23
 
24
- Assign yourself, swap `ready` → `working`, branch from the default branch per dev.md's `branch:` knob, and **create the ledger comment as your first write**. Record each task's base sha before starting it.
24
+ One session owns one issue, end to end — never hold two `working` claims at once. Assign yourself, swap `ready` → `working`, branch from the default branch per dev.md's `branch:` knob, and **create the ledger comment as your first write** — that comment's edit time is this claim's only liveness signal (dev-status reads a long-silent ledger as a possibly-orphaned claim), so a claim whose session dies before writing it is invisible. Record each task's base sha before starting it.
25
25
 
26
26
  ## Build — dark, test-first, checkpointed
27
27
 
28
28
  No progress updates, no questions. A `fix:` issue's diagnosis runs under the `dev-debug` skill — its phases govern the investigation inside this dark mode, and its winning suspect feeds the evidence comment. A spike the brief flagged runs first — its result opens the evidence comment and shapes the rest of the build. Then work the plan task by task:
29
29
 
30
30
  - **Red before green.** Write the failing test first — at the seams the brief names, never elsewhere — watch it fail for the stated reason, implement the minimal code, watch it pass. One slice at a time. The tests-are-real rubric (implementation-coupled, tautological, horizontal-sliced — defined in `dev-review`'s dispatch prompts) applies to your own tests before a reviewer ever sees them.
31
- - **Checkpoint the ledger** after every task (tick the plan checkbox in the same pass) and at every ruling, per the reference.
31
+ - **Checkpoint the ledger** after every task — and in the same pass tick the matching `[x]` in the *plan* comment (a second write, to a different comment than the ledger, and the one the operator's progress view reads — so the easiest to skip and the one that silently rots). Checkpoint at every ruling too; each checkpoint is also the claim's heartbeat, so never batch them "for later," per the reference.
32
32
  - **Transitory artifacts** — subagent reports, scratch diffs, drafts — live in `.vegastack/.tmp/<issue>-<slug>/`; subagents write full output to files there and return short status.
33
33
  - Decide routine things yourself and ledger the rulings. A structural choice mid-build — a new dependency, table, or service — checks `dev-architect`'s trigger discipline first; a moving part with no named trigger is a stop condition.
34
34
  - **The scope ratchet is a stop condition:** work revealed bigger than the issue's scope class (or plainly exceeding one session) → one `handback` comment proposing the upgrade or split (dev-plan's ratchet rules), `needs-operator`, stop.
@@ -71,7 +71,7 @@ Run the `dev-review` skill per dev.md's `review:` knob — fresh subagent axes b
71
71
  Branch: <name> @ <sha7>
72
72
  ```
73
73
 
74
- The tail's sha stays bare: GitHub auto-links a 7-character sha mid-comment as soon as the commit is on the remote, so the tail becomes a live commit link the moment the branch is pushed — while an explicit `/commit/<sha>` link written at hand-back points at a commit that has not been pushed yet, and 404s. The `**Review:**` line is the one home of surfaced rulings: every ledger `Ruling:` appears there, in the order made. Run `node <path-to-this-skill>/scripts/evidence-check.mjs --file <draft> --json` before posting — exit 2 means the shape is incomplete; fix, don't post. Post it, swap `working` → `for-operator`, stop, and close with the plain-language summary (which repeats, never replaces, the evidence content): what was built, which paths were taken, the rulings, what's worth the operator double-checking.
74
+ The tail's sha stays bare: GitHub auto-links a 7-character sha mid-comment as soon as the commit is on the remote, so the tail becomes a live commit link the moment the branch is pushed — while an explicit `/commit/<sha>` link written at hand-back points at a commit that has not been pushed yet, and 404s. The `**Review:**` line is the one home of surfaced rulings: every ledger `Ruling:` appears there, in the order made. Run `node <path-to-this-skill>/scripts/evidence-check.mjs --file <draft> --issue <n> --json` before posting — it checks the draft's shape and, with `--issue`, that the plan comment's `[x]` boxes reflect the ledger's completed tasks; exit 2 means the shape is incomplete or the checkboxes lag the ledger — fix, don't post. Post it, swap `working` → `for-operator`, stop, and close with the plain-language summary (which repeats, never replaces, the evidence content): what was built, which paths were taken, the rulings, what's worth the operator double-checking.
75
75
 
76
76
  ## Corrections loop — code and docs move together
77
77
 
@@ -6,16 +6,18 @@ The ledger comment's format, line vocabulary, and the resume read-order (brief
6
6
 
7
7
  Create the ledger comment as the session's **first write after claiming** — before any code — with just the marker and heading. Then checkpoint, editing in place:
8
8
 
9
- - **After each plan task completes** — and tick the matching checkbox in the plan comment in the same pass. Record the task's base sha *before* starting it, so the `complete` line's commit range is exact.
9
+ - **After each plan task completes** — and tick the matching `[x]` in the plan comment in the same pass. That box is a second write, to a different comment, that your own resume path never reads — so it is the one that silently lags reality, while the operator's progress view depends on it. The hand-back guard (`evidence-check --issue`) blocks when the ledger's completed tasks outnumber the plan's checked boxes. Record the task's base sha *before* starting it, so the `complete` line's commit range is exact.
10
10
  - **After each review fix round**, with the addressed/open counts.
11
11
  - **At every dark-mode judgment call.** A ruling is any decision the brief/plan didn't make for you that a reviewer or the operator could reasonably question. Rulings are cheap; unrecorded decisions are debt.
12
12
  - **On findings deferred or parked at review**, per dev-review's adjudication lines.
13
13
 
14
14
  Never batch checkpoints "for later" — the ledger's value is exactly that a crash between checkpoints loses one task, not the map. Under concurrent edits, last-writer-wins on one comment is accepted (single-operator workflow); note a clobber if you ever see one.
15
15
 
16
+ The ledger's edit time is also this claim's **heartbeat** — the only liveness signal an agent session exposes. dev-status reads a ledger silent past the orphan threshold (6h) as a *possibly-orphaned* claim: the session likely died before hand-back. A session that runs for days but keeps checkpointing never trips it; a dead one's ledger freezes. A single long task can legitimately go quiet — so checkpoint at rulings within it too, keeping the pulse alive — and the flag is always the operator's to act on (check, resume, or reclaim), never an automatic reset.
17
+
16
18
  ## Resuming — dev-implement's additions to the protocol
17
19
 
18
- - The takeover of a `working` issue requires the operator's explicit handover word; the protocol never makes claiming automatic.
20
+ - The takeover of a `working` issue requires the operator's explicit handover word; the protocol never makes claiming automatic. When nothing is worth resuming, the operator releases the claim instead with `scripts/reclaim.mjs --issue <n>` — `working` → `ready`, unassigned — which refuses a ledger still fresh under the orphan threshold unless `--force`.
19
21
  - Corroborate, don't re-verify: the commits the ledger names should exist in `git log` — reconcile the ranges; a mismatch is a `handback`, not a guess.
20
22
  - A task whose last line is a fix round is mid-loop — resume at the next round with the open findings; a later `complete` line supersedes earlier rounds.
21
23
  - Recorded rulings bind the resumed session: build on them, and surface disagreement in the evidence comment instead of re-litigating.
@@ -7,7 +7,7 @@
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { parseFlags, parseMarker, renderResult } from './lib/gh.mjs';
10
+ import { GhUnavailable, findMarkerComment, ghJson, parseFlags, parseMarker, renderResult } from './lib/gh.mjs';
11
11
 
12
12
  const REQUIRED_SECTIONS = [
13
13
  [/\*\*Done:\*\*/, '**Done:** section'],
@@ -38,18 +38,66 @@ export function checkEvidence(text) {
38
38
  return { blocks, warns: [] };
39
39
  }
40
40
 
41
+ // The plan comment's checkboxes and the ledger's completed tasks are two records
42
+ // of the same fact, and the checkbox — a second write, to a different comment
43
+ // than the ledger the session already maintains — is the one that silently rots.
44
+ // At hand-back the ledger's completed tasks must be reflected in the plan's
45
+ // checkboxes, or the operator reads a false x/y. Distinct task numbers with a
46
+ // `complete` line in the ledger, versus `[x]` boxes in the plan.
47
+ export function checkTaskConsistency(comments) {
48
+ const blocks = [];
49
+ const warns = [];
50
+ const plan = findMarkerComment(comments, 'plan')?.comment?.body;
51
+ const ledger = findMarkerComment(comments, 'ledger')?.comment?.body;
52
+
53
+ // No plan comment, or a plan with no checkboxes (e.g. a research issue), is
54
+ // nothing to reconcile — not a violation. No ledger yet is the same.
55
+ if (!plan || !ledger) return { blocks, warns };
56
+ const planDone = (plan.match(/^-\s*\[x\]/gim) ?? []).length;
57
+ const planTotal = planDone + (plan.match(/^-\s*\[ \]/gm) ?? []).length;
58
+ if (planTotal === 0) return { blocks, warns };
59
+
60
+ const completeTasks = new Set();
61
+ for (const m of ledger.matchAll(/^-\s*Task\s+(\d+):\s*complete\b/gim)) completeTasks.add(m[1]);
62
+ const ledgerComplete = completeTasks.size;
63
+
64
+ if (ledgerComplete > planDone) {
65
+ blocks.push(`${ledgerComplete} task(s) are marked complete in the ledger but only ${planDone} of ${planTotal} are checked off in the plan comment — tick the matching [x] boxes before hand-back`);
66
+ }
67
+ return { blocks, warns };
68
+ }
69
+
70
+ // Fetch the issue's comments and reconcile plan checkboxes against the ledger.
71
+ // Fails closed: an unreachable gh blocks rather than passing silently.
72
+ function taskConsistencyFromIssue({ issue, repo: repoFlag }) {
73
+ const repo = repoFlag || ghJson(['repo', 'view', '--json', 'nameWithOwner']).nameWithOwner;
74
+ const comments = ghJson(['api', `repos/${repo}/issues/${issue}/comments`, '--paginate']);
75
+ return checkTaskConsistency(comments);
76
+ }
77
+
41
78
  const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
42
79
  if (invokedDirectly) {
43
80
  const flags = parseFlags(process.argv.slice(2));
44
81
  let outcome;
45
82
  if (!flags.file) {
46
- outcome = { blocks: ['usage: evidence-check.mjs --file <evidence.md> [--json]'], warns: [] };
83
+ outcome = { blocks: ['usage: evidence-check.mjs --file <evidence.md> [--issue <n> --repo <o/r>] [--json]'], warns: [] };
47
84
  } else {
48
85
  try {
49
86
  outcome = checkEvidence(readFileSync(flags.file, 'utf8'));
50
87
  } catch (error) {
51
88
  outcome = { blocks: [`cannot read evidence: ${error.message}`], warns: [] };
52
89
  }
90
+ // Consistency check only when an issue is named — keeps the file-shape check
91
+ // network-free by default. A gh failure fails closed onto the block list.
92
+ if (flags.issue) {
93
+ try {
94
+ const consistency = taskConsistencyFromIssue(flags);
95
+ outcome.blocks.push(...consistency.blocks);
96
+ outcome.warns.push(...consistency.warns);
97
+ } catch (error) {
98
+ outcome.blocks.push(error instanceof GhUnavailable ? `cannot verify plan/ledger consistency: ${error.message}` : `consistency check error: ${error.message}`);
99
+ }
100
+ }
53
101
  }
54
102
  const { exitCode, text } = renderResult('evidence-check', outcome, { json: Boolean(flags.json) });
55
103
  console.log(text);
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // dev-implement reclaim: release an orphaned claim. The operator runs this after
3
+ // dev-status flags a working issue as possibly-orphaned — it resets working →
4
+ // ready and unassigns, so a fresh session can claim it cleanly. It NEVER claims
5
+ // or resumes: a takeover still needs the operator's explicit handover to a new
6
+ // session. It fails closed and refuses to release a claim whose ledger is still
7
+ // fresh (the session may be alive) unless --force is passed.
8
+ //
9
+ // Exit codes: 0 released · 2 refused/blocked (reasons printed). Read-verify runs
10
+ // before any mutation, so a block never leaves a half-released issue.
11
+ // Usage: node reclaim.mjs --issue <n> [--repo o/r] [--orphan-hours 6] [--force] [--json]
12
+ import { execFileSync } from 'node:child_process';
13
+ import { resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { GhUnavailable, findMarkerComment, ghJson, parseFlags, renderResult } from './lib/gh.mjs';
16
+
17
+ const WORKING = 'working';
18
+ const READY = 'ready';
19
+
20
+ const ageHours = (iso, now) => Math.floor((now - Date.parse(iso)) / 3_600_000);
21
+
22
+ // Read-verify: the deterministic facts that must hold before a release. Returns
23
+ // { blocks, plan } — plan is the mutation to run when blocks is empty.
24
+ export function evaluateReclaim({ issue, comments, orphanHours = 6, force = false, now = Date.now() }) {
25
+ const blocks = [];
26
+ const labels = (issue.labels ?? []).map((l) => l.name);
27
+
28
+ if (issue.state && issue.state !== 'open') blocks.push(`issue is ${issue.state} — only an open working issue can be reclaimed`);
29
+ if (!labels.includes(WORKING)) blocks.push(`issue is not '${WORKING}' (labels: [${labels.join(', ') || 'none'}]) — nothing to reclaim`);
30
+
31
+ const moved = findMarkerComment(comments, 'ledger')?.comment?.updated_at ?? null;
32
+ const ageH = moved ? ageHours(moved, now) : null;
33
+ if (!force && ageH !== null && ageH < orphanHours) {
34
+ blocks.push(`ledger moved ${ageH}h ago (< orphan threshold ${orphanHours}h) — this claim may be live; pass --force to release anyway`);
35
+ }
36
+
37
+ const assignees = (issue.assignees ?? []).map((a) => a.login);
38
+ return {
39
+ blocks,
40
+ plan: { removeAssignees: assignees, ledgerAgeHours: ageH },
41
+ };
42
+ }
43
+
44
+ function ghRun(args) {
45
+ try {
46
+ return execFileSync(process.env.VSK_GH || 'gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
47
+ } catch (error) {
48
+ const stderr = error.stderr?.toString().trim() || error.message;
49
+ throw new GhUnavailable(`gh ${args.join(' ')} failed: ${stderr}`);
50
+ }
51
+ }
52
+
53
+ export function gatherAndReclaim(flags) {
54
+ const repo = flags.repo || ghJson(['repo', 'view', '--json', 'nameWithOwner']).nameWithOwner;
55
+ const issueNumber = flags.issue;
56
+ const raw = ghJson(['api', `repos/${repo}/issues/${issueNumber}`]);
57
+ const comments = ghJson(['api', `repos/${repo}/issues/${issueNumber}/comments`, '--paginate']);
58
+ const orphanRaw = Number(flags['orphan-hours']);
59
+ const { blocks, plan } = evaluateReclaim({
60
+ issue: { state: raw.state, labels: raw.labels, assignees: raw.assignees },
61
+ comments,
62
+ orphanHours: Number.isFinite(orphanRaw) && orphanRaw >= 1 ? orphanRaw : 6,
63
+ force: Boolean(flags.force),
64
+ });
65
+ if (blocks.length > 0) return { blocks, warns: [] };
66
+
67
+ // Mutations, only past a clean read-verify. Label swap first (frees the
68
+ // claim), then unassign, then the operator-visible note.
69
+ ghRun(['issue', 'edit', String(issueNumber), '-R', repo, '--remove-label', WORKING, '--add-label', READY]);
70
+ for (const login of plan.removeAssignees) {
71
+ ghRun(['issue', 'edit', String(issueNumber), '-R', repo, '--remove-assignee', login]);
72
+ }
73
+ const age = plan.ledgerAgeHours === null ? 'no ledger comment was ever written' : `ledger silent ${plan.ledgerAgeHours}h`;
74
+ const note = `**Claim released.** \`${WORKING}\` → \`${READY}\`, unassigned — ${age}. The prior session did not hand back; its branch and ledger stand. Resume it (dev-implement, the operator's handover) or let a fresh session claim it.`;
75
+ ghRun(['issue', 'comment', String(issueNumber), '-R', repo, '--body', note]);
76
+ return { blocks: [], warns: [`released #${issueNumber}: ${WORKING} → ${READY}, unassigned (${age})`] };
77
+ }
78
+
79
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
80
+ if (invokedDirectly) {
81
+ const flags = parseFlags(process.argv.slice(2), ['json', 'force']);
82
+ let outcome;
83
+ if (!flags.issue) {
84
+ outcome = { blocks: ['usage: reclaim.mjs --issue <n> [--repo o/r] [--orphan-hours 6] [--force] [--json]'], warns: [] };
85
+ } else {
86
+ try {
87
+ outcome = gatherAndReclaim(flags);
88
+ } catch (error) {
89
+ outcome = { blocks: [error instanceof GhUnavailable ? `cannot verify: ${error.message}` : `reclaim error: ${error.message}`], warns: [] };
90
+ }
91
+ }
92
+ const { exitCode, text } = renderResult('reclaim', outcome, { json: Boolean(flags.json) });
93
+ console.log(text);
94
+ process.exit(exitCode);
95
+ }
@@ -13,6 +13,8 @@ Nearest neighbors: `dev-implement` invokes this per dev.md's `review:` knob and
13
13
 
14
14
  Build the review package first: `git log --oneline <base>..<head>` + `git diff --stat` + `git diff -U10`, written to `.vegastack/.tmp/<issue>-<slug>/review-<base7>..<head7>.diff`. Reviewers get paths — the brief (issue body), the plan comment, the package file, the project's `.vegastack/review-known-patterns.md` — plus the binding constraints copied verbatim. Reviewers write their full reports to `.tmp` files and return short status; a dead reviewer's findings survive on disk.
15
15
 
16
+ The guard provisions its own scanner: it locates the SkillSpector CLI through whatever channel installed it (uv, brew, pipx) and runs it by absolute path, so a working install is never reported as missing because `PATH` differs between the operator's shell and the agent's. dev.md's `skillspector-update:` knob decides the rest — `auto` (the default) installs it when absent and upgrades it before each scan, falling back to the installed copy on any failure; `notify` only reports what upstream published; `off` never touches the network. `--no-provision` forces one run to leave the machine alone. An upgrade that changes anything is reported before the findings, because after an upgrade a new finding is the tool having learned something, not the diff having broken something.
17
+
16
18
  When dev.md names a `skill-scan:` root, the security dispatch also gets the scan report: `node <path-to-this-skill>/scripts/skill-scan.mjs --json > .vegastack/.tmp/<issue>-<slug>/skill-scan.json` (add `--llm` for the semantic pass — advisory only, never a gate; it is non-deterministic and a degraded run inflates scores). The same guard runs at `dev-implement`'s Verify gate, so by review time it has already passed; the axis is here to triage what sits below the blocking bar and to judge whether anything above it was suppressed rather than fixed.
17
19
 
18
20
  ## The axes — parallel, fresh, never merged
@@ -1,3 +1,18 @@
1
1
  # Refresh contract — dev-review
2
2
 
3
- Evergreen: this skill asserts no version pins, numeric vendor limits, or dated claims — its content is review discipline (axes, severities, the bounded loop, dispatch briefs, the smell baseline, cross-agent handoff shape), all versionless. The `codex exec` / `claude -p` invocation forms are deliberately treated as durable CLI surfaces; dev-setup's detection covers their presence per machine. Revisit if a future edit introduces a volatile fact.
3
+ Most of this skill is versionless review discipline (axes, severities, the bounded loop, dispatch briefs, the smell baseline, cross-agent handoff) and asserts nothing that can go stale. The `codex exec` / `claude -p` invocation forms stay deliberately untracked as durable CLI surfaces; dev-setup's detection covers their presence per machine.
4
+
5
+ **The evergreen waiver was retired on 01-09-2026** (issue #83). `scripts/lib/skillspector.mjs` now parses the output of third-party commands and calls a third-party API, so the skill carries volatile facts for the first time — and the waiver's own escape clause said to revisit exactly then.
6
+
7
+ What the registry tracks, and why each one is load-bearing rather than decorative: every parser below fails **silently** if its upstream surface changes — returning "not installed", which the guard reports as a missing scanner instead of a parsing bug. That silence is the whole reason these are registered.
8
+
9
+ | Source | The claim it holds up | Breaks how |
10
+ |---|---|---|
11
+ | `SS-INSTALL` | `uv tool install git+https://github.com/NVIDIA/skillspector.git` is upstream's documented install, and the package is not on PyPI | the install command installs nothing, or the wrong thing |
12
+ | `SS-VERSION-OUTPUT` | `skillspector --version` prints `SkillSpector v<x.y.z>` on **stdout**, warnings on stderr | version reporting goes null; the baseline-pin warning stops firing |
13
+ | `SS-RELEASES-API` | the GitHub releases endpoint shape, and its 60-request/hour unauthenticated limit | `notify` silently reports nothing |
14
+ | `UV-TOOL-LIST` | `uv tool list --show-paths` prints `- skillspector (/abs/path)` | uv-installed scanners read as absent |
15
+ | `BREW-PREFIX` | `brew list --versions` exits non-zero when absent, while `brew --prefix` exits **0 with a path for any known formula** whether or not it is installed | brew detection returns a path that does not exist |
16
+ | `PIPX-LIST` | `pipx list --short` exits 0 even with nothing installed, so its output must be parsed | pipx-installed scanners read as absent, or absence reads as present |
17
+
18
+ Drift in any of them means reading the changed surface and updating both the parser and its dated comment in the same reviewed PR — never auto-applying. The parsers each have a unit test pinning the exact output shape, so a corrected parser has a failing test to satisfy.
@@ -1,6 +1,133 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "retrievalBaseline": "2026-08-28",
4
- "note": "Evergreen waiver recorded in REFRESH.md; sources deliberately empty.",
5
- "sources": []
3
+ "policy": {
4
+ "defaultChecksumScope": "html-text-v1",
5
+ "offline": "Use cached metadata only; fail closed when an entry is missing or older than thresholdDays.",
6
+ "drift": "Read the changed surface, then update the parser AND its dated comment in one reviewed PR; never auto-apply. Each parser has a unit test pinning the output shape.",
7
+ "copyright": "Store claim metadata, URLs, hashes and concise excerpts only; never archive third-party documentation.",
8
+ "cadence": "weekly scheduled refresh; thresholdDays must be >= 14 (2x cadence) so one missed run never breaches a threshold"
9
+ },
10
+ "sources": [
11
+ {
12
+ "id": "SS-INSTALL",
13
+ "service": "SkillSpector install instructions (uv; not on PyPI)",
14
+ "kind": "official-docs",
15
+ "stability": "vendor-docs",
16
+ "thresholdDays": 14,
17
+ "critical": true,
18
+ "urls": {
19
+ "primary": "https://github.com/NVIDIA/SkillSpector"
20
+ },
21
+ "versionDetection": {
22
+ "type": "manual-review"
23
+ },
24
+ "topics": [
25
+ "skillspector-install"
26
+ ],
27
+ "affected": [
28
+ "scripts/lib/skillspector.mjs",
29
+ "README.md"
30
+ ]
31
+ },
32
+ {
33
+ "id": "SS-VERSION-OUTPUT",
34
+ "service": "skillspector --version output shape (stdout vs stderr)",
35
+ "kind": "official-docs",
36
+ "stability": "vendor-docs",
37
+ "thresholdDays": 14,
38
+ "critical": true,
39
+ "urls": {
40
+ "primary": "https://github.com/NVIDIA/SkillSpector"
41
+ },
42
+ "versionDetection": {
43
+ "type": "manual-review"
44
+ },
45
+ "topics": [
46
+ "skillspector-version"
47
+ ],
48
+ "affected": [
49
+ "scripts/lib/skillspector.mjs"
50
+ ]
51
+ },
52
+ {
53
+ "id": "SS-RELEASES-API",
54
+ "service": "GitHub releases endpoint shape and unauthenticated rate limit",
55
+ "kind": "official-docs",
56
+ "stability": "vendor-docs",
57
+ "thresholdDays": 14,
58
+ "critical": false,
59
+ "urls": {
60
+ "primary": "https://docs.github.com/en/rest/releases/releases"
61
+ },
62
+ "versionDetection": {
63
+ "type": "manual-review"
64
+ },
65
+ "topics": [
66
+ "skillspector-releases"
67
+ ],
68
+ "affected": [
69
+ "scripts/lib/skillspector.mjs"
70
+ ]
71
+ },
72
+ {
73
+ "id": "UV-TOOL-LIST",
74
+ "service": "uv tool list --show-paths output shape",
75
+ "kind": "official-docs",
76
+ "stability": "vendor-docs",
77
+ "thresholdDays": 14,
78
+ "critical": true,
79
+ "urls": {
80
+ "primary": "https://docs.astral.sh/uv/concepts/tools/"
81
+ },
82
+ "versionDetection": {
83
+ "type": "manual-review"
84
+ },
85
+ "topics": [
86
+ "channel-uv"
87
+ ],
88
+ "affected": [
89
+ "scripts/lib/skillspector.mjs"
90
+ ]
91
+ },
92
+ {
93
+ "id": "BREW-PREFIX",
94
+ "service": "brew list --versions vs brew --prefix exit semantics",
95
+ "kind": "official-docs",
96
+ "stability": "vendor-docs",
97
+ "thresholdDays": 14,
98
+ "critical": false,
99
+ "urls": {
100
+ "primary": "https://docs.brew.sh/Manpage"
101
+ },
102
+ "versionDetection": {
103
+ "type": "manual-review"
104
+ },
105
+ "topics": [
106
+ "channel-brew"
107
+ ],
108
+ "affected": [
109
+ "scripts/lib/skillspector.mjs"
110
+ ]
111
+ },
112
+ {
113
+ "id": "PIPX-LIST",
114
+ "service": "pipx list --short exit code and output",
115
+ "kind": "official-docs",
116
+ "stability": "vendor-docs",
117
+ "thresholdDays": 14,
118
+ "critical": false,
119
+ "urls": {
120
+ "primary": "https://pipx.pypa.io/stable/docs/"
121
+ },
122
+ "versionDetection": {
123
+ "type": "manual-review"
124
+ },
125
+ "topics": [
126
+ "channel-pipx"
127
+ ],
128
+ "affected": [
129
+ "scripts/lib/skillspector.mjs"
130
+ ]
131
+ }
132
+ ]
6
133
  }
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ // Everything about the SkillSpector CLI itself — where it is, installing it,
3
+ // upgrading it, reading its version. `skill-scan.mjs` keeps owning the scan and
4
+ // the verdict and calls in here once, before scanning.
5
+ //
6
+ // Named for the tool, not for its role: this repo's own machinery is "skill
7
+ // scan", and the third-party binary is always "skillspector" by its exact name,
8
+ // so the generic word stays free (operator's rule, 01-09-2026).
9
+ //
10
+ // Every command runs through an injected `run`, and every path check through an
11
+ // injected `exists`, so unit tests never install software or touch the disk.
12
+ // Self-contained (ships with dev-review; no cross-skill imports, no dependencies).
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ // Upstream's own documented quick install, verified 01-09-2026 against the
18
+ // SkillSpector README, which documents uv and no other package manager. The git
19
+ // URL is not optional: the package is NOT published to PyPI (HTTP 404), so a
20
+ // bare `pip install skillspector` can never work.
21
+ export const INSTALL_COMMAND = ['uv', ['tool', 'install', 'git+https://github.com/NVIDIA/skillspector.git']];
22
+
23
+ // Per channel, in probe order. `detect` proves the tool is actually installed;
24
+ // `locate` turns that into an absolute executable path.
25
+ const UPGRADE = {
26
+ uv: ['uv', ['tool', 'upgrade', 'skillspector']],
27
+ brew: ['brew', ['upgrade', 'skillspector']],
28
+ pipx: ['pipx', ['upgrade', 'skillspector']],
29
+ };
30
+
31
+ // A command runner that never throws: a non-zero exit is data, not an
32
+ // exception, because "brew is not installed here" is an ordinary answer to
33
+ // "where is skillspector". stderr is folded into stdout so a failure message
34
+ // survives for the report.
35
+ export function defaultRun(cmd, args, { timeoutMs = 300_000 } = {}) {
36
+ try {
37
+ const stdout = execFileSync(cmd, args, {
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ // `env` is passed explicitly, as skill-scan.mjs does: under Bun a mutated
41
+ // process.env is NOT inherited by execFileSync children.
42
+ env: { ...process.env },
43
+ timeout: timeoutMs,
44
+ maxBuffer: 16 * 1024 * 1024,
45
+ });
46
+ return { ok: true, stdout };
47
+ } catch (error) {
48
+ const out = `${error.stdout?.toString() ?? ''}${error.stderr?.toString() ?? ''}`.trim();
49
+ return { ok: false, stdout: out || error.message || '' };
50
+ }
51
+ }
52
+
53
+ // `uv tool list --show-paths` prints one line per tool and one indented line per
54
+ // executable it installed:
55
+ // skillspector v2.11.0 (/home/x/.local/share/uv/tools/skillspector)
56
+ // - skillspector (/home/x/.local/bin/skillspector)
57
+ // The executable line is the one that matters — the first is the venv, which is
58
+ // not runnable. Anchored on both sides so `skillspector-extra` cannot match.
59
+ export function parseUvToolList(text) {
60
+ for (const line of String(text ?? '').split('\n')) {
61
+ const match = /^-\s+skillspector\s+\((.+)\)\s*$/.exec(line.trim());
62
+ if (match) return match[1];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // `pipx list --short` exits 0 whether or not anything is installed (verified
68
+ // 01-09-2026 — it prints "nothing has been installed with pipx" and succeeds),
69
+ // so the exit code proves nothing and the listing has to be read.
70
+ export function parsePipxList(text) {
71
+ return String(text ?? '')
72
+ .split('\n')
73
+ .some((line) => /^skillspector(\s|$)/.test(line.trim()));
74
+ }
75
+
76
+ // Ask each channel where it put the executable, in order, and believe only a
77
+ // path that is actually on disk.
78
+ //
79
+ // The existence check is not defensive padding: `brew --prefix <formula>` exits
80
+ // 0 and prints a path for any formula it KNOWS, installed or not — verified
81
+ // 01-09-2026, where it named /opt/homebrew/opt/skillspector while nothing was
82
+ // installed there. Detection therefore runs `brew list --versions` first, and
83
+ // the check below is the backstop for every channel.
84
+ export function locateSkillspector({ run = defaultRun, exists = existsSync } = {}) {
85
+ const believe = (channel, path) => (path && exists(path) ? { channel, path } : null);
86
+
87
+ const uv = run('uv', ['tool', 'list', '--show-paths']);
88
+ if (uv.ok) {
89
+ const found = believe('uv', parseUvToolList(uv.stdout));
90
+ if (found) return found;
91
+ }
92
+
93
+ if (run('brew', ['list', '--versions', 'skillspector']).ok) {
94
+ const prefix = run('brew', ['--prefix', 'skillspector']);
95
+ if (prefix.ok && prefix.stdout.trim()) {
96
+ const found = believe('brew', join(prefix.stdout.trim(), 'bin', 'skillspector'));
97
+ if (found) return found;
98
+ }
99
+ }
100
+
101
+ const pipx = run('pipx', ['list', '--short']);
102
+ if (pipx.ok && parsePipxList(pipx.stdout)) {
103
+ const dir = run('pipx', ['environment', '--value', 'PIPX_BIN_DIR']);
104
+ if (dir.ok && dir.stdout.trim()) {
105
+ const found = believe('pipx', join(dir.stdout.trim(), 'skillspector'));
106
+ if (found) return found;
107
+ }
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+
114
+ // Strip C0/C1 controls (ANSI escapes included) from anything a command printed:
115
+ // this text reaches a terminal report, and package-manager output carries names
116
+ // from outside the repo. Mirrors skill-scan.mjs's own `safe()`.
117
+ function safe(text) {
118
+ // eslint-disable-next-line no-control-regex
119
+ return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
120
+ }
121
+
122
+ // `skillspector --version` prints "SkillSpector v2.11.0" on STDOUT while its
123
+ // missing-API-key warnings go to stderr (verified 01-09-2026). `run` hands back
124
+ // stdout alone on success, so the warnings can never contaminate the match.
125
+ export function readVersion({ path, run = defaultRun }) {
126
+ const result = run(path, ['--version']);
127
+ if (!result.ok) return null;
128
+ const match = /SkillSpector\s+v?(\d+\.\d+\.\d+\S*)/i.exec(result.stdout);
129
+ return match ? match[1] : null;
130
+ }
131
+
132
+ // The lines a package manager reported moving. uv prints one `+ pkg==x` /
133
+ // `- pkg==y` line per dependency it changed; anything else simply yields none.
134
+ function changedLines(stdout) {
135
+ return String(stdout ?? '')
136
+ .split('\n')
137
+ .map((line) => safe(line.trim()))
138
+ .filter((line) => /^[+-]\s*\S/.test(line));
139
+ }
140
+
141
+ // Install when absent, upgrade when present — and never throw: a machine
142
+ // without a network, without uv, or with a locked package manager must fall
143
+ // back to whatever is already installed and let the scan proceed. Only a
144
+ // SkillSpector that cannot be found at all blocks, and that is skill-scan's
145
+ // call, not this function's.
146
+ //
147
+ // No version check runs first, deliberately. `uv tool upgrade` moves the whole
148
+ // dependency tree while the version string can hold steady (verified
149
+ // 01-09-2026: langsmith 0.11.2 -> 0.12.0 under an unchanged v2.11.0), so
150
+ // comparing versions would report "current" about a tool that just changed.
151
+ export function provisionSkillspector({ mode, located, run = defaultRun }) {
152
+ const idle = { action: 'none', before: null, after: null, changed: [], message: '' };
153
+
154
+ // `notify` reports; it never touches the machine. The release lookup that
155
+ // makes it useful belongs to the caller, which owns the network policy.
156
+ if (mode !== 'auto') return located ? { ...idle, before: readVersion({ path: located.path, run }) } : idle;
157
+
158
+ if (!located) {
159
+ const [cmd, args] = INSTALL_COMMAND;
160
+ const result = run(cmd, args);
161
+ if (!result.ok) {
162
+ return { ...idle, action: 'failed', message: safe(result.stdout), changed: changedLines(result.stdout) };
163
+ }
164
+ // `after` stays null: reading it needs the path, and only a fresh locate
165
+ // knows where the install landed. The caller re-locates and fills it in.
166
+ return { action: 'installed', before: null, after: null, changed: changedLines(result.stdout), message: '' };
167
+ }
168
+
169
+ const upgrade = UPGRADE[located.channel];
170
+ if (!upgrade) {
171
+ return { ...idle, message: `no upgrade command is known for the ${safe(String(located.channel))} channel` };
172
+ }
173
+
174
+ const before = readVersion({ path: located.path, run });
175
+ const result = run(upgrade[0], upgrade[1]);
176
+ if (!result.ok) {
177
+ return { action: 'failed', before, after: before, changed: [], message: safe(result.stdout) };
178
+ }
179
+ return {
180
+ action: 'upgraded',
181
+ before,
182
+ after: readVersion({ path: located.path, run }),
183
+ changed: changedLines(result.stdout),
184
+ message: '',
185
+ };
186
+ }
187
+
188
+
189
+ // Upstream's releases feed. Unauthenticated and rate-limited to 60 requests an
190
+ // hour per IP (verified 01-09-2026), which `notify` stays far inside because it
191
+ // asks once per guard run and only in that mode.
192
+ export const RELEASES_URL = 'https://api.github.com/repos/NVIDIA/SkillSpector/releases/latest';
193
+
194
+ async function fetchReleaseJson(url) {
195
+ // GitHub rejects requests without a User-Agent.
196
+ const response = await fetch(url, {
197
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'vegastack-skill-scan' },
198
+ signal: AbortSignal.timeout(10_000),
199
+ });
200
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
201
+ return response.json();
202
+ }
203
+
204
+ // The newest published release, or null. EVERY failure path is null and never a
205
+ // throw: `notify` is a courtesy line in a report, and a guard must not turn a
206
+ // flaky network into a verdict. A tag that is not a plain version (a nightly, a
207
+ // moved pointer) is rejected rather than reported as a version.
208
+ export async function latestRelease({ fetchJson = fetchReleaseJson, url = RELEASES_URL } = {}) {
209
+ let body;
210
+ try {
211
+ body = await fetchJson(url);
212
+ } catch {
213
+ return null;
214
+ }
215
+ if (!body || typeof body !== 'object') return null;
216
+ const tag = typeof body.tag_name === 'string' ? body.tag_name.trim() : '';
217
+ const match = /^v?(\d+\.\d+\.\d+\S*)$/.exec(tag);
218
+ return match ? match[1] : null;
219
+ }
220
+
221
+ export const UPGRADE_COMMANDS = UPGRADE;
@@ -15,6 +15,13 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs';
15
15
  import { tmpdir } from 'node:os';
16
16
  import { basename, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import {
19
+ defaultRun,
20
+ latestRelease,
21
+ locateSkillspector,
22
+ provisionSkillspector,
23
+ readVersion,
24
+ } from './lib/skillspector.mjs';
18
25
 
19
26
  // The clause every suppression must carry, mirroring the "Still flag if:"
20
27
  // requirement on .vegastack/review-known-patterns.md entries: a suppression
@@ -173,7 +180,7 @@ export function parseBaseline(text) {
173
180
  coverage.push({ skill: raw.skill, file: raw.file, sha256: raw.sha256, reason: raw.reason });
174
181
  });
175
182
 
176
- return { rules, fingerprints: rawFingerprints, coverage, errors, warns };
183
+ return { rules, fingerprints: rawFingerprints, coverage, errors, warns, scannerVersion: data.scanner_version ?? null };
177
184
  }
178
185
 
179
186
  // Absolute paths, sorted, of the skill directories under `root`. A directory is
@@ -318,13 +325,34 @@ export function evaluateScan(facts) {
318
325
  baselineErrors = [],
319
326
  skills = [],
320
327
  scanErrors = [],
328
+ skillspector = {},
329
+ baselinePin = {},
321
330
  } = facts;
322
331
 
323
332
  // Environment failures first: when the scanner never ran, a finding list is
324
333
  // not evidence of anything, and the real cause must read before the noise.
325
334
  if (binaryMissing) {
326
- blocks.push(`the \`skillspector\` binary is not on PATH — ${INSTALL_HINT}, or set skill-scan: none if this project has no skills`);
335
+ blocks.push(
336
+ `the \`skillspector\` binary could not be found — no install channel (uv, brew, pipx) reports it and it is not on PATH — ${INSTALL_HINT}; or, if it runs through a wrapper or container, point VSK_SKILLSPECTOR at that executable; or set skill-scan: none if this project has no skills`,
337
+ );
338
+ }
339
+ // An update that could not happen is a note, never a block: the scan ran on
340
+ // the copy that was already installed, which is exactly the documented
341
+ // fallback. Sanitized — this text comes from a package manager.
342
+ if (skillspector.action === 'failed' && skillspector.message) {
343
+ warns.push(`skillspector update failed, scanned with the installed copy instead — ${safe(skillspector.message)}`);
344
+ }
345
+ // A fingerprint is a content hash tied to the scanner that produced it, so a
346
+ // version change can stop it matching and quietly un-suppress its finding.
347
+ // Warn, never block: the finding coming back IS the loud outcome, and the pin
348
+ // is never moved automatically — that would assert a suppression still holds
349
+ // for a scanner nobody has run.
350
+ if (baselinePin.fingerprints > 0 && baselinePin.scannerVersion && skillspector.version && baselinePin.scannerVersion !== skillspector.version) {
351
+ warns.push(
352
+ `baseline pins scanner_version ${safe(baselinePin.scannerVersion)} for ${baselinePin.fingerprints} fingerprint(s) but skillspector ${safe(skillspector.version)} ran — re-verify those suppressions and move the pin deliberately, never automatically`,
353
+ );
327
354
  }
355
+
328
356
  if (rootMissing) {
329
357
  blocks.push(`scan root "${rootMissing}" does not exist — build it first if it is a build output, or correct dev.md's skill-scan: knob`);
330
358
  }
@@ -502,6 +530,29 @@ export function scanRootDeclarations(devMdText) {
502
530
  return [...String(devMdText ?? '').matchAll(KNOB_LINE)].map((match) => match[1]);
503
531
  }
504
532
 
533
+ // The sibling knob governing SkillSpector itself: off | notify | auto, absent
534
+ // reading as `auto` so an existing profile inherits the default without an
535
+ // edit. Same tolerant layout matching and same conflict discipline as
536
+ // `skill-scan:` — a knob the guard cannot see reads as absent, and absent must
537
+ // not silently mean something different from what the author wrote.
538
+ //
539
+ // Named for the tool, not for our guard: `skill-scan:` is our machinery,
540
+ // `skillspector-update:` is the third-party binary (operator's rule, 01-09-2026).
541
+ const UPDATE_KNOB_LINE = /^[ \t]*(?:[-*+][ \t]+)?skillspector-update:[ \t]*(\S+)/gm;
542
+
543
+ export const UPDATE_MODES = new Set(['off', 'notify', 'auto']);
544
+
545
+ // Every declared value, unvalidated. The caller refuses a conflict or an
546
+ // unrecognised value rather than picking one: guessing which mode the author
547
+ // meant is exactly the judgement a guard must not make.
548
+ export function updateModeDeclarations(devMdText) {
549
+ return [...String(devMdText ?? '').matchAll(UPDATE_KNOB_LINE)].map((match) => match[1]);
550
+ }
551
+
552
+ export function resolveUpdateMode(devMdText) {
553
+ return updateModeDeclarations(devMdText)[0] ?? 'auto';
554
+ }
555
+
505
556
  export function resolveScanRoot(devMdText) {
506
557
  const value = scanRootDeclarations(devMdText)[0];
507
558
  if (!value || value === 'none') return null;
@@ -529,10 +580,16 @@ function normalizeIssue(raw) {
529
580
  // Impure: shells out to the scanner, once per skill. `--baseline` is rejected
530
581
  // together with `--recursive` ("scan each sub-skill with its own baseline"), so
531
582
  // the loop is the supported path, not an optimization we passed up.
532
- export function gatherFacts({ root, baselinePath, llm }) {
583
+ export function gatherFacts({ root, baselinePath, llm, binary: binaryOverride }) {
533
584
  // VSK_SKILLSPECTOR is a TEST SEAM (stubs the scanner in unit tests), mirroring
534
- // ship-gate.mjs's VSK_GH. Normal runs resolve `skillspector` from PATH.
535
- const binary = process.env.VSK_SKILLSPECTOR || 'skillspector';
585
+ // ship-gate.mjs's VSK_GH. `binaryOverride` is the absolute path the CLI
586
+ // resolved through the tool's own install channel; a bare PATH lookup is the
587
+ // last resort, not the first.
588
+ //
589
+ // Locating deliberately happens in the CLI and NOT here: gatherFacts is
590
+ // driven directly by unit tests, and probing uv/brew/pipx from inside it
591
+ // would make the suite shell out to whatever is installed on the machine.
592
+ const binary = process.env.VSK_SKILLSPECTOR || binaryOverride || 'skillspector';
536
593
  const base = {
537
594
  binaryMissing: false,
538
595
  rootMissing: null,
@@ -551,6 +608,10 @@ export function gatherFacts({ root, baselinePath, llm }) {
551
608
  base.baselineErrors = parsed.errors;
552
609
  base.baselineWarns = parsed.warns;
553
610
  base.coverageAccepted = parsed.coverage;
611
+ // Only fingerprints are version-coupled: they are content hashes the
612
+ // scanner computed, so a different scanner may stop matching them. Rules
613
+ // bind to id+path and survive an upgrade untouched.
614
+ base.baselinePin = { scannerVersion: parsed.scannerVersion, fingerprints: parsed.fingerprints.length };
554
615
  }
555
616
  // Short-circuit: with a bad baseline nothing the scan reports is trustworthy —
556
617
  // suppressions may not apply — and the scanner would reject the file once per
@@ -685,6 +746,62 @@ export function gatherFacts({ root, baselinePath, llm }) {
685
746
  return base;
686
747
  }
687
748
 
749
+ // The provisioning sequence, extracted so it is testable: the CLI passes the
750
+ // real commands, unit tests pass fakes. Returns the `skillspector` report block.
751
+ //
752
+ // `mode` is a MACHINE policy, never a per-scan-root one — see the CLI, which
753
+ // reads it from the profile even when --root chose what to scan.
754
+ export async function provisionForRun({
755
+ mode,
756
+ locate,
757
+ provision,
758
+ versionOf,
759
+ pathVisible,
760
+ fetchLatest,
761
+ }) {
762
+ const state = {
763
+ mode,
764
+ channel: null,
765
+ path: null,
766
+ version: null,
767
+ action: 'none',
768
+ before: null,
769
+ after: null,
770
+ changed: [],
771
+ message: '',
772
+ available: null,
773
+ resolvedOutsidePath: false,
774
+ };
775
+
776
+ let located = locate();
777
+ const result = provision({ mode, located });
778
+ state.action = result.action;
779
+ state.changed = result.changed;
780
+ state.message = result.message;
781
+ state.before = result.before;
782
+ state.after = result.after;
783
+ // An install lands somewhere only a fresh probe knows about.
784
+ if (result.action === 'installed') located = locate();
785
+
786
+ if (located) {
787
+ state.channel = located.channel;
788
+ state.path = located.path;
789
+ state.version = versionOf(located.path);
790
+ // A fresh install has no "before", so its "after" is simply the version now
791
+ // installed — otherwise the report says "(unchanged)" about a tool that was
792
+ // not there a moment ago.
793
+ if (state.action === 'installed') state.after = state.version;
794
+ // Located through its channel while a bare PATH lookup cannot see it. This
795
+ // is reported, never warned about: it is the ordinary case this feature
796
+ // exists to serve, and making it a warn would pin the exit code at 1
797
+ // forever for exactly the setup that motivated the work.
798
+ state.resolvedOutsidePath = !pathVisible();
799
+ }
800
+
801
+ if (mode === 'notify') state.available = await fetchLatest();
802
+ return state;
803
+ }
804
+
688
805
  const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
689
806
  if (invokedDirectly) {
690
807
  const argv = process.argv.slice(2);
@@ -693,6 +810,8 @@ if (invokedDirectly) {
693
810
  return index === -1 ? undefined : argv[index + 1];
694
811
  };
695
812
  const json = argv.includes('--json');
813
+ // Forces this one run to leave the machine untouched, whatever the knob says.
814
+ const noProvision = argv.includes('--no-provision');
696
815
  const devMdPath = get('--dev-md') || '.vegastack/dev.md';
697
816
 
698
817
  let root = get('--root');
@@ -701,6 +820,50 @@ if (invokedDirectly) {
701
820
  let outcome = { blocks: [], warns: [] };
702
821
  let facts = { skills: [] };
703
822
  let baselinePath = get('--baseline') ?? null;
823
+ let updateMode = 'auto';
824
+ let binary;
825
+ // Warns raised before the scan runs. `outcome` is REASSIGNED by evaluateScan,
826
+ // so anything pushed onto it beforehand would be silently discarded.
827
+ const preWarns = [];
828
+ const skillspector = {
829
+ mode: 'auto',
830
+ channel: null,
831
+ path: null,
832
+ version: null,
833
+ action: 'none',
834
+ before: null,
835
+ after: null,
836
+ changed: [],
837
+ message: '',
838
+ available: null,
839
+ resolvedOutsidePath: false,
840
+ };
841
+
842
+ // The update mode is read from the profile ALWAYS, including for --root runs.
843
+ // --root chooses what to scan; it never decides whether this machine may be
844
+ // written to. Skipping this is how `skillspector-update: off` got ignored on
845
+ // exactly the invocation dev-review documents for vetting a stranger's skill.
846
+ {
847
+ let profileForMode = null;
848
+ try {
849
+ profileForMode = readFileSync(devMdPath, 'utf8');
850
+ } catch {
851
+ // An explicit --root may legitimately run outside any project.
852
+ }
853
+ if (profileForMode !== null) {
854
+ const declared = [...new Set(updateModeDeclarations(profileForMode))];
855
+ const unusable = declared.length > 1 || declared.some((value) => !UPDATE_MODES.has(value));
856
+ // A profile we cannot read unambiguously must not authorise writing to
857
+ // the machine. The non---root path below turns the same conditions into
858
+ // blocks; here the run continues, but touching nothing.
859
+ updateMode = unusable ? 'off' : resolveUpdateMode(profileForMode);
860
+ if (unusable && explicitRoot) {
861
+ preWarns.push(
862
+ `${devMdPath} does not give skillspector-update a single recognised value — this run left the machine untouched`,
863
+ );
864
+ }
865
+ }
866
+ }
704
867
 
705
868
  if (!explicitRoot) {
706
869
  // "Could not read the profile" and "the profile says none" are different
@@ -720,6 +883,22 @@ if (invokedDirectly) {
720
883
  `${devMdPath} gives skill-scan conflicting values (${declared.join(', ')}) — an example line above the real knob would otherwise silently decide the gate; leave exactly one`,
721
884
  );
722
885
  }
886
+ const updateDeclared = [...new Set(updateModeDeclarations(devMd))];
887
+ if (updateDeclared.length > 1) {
888
+ outcome.blocks.push(
889
+ `${devMdPath} gives skillspector-update conflicting values (${updateDeclared.join(', ')}) — leave exactly one`,
890
+ );
891
+ }
892
+ const unknown = updateDeclared.filter((value) => !UPDATE_MODES.has(value));
893
+ if (unknown.length > 0) {
894
+ // Defaulting an unrecognised value to `auto` would silently install
895
+ // software because of a typo. The guard refuses instead.
896
+ outcome.blocks.push(
897
+ `${devMdPath} sets skillspector-update to ${unknown.join(', ')} — expected one of off, notify, auto`,
898
+ );
899
+ }
900
+ updateMode = resolveUpdateMode(devMd);
901
+
723
902
  root = resolveScanRoot(devMd);
724
903
  skipped = root === null && declared.length <= 1;
725
904
  // The project's own suppressions apply to the project's own skills. They
@@ -731,23 +910,47 @@ if (invokedDirectly) {
731
910
  }
732
911
 
733
912
  if (!skipped && outcome.blocks.length === 0) {
913
+ skillspector.mode = noProvision ? 'off' : updateMode;
914
+
915
+ // VSK_SKILLSPECTOR is the test seam, and it means "this exact binary" — so
916
+ // it suppresses locating AND provisioning. Without that, every CLI test
917
+ // would shell out to whatever uv/brew happen to hold on the machine running
918
+ // the suite, and a unit suite that installs software is not a unit suite.
919
+ if (!process.env.VSK_SKILLSPECTOR) {
920
+ Object.assign(
921
+ skillspector,
922
+ await provisionForRun({
923
+ mode: skillspector.mode,
924
+ locate: () => locateSkillspector(),
925
+ provision: ({ mode, located }) => provisionSkillspector({ mode, located }),
926
+ versionOf: (path) => readVersion({ path }),
927
+ pathVisible: () => defaultRun('skillspector', ['--version']).ok,
928
+ fetchLatest: () => latestRelease(),
929
+ }),
930
+ );
931
+ binary = skillspector.path ?? undefined;
932
+ }
933
+
734
934
  // An uncaught throw would leave node exiting 1 — which in this guard's own
735
935
  // scheme reads as "pass with warnings". A crash is not a pass.
736
936
  try {
737
- facts = gatherFacts({ root, baselinePath, llm: argv.includes('--llm') });
937
+ facts = gatherFacts({ root, baselinePath, llm: argv.includes('--llm'), binary });
938
+ facts.skillspector = skillspector;
738
939
  outcome = evaluateScan(facts);
739
940
  } catch (error) {
740
- facts = { skills: [] };
941
+ facts = { skills: [], skillspector };
741
942
  outcome = { blocks: [`the scan failed unexpectedly: ${error.message}`], warns: [] };
742
943
  }
743
944
  }
744
945
 
946
+ outcome = { ...outcome, warns: [...preWarns, ...outcome.warns] };
745
947
  const ok = outcome.blocks.length === 0;
746
948
  if (json) {
747
949
  console.log(JSON.stringify({
748
950
  guard: 'skill-scan',
749
951
  ok,
750
952
  skipped,
953
+ skillspector,
751
954
  ...outcome,
752
955
  // The full normalized issue list, not a count: dev-review's Security axis
753
956
  // is told to read the source at each finding's file:line and to judge
@@ -763,6 +966,23 @@ if (invokedDirectly) {
763
966
  console.log('skill-scan: BLOCKED');
764
967
  for (const b of outcome.blocks) console.log(` block: ${b}`);
765
968
  } else {
969
+ // The version/dependency change reads BEFORE the findings: after an
970
+ // upgrade, new findings are the tool having learned something, not the diff
971
+ // having broken something, and an operator who cannot see that debugs the
972
+ // wrong thing.
973
+ if (skillspector.action === 'installed' || skillspector.action === 'upgraded') {
974
+ const span = skillspector.before === skillspector.after
975
+ ? `version ${skillspector.version ?? 'unknown'} (unchanged)`
976
+ : `version ${skillspector.before ?? 'none'} → ${skillspector.after ?? skillspector.version ?? 'unknown'}`;
977
+ console.log(`skill-scan: skillspector ${skillspector.action} via ${skillspector.channel ?? 'uv'} — ${span}`);
978
+ for (const line of skillspector.changed) console.log(` ${line}`);
979
+ }
980
+ if (skillspector.action === 'failed') {
981
+ console.log(`skill-scan: skillspector update failed, continuing with the installed copy — ${skillspector.message}`);
982
+ }
983
+ if (skillspector.available && skillspector.available !== skillspector.version) {
984
+ console.log(`skill-scan: skillspector ${skillspector.available} is available (installed: ${skillspector.version ?? 'unknown'})`);
985
+ }
766
986
  console.log(`skill-scan: ${ok ? (outcome.warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`);
767
987
  for (const entry of facts.skills) {
768
988
  console.log(` ${entry.name}: score ${entry.score} ${entry.severity} — ${entry.issues.length} finding(s)`);
@@ -27,7 +27,7 @@ Facts are your job; decisions are the user's. Gather these silently and present
27
27
  | native issue types | `gh api orgs/<org>/issue-types` — an `Epic` type routes parents to it; absent endpoint or type → the `epic` label fallback ([conventions](references/conventions.md)) |
28
28
  | Codex CLI (cross-agent review) | `command -v codex` — absent → record the gap in dev.md `## Environments` and recommend installing it |
29
29
  | agent skills in the repo | a directory holding skill folders, flat or one group deep — each folder carrying its own entry point — drafts the `skill-scan:` knob at that path; none found drafts `none`. Declare it **once**: a second `skill-scan:` line with a different value, even in a prose example, makes the profile ambiguous and the guard refuses. Where the project builds a flattened bundle the knob names the **built** directory, because unpackaged test fixtures are deliberately adversarial and score higher than anything that ships |
30
- | SkillSpector (skill scanning) | `command -v skillspector`absent while `skill-scan:` is set record it in `## Environments` with `uv tool install git+https://github.com/NVIDIA/skillspector.git`; the guard refuses rather than skipping, so this is a real gap |
30
+ | SkillSpector (skill scanning) | nothing to detectdev-review's guard locates the CLI through whatever channel holds it (uv, brew, pipx) and, under `skillspector-update: auto`, installs it when absent and upgrades it before each scan. Confirm the drafted `skillspector-update:` value with the operator instead: `auto` is the default and provisions silently, `notify` only reports what upstream published, `off` never touches the network |
31
31
 
32
32
  Not a git repo, or no origin remote → this is a **greenfield run, not an error**: follow the greenfield playbook in [stack-playbooks](references/stack-playbooks.md) — interview for the intended stack, offer `git init` and `gh repo create` each on its own yes, and render dev.md from the chosen playbook's conventions with TODO lines where machinery doesn't exist yet. A declined remote skips labels and records the TODO plainly.
33
33
 
@@ -49,7 +49,7 @@ Ask with your harness's question tool — AskUserQuestion in Claude Code, `reque
49
49
  - Playbook matched → show the drafted `## Ship` runbook (each step `auto:`, `ask:`, or `guard:` — every `guard:` line carries its runnable command inline), the `changelog:` convention, and the `release:` knob (per-merge or on-request) for confirmation; a keep-a-changelog convention with no CHANGELOG.md yet → offer to seed the skeleton; no machinery → "Ship: merge only" and move on
50
50
  - Guards drafted → offer to write their CI backstop steps into the project's workflow files (the local `guard:` lines run without CI); each file on the user's yes — release guards only, never general CI
51
51
  - Environments or run commands detected → confirm the drafted `## Environments` and `## Verify` bullets
52
- - Agent skills detected → confirm the drafted `skill-scan:` root, the `## Verify` bullet running the guard (preceded by the build command when the root is a build output), and a blocking `guard:` line in `## Ship` before the publish step — the published artifact is what the world installs. Skills detected but the operator declines the scan → `skill-scan: none`, said plainly, not silently omitted
52
+ - Agent skills detected → confirm the drafted `skill-scan:` root, the `skillspector-update:` value (`auto` by default — say plainly that it installs and upgrades the SkillSpector CLI on this machine without asking again, and that `notify` or `off` opt out), the `## Verify` bullet running the guard (preceded by the build command when the root is a build output), and a blocking `guard:` line in `## Ship` before the publish step — the published artifact is what the world installs. Skills detected but the operator declines the scan → `skill-scan: none`, said plainly, not silently omitted
53
53
  - Evidence repo (`ui-evidence: playwright`) → default is the owner's **shared** `<owner>/dev-review-evidence`; if it doesn't exist, offer `gh repo create <owner>/dev-review-evidence --private --add-readme` + the layout/retention README — created once, every project points at it. An org naming policy that rejects the name → pick the closest compliant name with the user and record it in the knob (the name is a knob value, not a contract)
54
54
  - App architecture detected → confirm the drafted `## Architecture` (hosting, stage, and kind are what detection usually can't fill — ask those); nothing detected → delete the section, the `stack:` line is enough
55
55
  - A legacy `.vegastack/arch.md` exists → fold its knob lines into `## Architecture`, offer each dated `notes:` line to the decision register on the user's yes, then offer to delete arch.md
@@ -15,6 +15,7 @@ evidence-repo: {{owner}}/dev-review-evidence # shared across projects; this pr
15
15
  gates: 3 # 3 = approve/PR/merge · 2 = approve + one "ship it" · 1 = direct-to-main, ship word merges locally and pushes, no PR
16
16
  tests: required # required | logic-only
17
17
  skill-scan: none # ONE line only — directory holding the agent skills to scan (the BUILT one where a bundle is generated) | none. dev-review's guard reads this and refuses if a second line gives a different value
18
+ skillspector-update: auto # off | notify | auto — how the SkillSpector CLI itself is kept current. auto installs it when absent and upgrades it before each scan, falling back to the installed copy on any failure; notify only reports what upstream has published; off never touches the network. ONE line only, same as above
18
19
  merge: squash
19
20
  branch: <type>/<issue>-<slug> # type: feat | fix | docs | chore | refactor — the only place this list lives
20
21
  labels: needs-operator needs-plan ready working for-operator risky research quick-build full-plan epic # epic label marks map parents only where the org has no native Epic issue type
@@ -12,10 +12,10 @@ Nearest neighbors: `dev-chronicle` answers "how did we get here"; this skill ans
12
12
  ## Gather
13
13
 
14
14
  ```
15
- node <path-to-this-skill>/scripts/status.mjs --stale-days 3 --json
15
+ node <path-to-this-skill>/scripts/status.mjs --orphan-hours 6 --json
16
16
  ```
17
17
 
18
- Read-only; it returns the board (open issues per state label with age, scope, risky), task progress `x/y` from plan-comment checkboxes, ledger movement for `working` issues (stale = no ledger edit within `--stale-days`, default 3), open PRs with check state, pending unrecorded `Decision:` proposals, and the last chronicle entry. Exit 2 = cannot verify (offline, unauthenticated) — report the gap plainly and stop; never render a guessed board.
18
+ Read-only; it returns the board (open issues per state label with age, scope, risky), task progress `x/y` from plan-comment checkboxes, ledger movement for `working` issues in hours (`possiblyOrphaned` = the ledger has been silent past `--orphan-hours`, default 6, or was never written — the claim's heartbeat has stopped), open PRs with check state, pending unrecorded `Decision:` proposals, and the last chronicle entry. Exit 2 = cannot verify (offline, unauthenticated) — report the gap plainly and stop; never render a guessed board.
19
19
 
20
20
  ## Render — names, never bare numbers
21
21
 
@@ -26,8 +26,8 @@ Needs you (N):
26
26
  - <linked title> — <state> <age>d: <one line: what it waits for and the word needed>
27
27
  Waiting on plan (N): - <linked title> — needs-plan <age>d
28
28
  Ready to build (N): - <linked title> — <scope>
29
- In flight (N): - <linked title> — working, task <x>/<y>, ledger moved <n>d ago
30
- Stale (N): - <linked title> — working <age>d, ledger silent <n>d → check or reclaim
29
+ In flight (N): - <linked title> — working, task <x>/<y>, ledger moved <n>h ago
30
+ Possibly orphaned (N): - <linked title> — working <age>d, ledger silent <n>hheartbeat stopped; check, resume, or reclaim (`reclaim.mjs --issue <n>`)
31
31
  Open PRs (N): - <linked title> — checks <green|pending-or-red|no-checks>
32
32
  Pending decisions (N): "<gist-plain>" (<linked issue>) — records at that issue's merge
33
33
  Last chronicle chapter: <date> — <title-plain>
@@ -38,9 +38,10 @@ Next: <the single most valuable operator action, and why>
38
38
  - Sections with zero entries are omitted, not rendered empty. A completely quiet board is one line: "Nothing needs you — <n> issues ready for agents, nothing in flight."
39
39
  - `risky` issues get their flag shown inline wherever they appear.
40
40
  - **Next** is one line, chosen not computed-looking: the action that unblocks the most (a plan approval blocking several ready issues beats a lone review).
41
+ - **Possibly orphaned** is the ledger heartbeat gone silent past the orphan window (or never started) — likely a dead session, not certainly one. Surface it with the `reclaim.mjs` command inline; the operator decides (check the session, hand it to a resume, or release the claim). A long-running task that keeps checkpointing never lands here.
41
42
  - <linked title> means a markdown link this report builds around the issue/PR title and its URL; numbers ride inside the link, never stand alone. That governs the references the board itself makes.
42
43
  - `<title-plain>` / `<gist-plain>` are the script's `titlePlain` / `gistPlain` fields — text quoted from elsewhere (a chronicle title, a decision gist) may arrive carrying markdown links, and raw bracket-and-parenthesis markup means nothing in a terminal, so it is quoted with the markup removed rather than relinked.
43
44
 
44
45
  ## Honesty rules
45
46
 
46
- Data comes only from the script; ordering, the wait-reason one-liners, and Next are the skill's judgment — clearly judgment, never dressed as data. A stale `working` issue is a fact to surface, not an accusation: "check or reclaim" is the operator's call (a takeover still needs their explicit handover, per dev-implement). Close with the plain-language summary only when invoked as part of a larger run; standalone, the report IS the summary.
47
+ Data comes only from the script; ordering, the wait-reason one-liners, and Next are the skill's judgment — clearly judgment, never dressed as data. A possibly-orphaned `working` issue is a fact to surface, not an accusation — the ledger heartbeat went silent, which is likely but not certainly a dead session: "check, resume, or reclaim" is the operator's call (a takeover still needs their explicit handover, and `reclaim.mjs` is theirs to run, per dev-implement). Close with the plain-language summary only when invoked as part of a larger run; standalone, the report IS the summary.
@@ -2,7 +2,7 @@
2
2
  // dev-status data gatherer: everything the board report needs, deterministically,
3
3
  // read-only, markers-only. The skill renders; this script never invents state.
4
4
  //
5
- // Usage: node status.mjs [--repo o/r] [--stale-days 3] [--dev-md <path>] --json
5
+ // Usage: node status.mjs [--repo o/r] [--orphan-hours 6] [--dev-md <path>] --json
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { resolve } from 'node:path';
@@ -46,6 +46,15 @@ export function ageDays(iso, now = Date.now()) {
46
46
  return Math.floor((now - Date.parse(iso)) / 86_400_000);
47
47
  }
48
48
 
49
+ // Ledger liveness is measured in hours, not days: a session that hands back in
50
+ // hours can go dark for a fraction of a day, which whole-day granularity cannot
51
+ // even represent. The ledger's updated_at is the only liveness proxy an agent
52
+ // session exposes — a live session (even a multi-day one) checkpoints and keeps
53
+ // this small; a dead one freezes it.
54
+ export function ageHours(iso, now = Date.now()) {
55
+ return Math.floor((now - Date.parse(iso)) / 3_600_000);
56
+ }
57
+
49
58
  export function parseMarker(body) {
50
59
  const match = /<!--\s*vsk:v1\s+([^>]*?)\s*-->/.exec(body ?? '');
51
60
  if (!match) return null;
@@ -110,7 +119,7 @@ export function checksState(rollup) {
110
119
  return rollup.every(ok) ? 'green' : 'pending-or-red';
111
120
  }
112
121
 
113
- export function gatherStatus({ repo, staleDays = 3, devMdPath = '.vegastack/dev.md', chroniclePath = '.vegastack/chronicle.md', now = Date.now() } = {}) {
122
+ export function gatherStatus({ repo, orphanHours = 6, devMdPath = '.vegastack/dev.md', chroniclePath = '.vegastack/chronicle.md', now = Date.now() } = {}) {
114
123
  const resolvedRepo = repo || gh(['repo', 'view', '--json', 'nameWithOwner']).nameWithOwner;
115
124
  const devMdText = existsSync(devMdPath) ? readFileSync(devMdPath, 'utf8') : '';
116
125
  const knobs = readKnobs(devMdText);
@@ -133,8 +142,12 @@ export function gatherStatus({ repo, staleDays = 3, devMdPath = '.vegastack/dev.
133
142
  const comments = gh(['api', `repos/${resolvedRepo}/issues/${issue.number}/comments`, '--paginate']);
134
143
  issue.tasks = taskProgress(comments);
135
144
  const moved = ledgerMovedAt(comments);
136
- issue.ledgerAgeDays = moved ? ageDays(moved, now) : null;
137
- issue.stale = bucket === knobs.states[3] && (issue.ledgerAgeDays === null || issue.ledgerAgeDays >= staleDays);
145
+ issue.ledgerAgeHours = moved ? ageHours(moved, now) : null;
146
+ // possiblyOrphaned: a working issue whose ledger has been silent past the
147
+ // orphan threshold — or which never got a ledger comment at all (claimed,
148
+ // then died before its first write). A fact for the operator to act on,
149
+ // never an automatic reclaim: the reset is theirs to run.
150
+ issue.possiblyOrphaned = bucket === knobs.states[3] && (issue.ledgerAgeHours === null || issue.ledgerAgeHours >= orphanHours);
138
151
  decisions.push(...pendingDecisions(comments, registerText).map((d) => ({ issue: issue.number, gist: d, gistPlain: stripLinks(d) })));
139
152
  }
140
153
  }
@@ -151,7 +164,7 @@ export function gatherStatus({ repo, staleDays = 3, devMdPath = '.vegastack/dev.
151
164
  if (m) lastChronicle = { date: m[1], title: m[2], titlePlain: stripLinks(m[2]) };
152
165
  }
153
166
 
154
- return { repo: resolvedRepo, staleDays, board, prs, pendingDecisions: decisions, lastChronicle };
167
+ return { repo: resolvedRepo, orphanHours, board, prs, pendingDecisions: decisions, lastChronicle };
155
168
  }
156
169
 
157
170
  const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -159,8 +172,8 @@ if (invokedDirectly) {
159
172
  const argv = process.argv.slice(2);
160
173
  const get = (f) => { const i = argv.indexOf(f); return i === -1 ? undefined : argv[i + 1]; };
161
174
  try {
162
- const staleDaysRaw = Number(get('--stale-days'));
163
- const data = gatherStatus({ repo: get('--repo'), staleDays: Number.isFinite(staleDaysRaw) && staleDaysRaw >= 1 ? staleDaysRaw : 3, devMdPath: get('--dev-md') });
175
+ const orphanRaw = Number(get('--orphan-hours'));
176
+ const data = gatherStatus({ repo: get('--repo'), orphanHours: Number.isFinite(orphanRaw) && orphanRaw >= 1 ? orphanRaw : 6, devMdPath: get('--dev-md') });
164
177
  console.log(JSON.stringify(data, null, argv.includes('--json') ? 2 : 0));
165
178
  } catch (error) {
166
179
  console.error(`status: cannot verify — ${error.message}`);
@@ -48,15 +48,16 @@
48
48
  "group": "dev-skills",
49
49
  "repoOnly": false,
50
50
  "files": {
51
- "SKILL.md": "f79f4d312f6c03ffdcf2e2fd44169e48520445900389e2d469c0946811be3801",
51
+ "SKILL.md": "1b38469b3fc0feedf8650ad1f9bfda35485406dc81de89a66e5ab57c670bc798",
52
52
  "agents/openai.yaml": "1abab383deca54d32749fd9156f9a6211fe96e02e0166dc8a16ff8459ac31b74",
53
53
  "references/conventions.md": "95bf916cc11f95f3d5eefcb34db4fbc05d88ae1c8f08869800dfa417f8da17ed",
54
- "references/ledger-and-resume.md": "5e3deae2af67735940564e04adcbc23c0c0dc86da2f6c80d68b8c1207c8f5900",
54
+ "references/ledger-and-resume.md": "974fe4895973aca4ce05ad3eca05c71f1bbb518a59027732c459cc3331fb2f98",
55
55
  "refresh/REFRESH.md": "7b854109d897b78b3055bf1ab89517afdca65c12eb65a4d009141e3ee11cf58f",
56
56
  "refresh/sources.json": "7bd883e5b3512e236a909ad6ff20c97f3555785867c31fd728256c7c2c514c74",
57
- "scripts/evidence-check.mjs": "9a360e6c5460d46b271b28457ee6be92f165c54c771c2dc6860c4a52e6890a9a",
57
+ "scripts/evidence-check.mjs": "974e343ae5372e8b3c930d4ba05e48b29ea5204048f9d2c3cdc8b89237933d8c",
58
58
  "scripts/lib/gh.mjs": "d2261e4b3e2d7b3cdc58123b1503c0264664d7e5a258e90d0caa79333466975a",
59
- "scripts/preflight.mjs": "1bf47b6d147c8603e28d96f7c81f2b4db61b60a3811190ca96b36d83129820d5"
59
+ "scripts/preflight.mjs": "1bf47b6d147c8603e28d96f7c81f2b4db61b60a3811190ca96b36d83129820d5",
60
+ "scripts/reclaim.mjs": "fea8299765ded7052a724e55399f40a1769d91f218df7c38922859800065fa73"
60
61
  }
61
62
  },
62
63
  "dev-intake": {
@@ -89,26 +90,27 @@
89
90
  "group": "dev-skills",
90
91
  "repoOnly": false,
91
92
  "files": {
92
- "SKILL.md": "561e0232d20fc7e6893b38a4c3d7deb41862d60d7b545a98d3ce7ba1d3c7ac06",
93
+ "SKILL.md": "bb5f2f34d7342052c5c97b628d11976fafc61996c320f568f863c466606284bf",
93
94
  "agents/openai.yaml": "3f35d3dc60f179d4ac2526997c4d10173ba3018d06f0dbe77e209ce2978fa702",
94
95
  "assets/review-known-patterns.md.template": "7747b807ed18c4c8bf8acdc6481b2f7f27914d932d757c7243a32a41a18d32c5",
95
96
  "references/conventions.md": "95bf916cc11f95f3d5eefcb34db4fbc05d88ae1c8f08869800dfa417f8da17ed",
96
97
  "references/cross-agent.md": "3541715d0563703373ea3d93582119b80356d1cbdee19ce17f958c0f7c914884",
97
98
  "references/dispatch-prompts.md": "cb8134a7b6ff9e20ec4db02d0af2f8767f71bf13fa9a21b28b7a586c2747ec1d",
98
99
  "references/security-axis.md": "e207071fc48f761f2deb24c73ef4f5ebcf28c130264dab7c3061ae6c0eee6543",
99
- "refresh/REFRESH.md": "242c1efe630327abe12bf9db4c4085aaf05b262a4dbd885b12b0f29cdd4a285d",
100
- "refresh/sources.json": "a7ba2340964dc70410626087686145b5790a0e763a18ef2c4d9aa37e950f2e04",
101
- "scripts/skill-scan.mjs": "2d74ebd63ffd66cca5208b5dab2984ace47ef4e832e188f6811ff7c2e303095d"
100
+ "refresh/REFRESH.md": "ae22dc0ecf756310f4c3aa7e2b20d13b94fb26595fb3b4432eb26cbc6fd7b27d",
101
+ "refresh/sources.json": "2bae54e726ecb43b778eabe5b62bde51d5725d2a58e700940727f275cd5afe16",
102
+ "scripts/lib/skillspector.mjs": "f4311c0ca61d3a54bf3012817b317667ae783c0ccd8ffc22b5fcb8674e9f937e",
103
+ "scripts/skill-scan.mjs": "6638c4ea9aaa29524c58946ea69a4149a156cd39491f5c30f028e53ac402f31c"
102
104
  }
103
105
  },
104
106
  "dev-setup": {
105
107
  "group": "dev-skills",
106
108
  "repoOnly": false,
107
109
  "files": {
108
- "SKILL.md": "4b29049b8ce165fb03f554c8784e095cbe97cef16752b57b86321352a1017ce7",
110
+ "SKILL.md": "17140c694fa2c0fe231f94df85863d2ed69876f61a31a719ca1458821367373e",
109
111
  "agents/openai.yaml": "be790c04fdf910e71642cfadf1a7c07c217bf7fad18581610feb4c31a79651f2",
110
112
  "assets/agents-section.md.template": "6c9591d50f73e1e05e78c8969c7836767432cf32487af7205b2cb9f4a410440e",
111
- "assets/dev-profile.md.template": "26e244b488d181e23ff4b49addd9ab519c8f5d43e2d914638033648a7ca5bd3a",
113
+ "assets/dev-profile.md.template": "b81517d1b982f5d69e4416e939e2935191adbc396dba8c75d18767672fd95e28",
112
114
  "references/conventions.md": "95bf916cc11f95f3d5eefcb34db4fbc05d88ae1c8f08869800dfa417f8da17ed",
113
115
  "references/harness-facts.md": "2be1d81f6a616d40dc865aaeefaf53c61bc13ade4ba1de8fb406c520bf15be40",
114
116
  "references/stack-playbooks.md": "8f457c3d0f96e3c99d745b04332590fdf8af53a9694541181d9c878ae0718ea2",
@@ -133,12 +135,12 @@
133
135
  "group": "dev-skills",
134
136
  "repoOnly": false,
135
137
  "files": {
136
- "SKILL.md": "56415c2f69bb1a2fcb488b9846a20e632eb902b05c5b2b58ae9206d3eaffd77b",
138
+ "SKILL.md": "1e4742805f8f6c99f36e3eea4171b39e336d14aad001d5309b507bf6efa40ae7",
137
139
  "agents/openai.yaml": "72435947f5afef0c8a2c170e987b14fe6057b174318c16b6b538ae8d129791fb",
138
140
  "references/conventions.md": "95bf916cc11f95f3d5eefcb34db4fbc05d88ae1c8f08869800dfa417f8da17ed",
139
141
  "refresh/REFRESH.md": "52aee9bae64d8342e127704d1db282e43ed57c101148727bf10172d6cb975bfa",
140
142
  "refresh/sources.json": "a7ba2340964dc70410626087686145b5790a0e763a18ef2c4d9aa37e950f2e04",
141
- "scripts/status.mjs": "bc56d0b5d996676cfccb752d9d77d0e9e7d64932741cd39d6a503e9761fbe34d"
143
+ "scripts/status.mjs": "f6672ef38cbe31a2335633a96567977d9b885390f1f766bf63f6dd7d172c987b"
142
144
  }
143
145
  },
144
146
  "skill-maintainer": {