session-orchestrator 3.23.0 → 3.24.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Check: every `scripts/**.mjs` path cited in `skills/`, `commands/` and
|
|
4
|
+
* `agents/` either EXISTS or is annotated as deliberately absent (#1176).
|
|
5
|
+
* Extended (#1187) to also cite `scripts/**.sh` and `hooks/**.sh` — see
|
|
6
|
+
* "## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" below for why that half
|
|
7
|
+
* is advisory, not blocking.
|
|
8
|
+
*
|
|
9
|
+
* ## Why
|
|
10
|
+
*
|
|
11
|
+
* Prose is not executed. A skill body that tells the coordinator to run
|
|
12
|
+
* `node scripts/lib/auto-commit.mjs` costs an operator a failed command and a
|
|
13
|
+
* re-derivation of what the file was supposed to do — and nothing in the
|
|
14
|
+
* corpus notices, because a markdown file compiles under every gate. Measured
|
|
15
|
+
* 2026-09-02 @ c3ab480: 237 distinct citations across the three scan roots,
|
|
16
|
+
* 7 of them dead.
|
|
17
|
+
*
|
|
18
|
+
* ## Fences are skipped, and that is most of the answer
|
|
19
|
+
*
|
|
20
|
+
* 4 of those 7 sat inside fenced code blocks — synthetic example paths
|
|
21
|
+
* (`scripts/example.mjs`, `scripts/lib/a.mjs`) in a snippet demonstrating a
|
|
22
|
+
* command's argument shape. A fenced snippet is an illustration of a FORM, not
|
|
23
|
+
* a claim that a file exists, so the shared fence tracker
|
|
24
|
+
* (`./markdown-fences.mjs`, #1181) silences them structurally
|
|
25
|
+
* rather than by allowlist.
|
|
26
|
+
*
|
|
27
|
+
* ## Annotation, and why placement is a rule rather than a convenience
|
|
28
|
+
*
|
|
29
|
+
* A citation in PROSE is a claim. When the claim is deliberate — a planned file
|
|
30
|
+
* behind an issue, a historical path kept for narrative, an inline example —
|
|
31
|
+
* say so on the line:
|
|
32
|
+
*
|
|
33
|
+
* <!-- path-check: planned #214 -->
|
|
34
|
+
* <!-- path-check: historical -->
|
|
35
|
+
* <!-- path-check: example -->
|
|
36
|
+
*
|
|
37
|
+
* The marker is honoured on the SAME line as the citation, or on the line
|
|
38
|
+
* IMMEDIATELY above WHEN THAT LINE CITES NOTHING ITSELF — nowhere else. A line
|
|
39
|
+
* carrying `citation + marker` exempts only that citation; it does not reach
|
|
40
|
+
* down to the next line, which would silently exempt a citation nobody
|
|
41
|
+
* annotated. Two lines above is INERT and the citation
|
|
42
|
+
* still reports, which is pinned by a test. The reason is the rule
|
|
43
|
+
* `recurring-issue-an-exemption-marker-that-only-works-same-line-is-visually-identical-to-one-in-a-comment-block-3bff005.md`
|
|
44
|
+
* in `.claude/rules/`:
|
|
45
|
+
* a marker that reads like an exemption but changes nothing is worse than no
|
|
46
|
+
* marker at all, because the guard then looks wrong instead of the marker
|
|
47
|
+
* looking misplaced. A malformed marker (unknown class, or `planned` without a
|
|
48
|
+
* `#<iid>`) is itself a finding for the same reason — it must never fail silent.
|
|
49
|
+
*
|
|
50
|
+
* ## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh` (#1187)
|
|
51
|
+
*
|
|
52
|
+
* Unlike `check-doc-cli-commands.mjs`, the oracle here is the repository's own
|
|
53
|
+
* filesystem, not a locally installed third-party binary — there is no version
|
|
54
|
+
* skew that could red an unrelated commit. So `.mjs` findings are `FAIL:` and
|
|
55
|
+
* the check returns non-zero, EXACTLY as before this module grew a second
|
|
56
|
+
* extension.
|
|
57
|
+
*
|
|
58
|
+
* The `.sh` half of the citation grammar (below) does not get that same
|
|
59
|
+
* severity by default. A #1176 repo-wide grep (`scripts/hooks` prose across
|
|
60
|
+
* `skills/commands/agents/docs/hooks`) found 27 distinct `.sh` citations, 21
|
|
61
|
+
* dead — but only ONE of those 27 sits inside this checker's three scan roots
|
|
62
|
+
* (`skills/contract-version-bump/SKILL.md:134`, itself arguably a
|
|
63
|
+
* cross-repo path — see the dry-run note at `scanSkillScriptPaths`'s
|
|
64
|
+
* `strictSh` option). The other 26 live in `docs/`, which this checker does
|
|
65
|
+
* NOT scan and — per this same paragraph's own evidence — MUST NOT start
|
|
66
|
+
* scanning as a side effect of the `.sh` extension: `docs/adr/*.md` alone
|
|
67
|
+
* carries 7 dead `.mjs` citations of its own (all historical/planned ADR
|
|
68
|
+
* prose, e.g. `scripts/lib/tool-adapter.mjs`, `scripts/lib/auto-commit.mjs`),
|
|
69
|
+
* none annotated, all outside this task's edit scope. Widening `SCAN_DIRS` to
|
|
70
|
+
* `docs` would turn those 7 into new BLOCKING findings on a doc surface
|
|
71
|
+
* nobody triaged — the opposite of "the `.mjs` behaviour stays exactly as
|
|
72
|
+
* today". So `SCAN_DIRS` stays `['skills', 'commands', 'agents']`; the wider
|
|
73
|
+
* `docs`/`hooks` prose census is a follow-up for whoever owns those files,
|
|
74
|
+
* not a silent scope change here.
|
|
75
|
+
*
|
|
76
|
+
* A `.sh` finding is therefore `WARN:` by default (visible, never blocking —
|
|
77
|
+
* `ok` and the CLI exit code ignore `severity: 'warn'` findings) and only
|
|
78
|
+
* becomes `FAIL:`/blocking under the `--strict-sh` CLI flag (or
|
|
79
|
+
* `strictSh: true` for `scanSkillScriptPaths()` callers) — flip that default
|
|
80
|
+
* once the dead `.sh` citations this checker CAN see are fixed by their doc
|
|
81
|
+
* owner (BV-004 revisit trigger).
|
|
82
|
+
*
|
|
83
|
+
* @module scripts/lib/validate/check-skill-script-paths
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
87
|
+
import path from 'node:path';
|
|
88
|
+
import { pathToFileURL } from 'node:url';
|
|
89
|
+
import { listRepoFiles } from './repo-files.mjs';
|
|
90
|
+
import { forEachLine } from './markdown-fences.mjs';
|
|
91
|
+
|
|
92
|
+
/** Documentation roots whose prose is treated as a claim about the repo. */
|
|
93
|
+
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents']);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A cited script path. One regex, one alternation, reused for every
|
|
97
|
+
* extension rather than a second scanner (#1187): `scripts/**.mjs` (the
|
|
98
|
+
* original, still the only `.mjs` root scanned), `scripts/**.sh` and
|
|
99
|
+
* `hooks/**.sh`. `hooks/**.mjs` is deliberately NOT part of this grammar —
|
|
100
|
+
* the `.mjs` half of the citation surface stays exactly `scripts/`, matching
|
|
101
|
+
* every existing annotation and fence-skip test unchanged.
|
|
102
|
+
*/
|
|
103
|
+
const CITATION_RE = /scripts\/[a-zA-Z0-9_/-]*\.(?:mjs|sh)|hooks\/[a-zA-Z0-9_/-]*\.sh/g;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Filename fragments that mark a citation as an ILLUSTRATIVE placeholder —
|
|
107
|
+
* `scripts/example.sh`, `hooks/my-hook.sh`, `scripts/<name>.sh` — rather than
|
|
108
|
+
* a claim that a real file exists. Checked only for a citation that already
|
|
109
|
+
* failed `existsSync` (a real file is never suppressed by this list, no
|
|
110
|
+
* matter what it's named). Recognised automatically, with no marker needed,
|
|
111
|
+
* because #1176 found 6 such `hooks/*.mjs` example names in hook-development
|
|
112
|
+
* prose (`hooks/example.mjs`, `guard.mjs`, `my-hook.mjs`, …) that would
|
|
113
|
+
* otherwise all need a hand-written `<!-- path-check: example -->` on every
|
|
114
|
+
* occurrence.
|
|
115
|
+
*
|
|
116
|
+
* Ceiling (BV-004): exactly these six fragments, case-insensitive substring
|
|
117
|
+
* match. A REAL path that happens to contain one of them (`scripts/lib/
|
|
118
|
+
* foobar-report.mjs`, `hooks/my-guard.sh`) is indistinguishable from a
|
|
119
|
+
* placeholder by this heuristic and would be silently swallowed if it were
|
|
120
|
+
* ever cited before being created. Revisit by shrinking this list (never
|
|
121
|
+
* growing it further) the moment that collision is observed for real — the
|
|
122
|
+
* escape hatch until then is the same `<!-- path-check: planned #<iid> -->`
|
|
123
|
+
* marker every other deliberate citation already uses.
|
|
124
|
+
*/
|
|
125
|
+
const PLACEHOLDER_FRAGMENTS = Object.freeze(['example', 'my-', '<', 'placeholder', 'foo', 'bar']);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Is `citedPath` an illustrative placeholder name rather than a real path?
|
|
129
|
+
*
|
|
130
|
+
* @param {string} citedPath
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
export function isPlaceholderCitation(citedPath) {
|
|
134
|
+
const lower = citedPath.toLowerCase();
|
|
135
|
+
return PLACEHOLDER_FRAGMENTS.some((fragment) => lower.includes(fragment));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The annotation marker, in any of its three classes. */
|
|
139
|
+
const ANNOTATION_RE = /<!--\s*path-check:\s*([^>]*?)\s*-->/;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Judge one annotation payload.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} payload the text between `path-check:` and `-->`
|
|
145
|
+
* @returns {{ok: boolean, class: string}}
|
|
146
|
+
*/
|
|
147
|
+
export function classifyAnnotation(payload) {
|
|
148
|
+
const text = payload.trim();
|
|
149
|
+
if (text === 'historical' || text === 'example') return { ok: true, class: text };
|
|
150
|
+
const planned = text.match(/^planned\s+#(\d+)$/);
|
|
151
|
+
if (planned) return { ok: true, class: `planned #${planned[1]}` };
|
|
152
|
+
return { ok: false, class: text };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Split a markdown body into citations and annotations, both OUTSIDE fences.
|
|
157
|
+
*
|
|
158
|
+
* The fence automaton is `./markdown-fences.mjs` (#1181 — one tracker
|
|
159
|
+
* shared with `check-doc-cli-commands.mjs` and
|
|
160
|
+
* `check-vcs-repo-flag.mjs`): a fence opens on ``` / ~~~ with an optional
|
|
161
|
+
* info string and closes on the same character, at least as long, with no
|
|
162
|
+
* info string.
|
|
163
|
+
*
|
|
164
|
+
* Two properties are load-bearing because the automaton fails OPEN:
|
|
165
|
+
*
|
|
166
|
+
* 1. A fence that never closes swallows the whole rest of the file. That is a
|
|
167
|
+
* doc defect in its own right (`unbalanced-fence`), so it is REPORTED —
|
|
168
|
+
* and the swallowed tail is re-read as prose, so a dead citation hiding
|
|
169
|
+
* behind the unmatched opener still surfaces instead of being silenced by
|
|
170
|
+
* the very defect that made it invisible. Measured on
|
|
171
|
+
* `agents/db-specialist.md`, where a stray closing fence opened a block
|
|
172
|
+
* that ran to EOF and blinded the last 41 lines.
|
|
173
|
+
* 2. A fence inside a blockquote (`> ```) is a fence. Without stripping the
|
|
174
|
+
* `>` chain first, a quoted fenced example is read as prose and its
|
|
175
|
+
* illustrative paths are reported — a false red, the fail-CLOSED mirror of
|
|
176
|
+
* the same blind spot.
|
|
177
|
+
*
|
|
178
|
+
* @param {string[]} lines body split on `\n`
|
|
179
|
+
* @returns {{citations: {line: number, path: string}[], annotations: Map<number, {ok: boolean, class: string, raw: string}>, unbalancedFence: {line: number} | null}}
|
|
180
|
+
*/
|
|
181
|
+
export function extractCitations(lines) {
|
|
182
|
+
/** @type {{line: number, path: string}[]} */
|
|
183
|
+
const citations = [];
|
|
184
|
+
/** @type {Map<number, {ok: boolean, class: string, raw: string}>} */
|
|
185
|
+
const annotations = new Map();
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read one line as prose.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} raw the line
|
|
191
|
+
* @param {number} lineNumber its 1-based position
|
|
192
|
+
*/
|
|
193
|
+
const collect = (raw, lineNumber) => {
|
|
194
|
+
const annotation = raw.match(ANNOTATION_RE);
|
|
195
|
+
if (annotation) {
|
|
196
|
+
annotations.set(lineNumber, { ...classifyAnnotation(annotation[1]), raw: annotation[0] });
|
|
197
|
+
}
|
|
198
|
+
for (const hit of raw.matchAll(CITATION_RE)) {
|
|
199
|
+
citations.push({ line: lineNumber, path: hit[0] });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// A blockquoted fence is still a fence — the shared tracker strips the `>`
|
|
204
|
+
// chain before detection so the quoted example's body stays fenced.
|
|
205
|
+
const { unbalancedFenceLine } = forEachLine(
|
|
206
|
+
lines.join('\n'),
|
|
207
|
+
(raw, { lineNumber, inFence }) => {
|
|
208
|
+
if (inFence) return;
|
|
209
|
+
collect(raw, lineNumber);
|
|
210
|
+
},
|
|
211
|
+
{ stripBlockquotes: true },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (unbalancedFenceLine === null) return { citations, annotations, unbalancedFence: null };
|
|
215
|
+
|
|
216
|
+
// EOF with the fence still open: never swallow silently. Re-read the tail as
|
|
217
|
+
// prose so the citations the defect hid are reported alongside it.
|
|
218
|
+
for (let index = unbalancedFenceLine; index < lines.length; index += 1) collect(lines[index], index + 1);
|
|
219
|
+
return { citations, annotations, unbalancedFence: { line: unbalancedFenceLine } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Census the documentation corpus for dead `scripts/**.mjs`/`.sh` and
|
|
224
|
+
* `hooks/**.sh` citations.
|
|
225
|
+
*
|
|
226
|
+
* @param {{pluginRoot: string, dirs?: string[], strictSh?: boolean}} options
|
|
227
|
+
* `strictSh` (default `false`) promotes a dead `.sh` citation from
|
|
228
|
+
* `severity: 'warn'` to `severity: 'fail'` — see the module docblock
|
|
229
|
+
* "Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" for why the default stays
|
|
230
|
+
* advisory in this release.
|
|
231
|
+
* @returns {{ok: boolean, summary: object, findings: {kind: string, file: string, line: number, path: string, annotation: string | null, message: string, severity: 'fail' | 'warn'}[], toolError: boolean}}
|
|
232
|
+
*/
|
|
233
|
+
export function scanSkillScriptPaths({ pluginRoot, dirs = SCAN_DIRS, strictSh = false }) {
|
|
234
|
+
/** @type {{kind: string, file: string, line: number, path: string, annotation: string | null, message: string, severity: 'fail' | 'warn'}[]} */
|
|
235
|
+
const findings = [];
|
|
236
|
+
const summary = {
|
|
237
|
+
filesScanned: 0,
|
|
238
|
+
citations: 0,
|
|
239
|
+
existing: 0,
|
|
240
|
+
annotated: 0,
|
|
241
|
+
placeholders: 0,
|
|
242
|
+
findings: 0,
|
|
243
|
+
warnings: 0,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** @type {string[]} */
|
|
247
|
+
let files;
|
|
248
|
+
try {
|
|
249
|
+
// The git index, never a `readdirSync` walk (#1143): a walk cannot see
|
|
250
|
+
// `.gitignore`, so a worktree under `.claude/worktrees/` or any ignored
|
|
251
|
+
// artefact would enter this census as if it were repository documentation.
|
|
252
|
+
files = listRepoFiles(pluginRoot, { dirs, exts: ['.md'] });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
findings.push({
|
|
255
|
+
kind: 'tool-error',
|
|
256
|
+
file: '-',
|
|
257
|
+
line: 0,
|
|
258
|
+
path: '-',
|
|
259
|
+
annotation: null,
|
|
260
|
+
message: `cannot enumerate the scan corpus: ${error instanceof Error ? error.message : String(error)}`,
|
|
261
|
+
severity: 'fail',
|
|
262
|
+
});
|
|
263
|
+
return { ok: false, summary, findings, toolError: true };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const absolute of files) {
|
|
267
|
+
const relative = path.relative(pluginRoot, absolute);
|
|
268
|
+
/** @type {string} */
|
|
269
|
+
let body;
|
|
270
|
+
try {
|
|
271
|
+
body = readFileSync(absolute, 'utf8');
|
|
272
|
+
} catch (error) {
|
|
273
|
+
findings.push({
|
|
274
|
+
kind: 'tool-error',
|
|
275
|
+
file: relative,
|
|
276
|
+
line: 0,
|
|
277
|
+
path: '-',
|
|
278
|
+
annotation: null,
|
|
279
|
+
message: `cannot read: ${error instanceof Error ? error.message : String(error)}`,
|
|
280
|
+
severity: 'fail',
|
|
281
|
+
});
|
|
282
|
+
return { ok: false, summary, findings, toolError: true };
|
|
283
|
+
}
|
|
284
|
+
summary.filesScanned += 1;
|
|
285
|
+
|
|
286
|
+
const { citations, annotations, unbalancedFence } = extractCitations(body.split('\n'));
|
|
287
|
+
if (unbalancedFence) {
|
|
288
|
+
findings.push({
|
|
289
|
+
kind: 'unbalanced-fence',
|
|
290
|
+
file: relative,
|
|
291
|
+
line: unbalancedFence.line,
|
|
292
|
+
path: '-',
|
|
293
|
+
annotation: null,
|
|
294
|
+
message:
|
|
295
|
+
'a code fence opens here and never closes — every line below it is invisible to this ' +
|
|
296
|
+
'check (a fence closes only with the same character, at least as long, and no info ' +
|
|
297
|
+
'string); close it or remove the stray marker',
|
|
298
|
+
severity: 'fail',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// Which lines carry a citation of their own. A marker that sits on such a
|
|
302
|
+
// line is that citation's OWN exemption and must not also reach downward.
|
|
303
|
+
const citedLines = new Set(citations.map((c) => c.line));
|
|
304
|
+
|
|
305
|
+
// A malformed marker is reported wherever it stands, even with nothing to
|
|
306
|
+
// exempt: it reads as an exemption and grants none.
|
|
307
|
+
for (const [line, annotation] of annotations) {
|
|
308
|
+
if (annotation.ok) continue;
|
|
309
|
+
findings.push({
|
|
310
|
+
kind: 'bad-annotation',
|
|
311
|
+
file: relative,
|
|
312
|
+
line,
|
|
313
|
+
path: '-',
|
|
314
|
+
annotation: annotation.raw,
|
|
315
|
+
message:
|
|
316
|
+
`malformed marker \`${annotation.raw}\` — expected \`path-check: planned #<iid>\`, ` +
|
|
317
|
+
'`path-check: historical` or `path-check: example`',
|
|
318
|
+
severity: 'fail',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const citation of citations) {
|
|
323
|
+
summary.citations += 1;
|
|
324
|
+
if (existsSync(path.join(pluginRoot, citation.path))) {
|
|
325
|
+
summary.existing += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
// An illustrative placeholder name needs no marker — see
|
|
329
|
+
// `isPlaceholderCitation`'s docblock for the closed fragment list and
|
|
330
|
+
// its named ceiling.
|
|
331
|
+
if (isPlaceholderCitation(citation.path)) {
|
|
332
|
+
summary.placeholders += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// Same line, or the line immediately above — and the line above only
|
|
336
|
+
// when it carries NO citation itself. A `citation + marker` line is one
|
|
337
|
+
// self-contained exemption; letting it also cover the next line silently
|
|
338
|
+
// exempts a dead citation nobody ever annotated (the live shape at
|
|
339
|
+
// skills/wave-executor/wave-loop.md's `example` marker).
|
|
340
|
+
const above = citedLines.has(citation.line - 1)
|
|
341
|
+
? undefined
|
|
342
|
+
: annotations.get(citation.line - 1);
|
|
343
|
+
const marker = annotations.get(citation.line) ?? above;
|
|
344
|
+
if (marker?.ok) {
|
|
345
|
+
summary.annotated += 1;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (marker && !marker.ok) continue; // already reported as bad-annotation
|
|
349
|
+
|
|
350
|
+
// `.mjs` is blocking exactly as before this module grew a `.sh` half.
|
|
351
|
+
// `.sh` is advisory (`warn`) unless the caller opted into `strictSh`.
|
|
352
|
+
const isSh = path.extname(citation.path) === '.sh';
|
|
353
|
+
const severity = isSh && !strictSh ? 'warn' : 'fail';
|
|
354
|
+
if (severity === 'warn') summary.warnings += 1;
|
|
355
|
+
findings.push({
|
|
356
|
+
kind: 'missing-path',
|
|
357
|
+
file: relative,
|
|
358
|
+
line: citation.line,
|
|
359
|
+
path: citation.path,
|
|
360
|
+
annotation: null,
|
|
361
|
+
message:
|
|
362
|
+
(isSh
|
|
363
|
+
? severity === 'warn'
|
|
364
|
+
? `\`${citation.path}\` does not exist (advisory — .sh citations do not block ` +
|
|
365
|
+
'validate-plugin until re-run with --strict-sh; see #1187) — '
|
|
366
|
+
: `\`${citation.path}\` does not exist (--strict-sh) — `
|
|
367
|
+
: `\`${citation.path}\` does not exist — `) +
|
|
368
|
+
'create it, fix the path, or annotate the citation with ' +
|
|
369
|
+
'`<!-- path-check: planned #<iid> | historical | example -->` on this line or the ' +
|
|
370
|
+
'line directly above',
|
|
371
|
+
severity,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
377
|
+
summary.findings = findings.length;
|
|
378
|
+
const blocking = findings.filter((f) => f.severity !== 'warn');
|
|
379
|
+
return { ok: blocking.length === 0, summary, findings, toolError: false };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Run the human-readable validator CLI.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} pluginRoot absolute plugin root
|
|
386
|
+
* @returns {number} 0 = clean, 1 = findings, 2 = tool error
|
|
387
|
+
*/
|
|
388
|
+
export function runCheckSkillScriptPaths(pluginRoot, { strictSh = false } = {}) {
|
|
389
|
+
console.log('--- Check: scripts/*.mjs (+ *.sh) paths cited in skills/commands/agents exist ---');
|
|
390
|
+
const inspection = scanSkillScriptPaths({ pluginRoot, strictSh });
|
|
391
|
+
|
|
392
|
+
for (const item of inspection.findings) {
|
|
393
|
+
// A `warn`-severity finding (a `.sh` citation, non-strict mode) is
|
|
394
|
+
// reported for visibility but must NOT print as ` FAIL:` — the
|
|
395
|
+
// validate-plugin aggregator counts failures by that exact 2-space
|
|
396
|
+
// prefix (`scripts/validate-plugin.mjs`'s `runCheck()`), so a `WARN:`
|
|
397
|
+
// line is how this check stays advisory end-to-end.
|
|
398
|
+
const label = item.severity === 'warn' ? 'WARN' : 'FAIL';
|
|
399
|
+
console.log(` ${label}: [${item.kind}] ${item.file}:${item.line} ${item.path} — ${item.message}`);
|
|
400
|
+
}
|
|
401
|
+
if (inspection.toolError) {
|
|
402
|
+
console.log('');
|
|
403
|
+
console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
|
|
404
|
+
return 2;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const s = inspection.summary;
|
|
408
|
+
const blockingCount = inspection.findings.filter((f) => f.severity !== 'warn').length;
|
|
409
|
+
if (inspection.ok) {
|
|
410
|
+
console.log(
|
|
411
|
+
` PASS: ${s.citations} script citation(s) in ${s.filesScanned} doc file(s) — ` +
|
|
412
|
+
`${s.existing} exist, ${s.annotated} annotated as deliberately absent, ` +
|
|
413
|
+
`${s.placeholders} placeholder(s)` +
|
|
414
|
+
(s.warnings > 0 ? `, ${s.warnings} advisory .sh warning(s) (see --strict-sh)` : ''),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
console.log('');
|
|
418
|
+
console.log(`Results: ${inspection.ok ? 1 : 0} passed, ${blockingCount} failed`);
|
|
419
|
+
return inspection.ok ? 0 : 1;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
423
|
+
if (isMain) {
|
|
424
|
+
const strictSh = process.argv.includes('--strict-sh');
|
|
425
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--json' && arg !== '--strict-sh');
|
|
426
|
+
const root = path.resolve(args[0] || process.cwd());
|
|
427
|
+
if (process.argv.includes('--json')) {
|
|
428
|
+
const inspection = scanSkillScriptPaths({ pluginRoot: root, strictSh });
|
|
429
|
+
// Write, THEN set the exit code — `process.exit()` after a large print
|
|
430
|
+
// discards whatever is still queued on an async stdout pipe.
|
|
431
|
+
process.stdout.write(`${JSON.stringify(inspection, null, 2)}\n`);
|
|
432
|
+
process.exitCode = inspection.toolError ? 2 : inspection.ok ? 0 : 1;
|
|
433
|
+
} else {
|
|
434
|
+
process.exitCode = runCheckSkillScriptPaths(root, { strictSh });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
@@ -695,9 +695,19 @@ export function resolveUntrackedOracle(repoRoot, candidates) {
|
|
|
695
695
|
// directory candidates (`.git`, `.claude`, `docs/prd`, `skills/bootstrap`, …)
|
|
696
696
|
// were all false. A directory can therefore only be condemned by
|
|
697
697
|
// `check-ignore`, which does judge directories correctly.
|
|
698
|
+
//
|
|
699
|
+
// `.git` ALSO needs an explicit exemption from THIS FILES-ONLY branch, not
|
|
700
|
+
// just from the directory-only reasoning above. In a normal checkout `.git`
|
|
701
|
+
// is a directory and the `isFile()` guard already excludes it — but in a
|
|
702
|
+
// LINKED WORKTREE (`git worktree add`) `.git` is a FILE containing
|
|
703
|
+
// `gitdir: <path>`, which makes `existsSync() && isFile()` true and
|
|
704
|
+
// re-opens exactly the false-positive the comment above says is closed.
|
|
705
|
+
// `.git` is present in every clone/worktree by construction regardless of
|
|
706
|
+
// which shape it takes, so it is never a "never git-add-ed" candidate.
|
|
698
707
|
for (const c of all) {
|
|
699
708
|
if (untracked.has(c)) continue;
|
|
700
709
|
if (tracked.has(c)) continue;
|
|
710
|
+
if (c === '.git') continue;
|
|
701
711
|
try {
|
|
702
712
|
const abs = path.join(repoRoot, c);
|
|
703
713
|
if (existsSync(abs) && statSync(abs).isFile()) untracked.add(c);
|
|
@@ -304,13 +304,6 @@ const ALLOWLIST = Object.freeze({
|
|
|
304
304
|
'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
|
|
305
305
|
webhooks:
|
|
306
306
|
'dedicated reader outside the parser layer — scripts/lib/webhook-url.mjs resolves these URLs env-first (S2 exemption only; S1 evidence is real)',
|
|
307
|
-
// S4 entry (module path, not a config key). The prose-only state is REAL and
|
|
308
|
-
// recorded here BY NAME rather than left anonymous among the ~50-module S4
|
|
309
|
-
// backlog, where a per-module expectation cannot be reviewed. Named callers
|
|
310
|
-
// let a reviewer check the four sites; the self-draining `allowlist-stale`
|
|
311
|
-
// rule reports this entry the day a .mjs caller makes it reachable.
|
|
312
|
-
[path.join('scripts', 'lib', 'session-transition.mjs')]:
|
|
313
|
-
'prose-only consumer — leaveSourceRoot() is called by 4 skill prose sites (skills/session-start/SKILL.md:45 + :229, skills/_shared/parallel-aware-auq.md, skills/_shared/parallel-aware-preamble.md); the worktree promotion is a coordinator action, not a script (#1069)',
|
|
314
307
|
});
|
|
315
308
|
|
|
316
309
|
/**
|