@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 +1 -1
- package/skill/dev-implement/SKILL.md +4 -4
- package/skill/dev-implement/references/ledger-and-resume.md +4 -2
- package/skill/dev-implement/scripts/evidence-check.mjs +50 -2
- package/skill/dev-implement/scripts/reclaim.mjs +95 -0
- package/skill/dev-review/SKILL.md +2 -0
- package/skill/dev-review/refresh/REFRESH.md +16 -1
- package/skill/dev-review/refresh/sources.json +130 -3
- package/skill/dev-review/scripts/lib/skillspector.mjs +221 -0
- package/skill/dev-review/scripts/skill-scan.mjs +227 -7
- package/skill/dev-setup/SKILL.md +2 -2
- package/skill/dev-setup/assets/dev-profile.md.template +1 -0
- package/skill/dev-status/SKILL.md +6 -5
- package/skill/dev-status/scripts/status.mjs +20 -7
- package/skill-integrity.json +14 -12
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
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(
|
|
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.
|
|
535
|
-
|
|
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)`);
|
package/skill/dev-setup/SKILL.md
CHANGED
|
@@ -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) |
|
|
30
|
+
| SkillSpector (skill scanning) | nothing to detect — dev-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 --
|
|
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 (
|
|
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>
|
|
30
|
-
|
|
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>h → heartbeat 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
|
|
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] [--
|
|
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,
|
|
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.
|
|
137
|
-
|
|
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,
|
|
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
|
|
163
|
-
const data = gatherStatus({ repo: get('--repo'),
|
|
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}`);
|
package/skill-integrity.json
CHANGED
|
@@ -48,15 +48,16 @@
|
|
|
48
48
|
"group": "dev-skills",
|
|
49
49
|
"repoOnly": false,
|
|
50
50
|
"files": {
|
|
51
|
-
"SKILL.md": "
|
|
51
|
+
"SKILL.md": "1b38469b3fc0feedf8650ad1f9bfda35485406dc81de89a66e5ab57c670bc798",
|
|
52
52
|
"agents/openai.yaml": "1abab383deca54d32749fd9156f9a6211fe96e02e0166dc8a16ff8459ac31b74",
|
|
53
53
|
"references/conventions.md": "95bf916cc11f95f3d5eefcb34db4fbc05d88ae1c8f08869800dfa417f8da17ed",
|
|
54
|
-
"references/ledger-and-resume.md": "
|
|
54
|
+
"references/ledger-and-resume.md": "974fe4895973aca4ce05ad3eca05c71f1bbb518a59027732c459cc3331fb2f98",
|
|
55
55
|
"refresh/REFRESH.md": "7b854109d897b78b3055bf1ab89517afdca65c12eb65a4d009141e3ee11cf58f",
|
|
56
56
|
"refresh/sources.json": "7bd883e5b3512e236a909ad6ff20c97f3555785867c31fd728256c7c2c514c74",
|
|
57
|
-
"scripts/evidence-check.mjs": "
|
|
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": "
|
|
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": "
|
|
100
|
-
"refresh/sources.json": "
|
|
101
|
-
"scripts/
|
|
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": "
|
|
110
|
+
"SKILL.md": "17140c694fa2c0fe231f94df85863d2ed69876f61a31a719ca1458821367373e",
|
|
109
111
|
"agents/openai.yaml": "be790c04fdf910e71642cfadf1a7c07c217bf7fad18581610feb4c31a79651f2",
|
|
110
112
|
"assets/agents-section.md.template": "6c9591d50f73e1e05e78c8969c7836767432cf32487af7205b2cb9f4a410440e",
|
|
111
|
-
"assets/dev-profile.md.template": "
|
|
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": "
|
|
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": "
|
|
143
|
+
"scripts/status.mjs": "f6672ef38cbe31a2335633a96567977d9b885390f1f766bf63f6dd7d172c987b"
|
|
142
144
|
}
|
|
143
145
|
},
|
|
144
146
|
"skill-maintainer": {
|