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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. 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
  /**