session-orchestrator 3.19.0 → 3.21.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 (158) 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/rules/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -0,0 +1,757 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-unwired-features.mjs — census of DECLARED-BUT-UNREAD Session Config keys.
4
+ *
5
+ * ## The defect class
6
+ *
7
+ * This repo's recurring systemic failure is not a broken feature — it is a
8
+ * feature that was built, documented, schema-validated and tested, and then
9
+ * never switched on. One 2026-08-08 analysis found three independent instances:
10
+ * `efficiency.output-level` (10 test files, 0 runtime consumers, ~15 months
11
+ * dead), `issue-budget` (complete with a PreToolUse hook and an overflow
12
+ * collector, never entered in the live Session Config), and
13
+ * `compact-nudge` / `goal-integration` (0 `.mjs` read sites — schema + prose
14
+ * only). Prose cannot catch this class; every one of those keys was documented
15
+ * exactly as prescribed. A mechanical census can.
16
+ *
17
+ * ## What this check owns — and what it deliberately does NOT
18
+ *
19
+ * The declared config surface has THREE faces, and only one of the three edges
20
+ * between them was previously guarded:
21
+ *
22
+ * template ↔ live Session Config → owned by `claude-md-drift-check`
23
+ * Check 6 (`session-config-parity`).
24
+ * NOT duplicated here.
25
+ * template ↔ code ┐
26
+ * live cfg ↔ code ┘ → owned by THIS check.
27
+ *
28
+ * So: a key that appears in `docs/session-config-template.md` and/or in the
29
+ * live `## Session Config` block, but that NO `.mjs` under `scripts/` or
30
+ * `hooks/` ever reads, is reported here. That is the `compact-nudge` class.
31
+ *
32
+ * ## Two signals, because a mention is not a read
33
+ *
34
+ * S1 `unwired-config-key` — zero non-comment mentions in the consumer
35
+ * corpus. The blunt case.
36
+ * S2 `parser-orphan-config-key` — the key IS mentioned, but no file in the
37
+ * config-parser layer (`PARSER_PATHS`) knows
38
+ * it, so nothing turns the YAML into a value.
39
+ *
40
+ * S2 exists because S1 alone is fooled by a mention that reads nothing.
41
+ * `express-path.enabled` passes S1 on the strength of ONE line —
42
+ * `scripts/lib/state-md/body-sections.mjs:699`, a log-message template literal
43
+ * that interpolates a value its caller already had. No parser resolves
44
+ * `express-path` from config at all; the gate lives entirely in
45
+ * `skills/session-start/phase-8-5-express-path.md` prose. S1 called that wired;
46
+ * S2 calls it what it is.
47
+ *
48
+ * S2 applies to TOP-LEVEL keys only — a nested key reaches code through its
49
+ * parent — and its premise is structural: every Session Config key has to pass
50
+ * through the parser layer to become a value. Measured 2026-08-08: 84 of 89
51
+ * top-level keys satisfy it, so the 5 that do not are signal, not noise.
52
+ *
53
+ * ## S3 `orphaned-prose-module` — the same disease, one level out
54
+ *
55
+ * A config key is not the only thing prose can promise. A DOCUMENT can also
56
+ * assert that a module does a job that nothing calls. S3 reports a module under
57
+ * `scripts/`/`hooks/` that satisfies ALL of:
58
+ *
59
+ * 1. its basename is named in tracked, non-historical prose,
60
+ * 2. NO other production module references that basename on a non-comment line,
61
+ * 3. it is not a CLI entrypoint (no shebang, no main-guard),
62
+ * 4. it exports at least one named symbol, and
63
+ * 5. the prose naming it names NONE of those exported symbols.
64
+ *
65
+ * Condition 5 is the discriminator, and it is a claim about GRAMMAR. "dispatch
66
+ * via `runWavePool()`" names a symbol: it is an INSTRUCTION addressed to a reader
67
+ * who will execute it, which is legitimate prose-wiring. "transitions **are
68
+ * validated** against `foo.mjs`" names only the file, in the passive voice: it
69
+ * ASSERTS that something happens by itself. Nobody is addressed, so nobody does
70
+ * it. Passive + bare filename + zero symbols is the signature of a dead promise.
71
+ *
72
+ * ### Why this is a narrow rule and not an export census
73
+ *
74
+ * The obvious broader check — "report every export with no non-test importer" —
75
+ * was measured on 2026-08-14 and is NOT buildable: 1366 exports, 779 without a
76
+ * non-test importer, a false-positive rate of 93.2% naive and still 81.2% after
77
+ * four exclusion rules. A gate that prints 282 lines gets switched off in week
78
+ * two, which is this file's own disease one level up. S3 trades that recall for
79
+ * precision: it only fires where prose made a CLAIM, so every hit has a document
80
+ * to correct.
81
+ *
82
+ * ### Honest limitation: the population is tiny, by construction
83
+ *
84
+ * The measured cascade on 2026-08-14 was 452 production modules → 329 named in
85
+ * prose → 95 with no production reference → 57 non-entrypoint → 56 with a named
86
+ * export → **2**. Do not read a near-empty report as a broken check: S3 is a
87
+ * RELAPSE GUARD, not a cleanup tool. Its value is catching the NEXT false
88
+ * promise on the day it is written, not finding mass today.
89
+ *
90
+ * ### Two false-positive classes this rule was calibrated against
91
+ *
92
+ * Both were live hits in the first draft, and both are now excluded by
93
+ * construction — reintroducing either would be a regression:
94
+ *
95
+ * - **Dynamic-import consumers.** `scripts/lib/skill-health/join.mjs` looks
96
+ * orphaned to any `from '…join.mjs'` regex: `harness-audit/categories/
97
+ * category9.mjs` resolves it via `new URL('../../skill-health/join.mjs',
98
+ * import.meta.url)` and imports the resulting VARIABLE inside a generated
99
+ * child-process source string. Condition 2 therefore counts any non-comment
100
+ * mention of the basename as a reference, not just a static import specifier.
101
+ * - **Re-export shims.** `scripts/lib/autopilot-telemetry.mjs` is
102
+ * `export * from './autopilot/telemetry.mjs'` — zero NAMED exports, so
103
+ * condition 5 ("prose names none of its exports") is vacuously true and the
104
+ * module is reported for having no symbols to name. Condition 4 excludes it.
105
+ *
106
+ * `CHANGELOG.md` is excluded from the prose corpus for the same reason: it is an
107
+ * append-only record of what a PAST release shipped, so it names the symbols of
108
+ * code that may since have died. Counting it silenced a true positive
109
+ * (`soul-resolve.mjs`, whose only live claim is in `.claude/rules/owner-persona.md`
110
+ * but whose symbols appear in a 2026-06 changelog entry).
111
+ *
112
+ * ## Consumer scope, and why "prose-only" is a finding rather than an error
113
+ *
114
+ * Read sites are counted in `scripts/**` and `hooks/**` (`.mjs`/`.js`/`.cjs`),
115
+ * with every `tests/` path excluded — a key read only by its own tests is
116
+ * exactly the dead surface this check hunts.
117
+ *
118
+ * Skill bodies are NOT consumers for this purpose. A key consumed only by
119
+ * markdown prose is real (an LLM reads the instruction), but it is a WEAKER
120
+ * wiring than code: nothing fails when the prose is reworded or the skill is
121
+ * retired. Those keys are legitimate — they belong on the allowlist below with
122
+ * their prose consumer named, which turns an invisible assumption into a
123
+ * reviewable line.
124
+ *
125
+ * ## Allowlisting (how, and the standing requirement)
126
+ *
127
+ * Add an entry to `ALLOWLIST` keyed by the FULL dotted key path, whose value is
128
+ * a non-empty reason naming the actual consumer:
129
+ *
130
+ * 'auto-skill-dispatch': 'prose-only consumer: skills/using-orchestrator/SKILL.md',
131
+ *
132
+ * Every entry needs a reason — an empty or whitespace-only one is itself
133
+ * reported (`allowlist-missing-reason`), so the escape hatch cannot be used to
134
+ * silence a key without saying why. The list also drains itself: an entry is
135
+ * reported as `allowlist-stale` both when its key has left every config surface
136
+ * AND when the key stops triggering a finding (i.e. it finally got wired), so a
137
+ * fixed key does not leave a permanent exemption behind.
138
+ *
139
+ * ## Mode: WARN, not blocking (v1)
140
+ *
141
+ * Findings never fail the process; exit is 0 whenever the scan completed
142
+ * (2 only on a filesystem/tool error). This is deliberate. The repo currently
143
+ * ships 14 of 27 Session Config switches set to `false`; a blocking gate on
144
+ * that inventory would be red from day one, and a gate that is red on day one
145
+ * gets disabled — which is the same disease this file exists to treat, one
146
+ * level up. Warn first, ratchet later once the census is at zero.
147
+ *
148
+ * ## Named residuals (so nobody over-reads the coverage claim)
149
+ *
150
+ * - **Token-level, not dataflow.** A key whose value is parsed and then never
151
+ * consumed downstream reads as WIRED here. `efficiency.output-level` is that
152
+ * shape (parsed by `scripts/lib/owner-yaml.mjs`, consumed by nothing) and
153
+ * would NOT be caught — nor is it in scope, being an `owner.yaml` key rather
154
+ * than a Session Config one. Catching it needs an import/dataflow graph.
155
+ * - **Unused `export`s under `scripts/lib/` are NOT censused here.** A naive
156
+ * token census of that second axis produced 441 candidates over 356 files on
157
+ * 2026-08-08, with false positives in the first screenful (`validateAgentOutput`
158
+ * among them) — a list that size is switched off, not acted on. That axis
159
+ * needs a real import graph; `knip` is not a dependency of this repo
160
+ * (`grep -n knip package.json` → no match) and adopting it is issue #977.
161
+ * - **A comment mention counts as a non-read.** Matches are tallied per line;
162
+ * a key named only in comments is reported, with the comment count attached.
163
+ * - Files are read with `readFileSync`, never a `grep` spawn: one NUL byte
164
+ * makes a text file invisible to grep-based audits (see
165
+ * `.claude/rules/anti-pattern-a-nul-byte-in-a-tracked-production-file-...md`),
166
+ * which would silently drop a consumer and manufacture a false positive.
167
+ *
168
+ * Import-safety: importing this module exposes the collector and runner only;
169
+ * the CLI path is guarded at the bottom of the file.
170
+ */
171
+
172
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
173
+ import path from 'node:path';
174
+ import { pathToFileURL } from 'node:url';
175
+
176
+ /** Documented config surface — every `yaml` fence in this file is a declaration. */
177
+ const TEMPLATE_REL = 'docs/session-config-template.md';
178
+
179
+ /** Live config surface. First existing file wins (CLAUDE.md beats AGENTS.md). */
180
+ const INSTRUCTION_FILES = Object.freeze(['CLAUDE.md', 'AGENTS.md']);
181
+
182
+ /** Directories whose code counts as a runtime consumer. */
183
+ const CONSUMER_DIRS = Object.freeze(['scripts', 'hooks']);
184
+
185
+ /** Extensions that can hold a runtime read site. */
186
+ const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
187
+
188
+ /** Directory names excluded from the consumer scan at any depth. */
189
+ const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'tests', 'test', '__tests__']);
190
+
191
+ /** Extension carrying prose claims (signal S3). */
192
+ const PROSE_EXTENSIONS = Object.freeze(['.md']);
193
+
194
+ /**
195
+ * Additionally excluded from the S3 PROSE corpus. `.orchestrator/` is generated
196
+ * telemetry and audit output — machine-written, so it asserts nothing.
197
+ */
198
+ const PROSE_EXCLUDED_DIRS = Object.freeze([...EXCLUDED_DIRS, '.orchestrator']);
199
+
200
+ /**
201
+ * Prose files excluded by basename.
202
+ *
203
+ * `CHANGELOG.md` is a HISTORICAL record: it describes what a past release
204
+ * shipped, so it keeps naming symbols of code that has since been deleted. See
205
+ * the header for the true positive this masked.
206
+ *
207
+ * `STATE.md` is per-session MUTABLE state, not documentation. A module named in
208
+ * a wave plan is not a durable promise, and counting it would make this check's
209
+ * output depend on whichever session happens to be open — a repo-wide census
210
+ * must not change because a task description mentioned a filename.
211
+ */
212
+ const PROSE_EXCLUDED_FILES = Object.freeze(['CHANGELOG.md', 'STATE.md']);
213
+
214
+ /**
215
+ * This file excludes ITSELF from the consumer corpus. Load-bearing: every
216
+ * `ALLOWLIST` key is a string literal here, so without the exclusion each
217
+ * allowlist entry becomes its own read site and the check reports the key as
218
+ * wired — silently blinding itself to exactly the keys an operator flagged as
219
+ * needing review. (Observed on first run: 2 allowlisted keys reported as 0.)
220
+ */
221
+ const SELF_REL = path.join('scripts', 'lib', 'validate', 'check-unwired-features.mjs');
222
+
223
+ /**
224
+ * The config-parser layer: the files a Session Config key must pass through to
225
+ * become a runtime value. Signal S2 (see header) checks top-level keys against
226
+ * this subset. Directories are walked; plain files are taken as-is.
227
+ */
228
+ const PARSER_PATHS = Object.freeze([
229
+ path.join('scripts', 'lib', 'config'),
230
+ path.join('scripts', 'lib', 'config.mjs'),
231
+ path.join('scripts', 'lib', 'config-schema.mjs'),
232
+ path.join('scripts', 'parse-config.mjs'),
233
+ ]);
234
+
235
+ /**
236
+ * Declared-but-unread keys accepted on purpose. Key = full dotted path,
237
+ * value = REASON naming the real consumer. See the header for the contract:
238
+ * an empty reason, a key that left every config surface, and a key that got
239
+ * wired are all reported so the list stays short and true.
240
+ */
241
+ const ALLOWLIST = Object.freeze({
242
+ 'auto-skill-dispatch':
243
+ 'prose-only consumer — skills/using-orchestrator/SKILL.md + skills/_shared/bootstrap-gate.md read this key as an LLM instruction; there is no .mjs gate by design',
244
+ 'auto-commit-per-wave':
245
+ 'prose-only consumer — skills/wave-executor/wave-loop.md gates the per-wave commit step on this key; the commit itself is a coordinator action, not a script',
246
+ 'instruction-budget':
247
+ 'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
248
+ webhooks:
249
+ 'dedicated reader outside the parser layer — scripts/lib/webhook-url.mjs resolves these URLs env-first (S2 exemption only; S1 evidence is real)',
250
+ });
251
+
252
+ /**
253
+ * @typedef {{
254
+ * key: string,
255
+ * leaf: string,
256
+ * root: string,
257
+ * sources: string[],
258
+ * }} DeclaredKey
259
+ */
260
+
261
+ /**
262
+ * @typedef {{
263
+ * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
264
+ * | 'allowlist-stale' | 'orphaned-prose-module' | 'tool-error',
265
+ * key: string,
266
+ * message: string,
267
+ * }} Finding
268
+ */
269
+
270
+ /**
271
+ * Recursively collect files, skipping symlinks and excluded directories.
272
+ *
273
+ * @param {string} directory absolute directory path
274
+ * @param {string[]} [acc]
275
+ * @param {readonly string[]} [extensions] extensions to keep
276
+ * @param {readonly string[]} [excludedDirs] directory names pruned at any depth
277
+ * @returns {string[]} absolute file paths
278
+ */
279
+ function walkCode(directory, acc = [], extensions = CODE_EXTENSIONS, excludedDirs = EXCLUDED_DIRS) {
280
+ if (!existsSync(directory)) return acc;
281
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
282
+ if (entry.isSymbolicLink()) continue;
283
+ if (excludedDirs.includes(entry.name)) continue;
284
+ const fullPath = path.join(directory, entry.name);
285
+ if (entry.isDirectory()) walkCode(fullPath, acc, extensions, excludedDirs);
286
+ else if (entry.isFile() && extensions.includes(path.extname(entry.name))) acc.push(fullPath);
287
+ }
288
+ return acc;
289
+ }
290
+
291
+ /**
292
+ * Extract dotted key paths from YAML-ish lines.
293
+ *
294
+ * Indentation drives nesting; a `- ` list-item prefix is treated as one extra
295
+ * level so `custom-phases: [- name: …]` yields `custom-phases.name`. Comment
296
+ * lines and inline `# …` trailers are stripped first, so a commented-out key
297
+ * (e.g. the deliberately-disabled `# bash-write-guard: true`) is NOT counted as
298
+ * declared — commenting a key out IS the documented way to leave it unset.
299
+ *
300
+ * @param {string[]} lines raw YAML lines
301
+ * @param {(key: string, leaf: string, rootKey: string) => void} emit
302
+ * @returns {void}
303
+ */
304
+ function extractKeyLines(lines, emit) {
305
+ /** @type {{name: string, indent: number}[]} */
306
+ const stack = [];
307
+ for (const raw of lines) {
308
+ const trimmed = raw.trim();
309
+ if (!trimmed || trimmed.startsWith('#')) continue;
310
+ const withoutComment = raw.replace(/\s+#.*$/, '');
311
+ let indent = (withoutComment.match(/^(\s*)/) ?? ['', ''])[1].length;
312
+ let body = withoutComment.trim();
313
+ if (body.startsWith('- ')) {
314
+ body = body.slice(2).trim();
315
+ indent += 2;
316
+ }
317
+ const matched = body.match(/^([A-Za-z0-9_.-]+):(?:\s.*)?$/);
318
+ if (!matched) continue;
319
+ const name = matched[1];
320
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
321
+ const parents = stack.map((frame) => frame.name);
322
+ emit([...parents, name].join('.'), name, parents[0] ?? name);
323
+ stack.push({ name, indent });
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Collect every key declared in the template's ```yaml fences and in the live
329
+ * `## Session Config` block(s) of the instruction file.
330
+ *
331
+ * The live-config scan runs to the next `## ` heading at column 0, so the
332
+ * parity-exempt `## Skill Evolution` / `## Dispatcher Autonomy` blocks are
333
+ * picked up as their own declaration sources rather than silently skipped —
334
+ * their keys are just as capable of going unread.
335
+ *
336
+ * @param {string} pluginRoot absolute plugin root
337
+ * @returns {{keys: Map<string, DeclaredKey>, sourcesScanned: string[]}}
338
+ */
339
+ export function collectDeclaredKeys(pluginRoot) {
340
+ /** @type {Map<string, DeclaredKey>} */
341
+ const keys = new Map();
342
+ /** @type {string[]} */
343
+ const sourcesScanned = [];
344
+
345
+ /** @type {(source: string) => (key: string, leaf: string, rootKey: string) => void} */
346
+ const emitter = (source) => (key, leaf, rootKey) => {
347
+ const existing = keys.get(key);
348
+ if (existing) {
349
+ if (!existing.sources.includes(source)) existing.sources.push(source);
350
+ return;
351
+ }
352
+ keys.set(key, { key, leaf, root: rootKey, sources: [source] });
353
+ };
354
+
355
+ const templatePath = path.join(pluginRoot, TEMPLATE_REL);
356
+ if (existsSync(templatePath)) {
357
+ sourcesScanned.push(TEMPLATE_REL);
358
+ const emit = emitter(TEMPLATE_REL);
359
+ let inYamlFence = false;
360
+ /** @type {string[]} */
361
+ let fence = [];
362
+ for (const raw of readFileSync(templatePath, 'utf8').split('\n')) {
363
+ if (raw.trim().startsWith('```')) {
364
+ if (inYamlFence) extractKeyLines(fence, emit);
365
+ inYamlFence = raw.trim().startsWith('```yaml');
366
+ fence = [];
367
+ continue;
368
+ }
369
+ if (inYamlFence) fence.push(raw);
370
+ }
371
+ if (inYamlFence) extractKeyLines(fence, emit);
372
+ }
373
+
374
+ for (const candidate of INSTRUCTION_FILES) {
375
+ const instructionPath = path.join(pluginRoot, candidate);
376
+ if (!existsSync(instructionPath)) continue;
377
+ sourcesScanned.push(candidate);
378
+ const emit = emitter(candidate);
379
+ const lines = readFileSync(instructionPath, 'utf8').split('\n');
380
+ let inConfigBlock = false;
381
+ /** @type {string[]} */
382
+ let block = [];
383
+ for (const raw of lines) {
384
+ if (/^## /.test(raw)) {
385
+ if (inConfigBlock) extractKeyLines(block, emit);
386
+ block = [];
387
+ inConfigBlock = /^## (Session Config|Skill Evolution|Dispatcher Autonomy)\b/.test(raw);
388
+ continue;
389
+ }
390
+ // `>` blockquote prose inside a config block carries no declarations.
391
+ if (inConfigBlock && !raw.trim().startsWith('>')) block.push(raw);
392
+ }
393
+ if (inConfigBlock) extractKeyLines(block, emit);
394
+ break; // CLAUDE.md wins ties (skills/_shared/instruction-file-resolution.md)
395
+ }
396
+
397
+ return { keys, sourcesScanned };
398
+ }
399
+
400
+ /**
401
+ * Build a word-boundary matcher for a config-key token.
402
+ *
403
+ * `-` is part of the boundary class so `mode` does not match `mode-x`, and
404
+ * `$` so `enabled` does not match `isEnabled$`.
405
+ *
406
+ * @param {string} token
407
+ * @returns {RegExp}
408
+ */
409
+ function tokenMatcher(token) {
410
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
411
+ return new RegExp(`(?<![A-Za-z0-9_$-])${escaped}(?![A-Za-z0-9_$-])`);
412
+ }
413
+
414
+ /** @param {string} line @returns {boolean} whether the line is comment-only */
415
+ function isCommentLine(line) {
416
+ const trimmed = line.trim();
417
+ return (
418
+ trimmed.startsWith('//') ||
419
+ trimmed.startsWith('*') ||
420
+ trimmed.startsWith('/*') ||
421
+ trimmed.startsWith('#')
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Count read sites for one declared key across the consumer corpus.
427
+ *
428
+ * Matching is on the LEAF token, because a nested key is read as
429
+ * `cfg['memory']?.banner?.enabled` — the dotted path never appears verbatim.
430
+ * That alone would make a generic leaf (`enabled`, `mode`, `url`) match
431
+ * everywhere, so a nested key with a NON-DISTINCTIVE leaf (no hyphen — i.e. a
432
+ * token that could be a plain JS identifier) additionally requires its
433
+ * top-level ancestor token in the SAME file.
434
+ *
435
+ * A hyphenated leaf is exempt from that ancestor requirement on purpose:
436
+ * `enforcement-gates.path-guard` is read in `hooks/enforce-scope.mjs` as
437
+ * `scope.gates?.['path-guard']`, a file that never names `enforcement-gates`.
438
+ * Requiring the ancestor there produced a false positive on a genuinely-wired
439
+ * gate — and false positives are what get this check switched off.
440
+ *
441
+ * @param {DeclaredKey} declared
442
+ * @param {{relative: string, lines: string[], body: string}[]} corpus
443
+ * @returns {{code: number, comment: number, files: string[]}}
444
+ */
445
+ export function countReadSites(declared, corpus) {
446
+ const leafRe = tokenMatcher(declared.leaf);
447
+ const rootRe = tokenMatcher(declared.root);
448
+ const scopedByAncestor = declared.key !== declared.leaf && !declared.leaf.includes('-');
449
+ let code = 0;
450
+ let comment = 0;
451
+ /** @type {string[]} */
452
+ const files = [];
453
+
454
+ for (const file of corpus) {
455
+ if (scopedByAncestor && !rootRe.test(file.body)) continue;
456
+ if (!leafRe.test(file.body)) continue;
457
+ let codeInFile = 0;
458
+ for (const line of file.lines) {
459
+ if (!leafRe.test(line)) continue;
460
+ if (isCommentLine(line)) comment += 1;
461
+ else codeInFile += 1;
462
+ }
463
+ if (codeInFile > 0) files.push(file.relative);
464
+ code += codeInFile;
465
+ }
466
+
467
+ return { code, comment, files };
468
+ }
469
+
470
+ /**
471
+ * Extract the NAMED symbols a module exports.
472
+ *
473
+ * Deliberately named-only: `export * from './x.mjs'` yields nothing, which is
474
+ * what marks a re-export shim as unjudgeable by S3 (see header, FP class 2).
475
+ *
476
+ * @param {string} body module source
477
+ * @returns {string[]} exported symbol names
478
+ */
479
+ export function collectExportedSymbols(body) {
480
+ /** @type {Set<string>} */
481
+ const names = new Set();
482
+ const declaration = /^export\s+(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z0-9_$]+)/gm;
483
+ for (const match of body.matchAll(declaration)) names.add(match[1]);
484
+ for (const match of body.matchAll(/^export\s*\{([^}]*)\}/gm)) {
485
+ for (const clause of match[1].split(',')) {
486
+ const name = clause.trim().split(/\s+as\s+/).pop()?.trim();
487
+ if (name && /^[A-Za-z0-9_$]+$/.test(name)) names.add(name);
488
+ }
489
+ }
490
+ return [...names];
491
+ }
492
+
493
+ /**
494
+ * Whether a module is a CLI entrypoint rather than a library.
495
+ *
496
+ * An entrypoint is invoked by path (npm script, hook wiring, CI job), so having
497
+ * no importer is its normal state and says nothing about being wired.
498
+ *
499
+ * @param {string} body module source
500
+ * @returns {boolean}
501
+ */
502
+ export function isCliEntrypoint(body) {
503
+ return (
504
+ body.startsWith('#!') ||
505
+ /import\.meta\.url\s*===|require\.main\s*===\s*module|process\.argv\[1\]/.test(body)
506
+ );
507
+ }
508
+
509
+ /**
510
+ * Signal S3 — modules a document promises but nothing calls.
511
+ *
512
+ * See the header for the five conditions, the grammar discriminator, and the two
513
+ * false-positive classes this is calibrated against. Condition 2 counts ANY
514
+ * non-comment mention of the basename as a reference (not just a static import
515
+ * specifier) because a real consumer can reach a module through
516
+ * `new URL(…, import.meta.url)` + dynamic `import()`.
517
+ *
518
+ * @param {string} pluginRoot absolute plugin root
519
+ * @returns {{findings: Finding[], scanned: {modules: number, prose: number}}}
520
+ */
521
+ export function collectOrphanedProseModules(pluginRoot) {
522
+ /** @type {Finding[]} */
523
+ const findings = [];
524
+
525
+ const modules = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
526
+ .sort()
527
+ .map((absolute) => {
528
+ const body = readFileSync(absolute, 'utf8');
529
+ return {
530
+ relative: path.relative(pluginRoot, absolute),
531
+ base: path.basename(absolute),
532
+ body,
533
+ lines: body.split('\n'),
534
+ };
535
+ });
536
+
537
+ const prose = walkCode(pluginRoot, [], PROSE_EXTENSIONS, PROSE_EXCLUDED_DIRS)
538
+ .filter((absolute) => !PROSE_EXCLUDED_FILES.includes(path.basename(absolute)))
539
+ .sort()
540
+ .map((absolute) => ({
541
+ relative: path.relative(pluginRoot, absolute),
542
+ body: readFileSync(absolute, 'utf8'),
543
+ }));
544
+
545
+ for (const module of modules) {
546
+ // (1) named by a live document
547
+ const claims = prose.filter((doc) => doc.body.includes(module.base));
548
+ if (claims.length === 0) continue;
549
+
550
+ // (2) no production module references it outside a comment
551
+ const referenced = modules.some(
552
+ (other) =>
553
+ other.relative !== module.relative &&
554
+ other.lines.some((line) => line.includes(module.base) && !isCommentLine(line)),
555
+ );
556
+ if (referenced) continue;
557
+
558
+ // (3) not invoked by path
559
+ if (isCliEntrypoint(module.body)) continue;
560
+
561
+ // (4) has symbols the prose could have named
562
+ const symbols = collectExportedSymbols(module.body);
563
+ if (symbols.length === 0) continue;
564
+
565
+ // (5) the prose names none of them → nobody is addressed, so nobody acts
566
+ const naming = claims.filter((doc) => symbols.some((symbol) => tokenMatcher(symbol).test(doc.body)));
567
+ if (naming.length > 0) continue;
568
+
569
+ findings.push({
570
+ kind: 'orphaned-prose-module',
571
+ key: module.relative,
572
+ message:
573
+ `named in ${claims.map((doc) => doc.relative).join(' + ')} but no .mjs under ` +
574
+ `${CONSUMER_DIRS.join('/ or ')}/ references it, and that prose names none of its ` +
575
+ `export(s) (${symbols.join(', ')}) — the document promises behaviour nothing performs; ` +
576
+ 'wire it, delete it, or reword the prose to describe what actually happens',
577
+ });
578
+ }
579
+
580
+ return { findings, scanned: { modules: modules.length, prose: prose.length } };
581
+ }
582
+
583
+ /**
584
+ * Run the full census.
585
+ *
586
+ * @param {string} pluginRoot absolute plugin root
587
+ * @returns {{
588
+ * ok: boolean,
589
+ * summary: {declaredKeys: number, consumerFiles: number, unwired: number, allowlisted: number,
590
+ * orphanedModules: number},
591
+ * sourcesScanned: string[],
592
+ * findings: Finding[],
593
+ * toolError: boolean,
594
+ * }}
595
+ */
596
+ export function inspectUnwiredFeatures(pluginRoot) {
597
+ /** @type {Finding[]} */
598
+ const findings = [];
599
+ const result = {
600
+ ok: false,
601
+ summary: { declaredKeys: 0, consumerFiles: 0, unwired: 0, allowlisted: 0, orphanedModules: 0 },
602
+ /** @type {string[]} */
603
+ sourcesScanned: [],
604
+ findings,
605
+ toolError: false,
606
+ };
607
+
608
+ /** @type {{keys: Map<string, DeclaredKey>, sourcesScanned: string[]}} */
609
+ let declared;
610
+ /** @type {{relative: string, lines: string[], body: string}[]} */
611
+ let corpus;
612
+ /** @type {string} */
613
+ let parserBody;
614
+ /** @type {ReturnType<typeof collectOrphanedProseModules>} */
615
+ let orphans;
616
+ try {
617
+ declared = collectDeclaredKeys(pluginRoot);
618
+ corpus = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
619
+ .sort()
620
+ .filter((absolute) => path.relative(pluginRoot, absolute) !== SELF_REL)
621
+ .map((absolute) => {
622
+ const body = readFileSync(absolute, 'utf8');
623
+ return { relative: path.relative(pluginRoot, absolute), lines: body.split('\n'), body };
624
+ });
625
+ parserBody = PARSER_PATHS.flatMap((relative) => {
626
+ const absolute = path.join(pluginRoot, relative);
627
+ if (!existsSync(absolute)) return [];
628
+ return CODE_EXTENSIONS.includes(path.extname(absolute)) ? [absolute] : walkCode(absolute);
629
+ })
630
+ .map((absolute) => readFileSync(absolute, 'utf8'))
631
+ .join('\n');
632
+ orphans = collectOrphanedProseModules(pluginRoot);
633
+ } catch (error) {
634
+ result.toolError = true;
635
+ findings.push({
636
+ kind: 'tool-error',
637
+ key: '-',
638
+ message: `cannot enumerate config surface: ${error instanceof Error ? error.message : String(error)}`,
639
+ });
640
+ return result;
641
+ }
642
+
643
+ result.sourcesScanned = declared.sourcesScanned;
644
+ result.summary.declaredKeys = declared.keys.size;
645
+ result.summary.consumerFiles = corpus.length;
646
+
647
+ /** @type {Set<string>} */
648
+ const flagged = new Set();
649
+
650
+ for (const key of [...declared.keys.keys()].sort()) {
651
+ const meta = /** @type {DeclaredKey} */ (declared.keys.get(key));
652
+ const { code, comment } = countReadSites(meta, corpus);
653
+
654
+ /** @type {Finding | null} */
655
+ let issue = null;
656
+ if (code === 0) {
657
+ const commentNote = comment > 0 ? ` (${comment} comment-only mention(s))` : '';
658
+ issue = {
659
+ kind: 'unwired-config-key',
660
+ key,
661
+ message:
662
+ `declared in ${meta.sources.join(' + ')} but no .mjs under ${CONSUMER_DIRS.join('/ or ')}/ ` +
663
+ `reads it${commentNote} — wire it, delete it, or allowlist it with a reason`,
664
+ };
665
+ } else if (key === meta.root && !tokenMatcher(key).test(parserBody)) {
666
+ issue = {
667
+ kind: 'parser-orphan-config-key',
668
+ key,
669
+ message:
670
+ `mentioned in ${CONSUMER_DIRS.join('/ or ')}/ but unknown to the config-parser layer ` +
671
+ `(${PARSER_PATHS.join(', ')}) — nothing turns this YAML into a value; the mention may be ` +
672
+ 'a log string or a comment-adjacent reference',
673
+ };
674
+ }
675
+ if (!issue) continue;
676
+ flagged.add(key);
677
+
678
+ if (Object.prototype.hasOwnProperty.call(ALLOWLIST, key)) {
679
+ result.summary.allowlisted += 1;
680
+ if (String(ALLOWLIST[key] ?? '').trim() === '') {
681
+ findings.push({
682
+ kind: 'allowlist-missing-reason',
683
+ key,
684
+ message: 'allowlist entry has no reason — name the actual consumer or remove the entry',
685
+ });
686
+ }
687
+ continue;
688
+ }
689
+
690
+ result.summary.unwired += 1;
691
+ findings.push(issue);
692
+ }
693
+
694
+ for (const key of Object.keys(ALLOWLIST).sort()) {
695
+ if (flagged.has(key)) continue;
696
+ findings.push({
697
+ kind: 'allowlist-stale',
698
+ key,
699
+ message: declared.keys.has(key)
700
+ ? 'allowlisted key no longer triggers a finding (it is wired now) — remove the entry'
701
+ : 'allowlisted key is no longer declared in any config surface — remove the entry',
702
+ });
703
+ }
704
+
705
+ // S3 — prose promises a module nothing calls. Reported alongside the config
706
+ // census because it is the same defect class one level out: a claim with no
707
+ // mechanism behind it.
708
+ result.summary.orphanedModules = orphans.findings.length;
709
+ findings.push(...orphans.findings);
710
+
711
+ result.ok = !result.toolError && findings.length === 0;
712
+ return result;
713
+ }
714
+
715
+ /**
716
+ * Run the human-readable validator CLI.
717
+ *
718
+ * WARN-ONLY: findings print as WARN and still exit 0. See the header for why a
719
+ * blocking gate would be red on day one on this repo's current inventory.
720
+ *
721
+ * @param {string} pluginRoot absolute plugin root
722
+ * @returns {number} 0 = scan completed (with or without findings), 2 = tool error
723
+ */
724
+ export function runCheckUnwiredFeatures(pluginRoot) {
725
+ console.log('--- Check: unwired config keys (declared-but-unread census, WARN-only) ---');
726
+ const inspection = inspectUnwiredFeatures(pluginRoot);
727
+
728
+ if (inspection.toolError) {
729
+ for (const item of inspection.findings) console.log(` FAIL: ${item.key} — ${item.message}`);
730
+ console.log('');
731
+ console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
732
+ return 2;
733
+ }
734
+
735
+ const { declaredKeys, consumerFiles, unwired, allowlisted, orphanedModules } = inspection.summary;
736
+ for (const item of inspection.findings) {
737
+ console.log(` WARN: [${item.kind}] ${item.key} — ${item.message}`);
738
+ }
739
+ console.log(
740
+ ` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
741
+ `against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted, ` +
742
+ `${orphanedModules} prose-orphaned module(s)`,
743
+ );
744
+ console.log('');
745
+ console.log('Results: 1 passed, 0 failed');
746
+ return 0;
747
+ }
748
+
749
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
750
+ if (isMain) {
751
+ const pluginRoot = process.argv[2];
752
+ if (!pluginRoot) {
753
+ console.error('Usage: check-unwired-features.mjs <plugin-root>');
754
+ process.exit(2);
755
+ }
756
+ process.exit(runCheckUnwiredFeatures(path.resolve(pluginRoot)));
757
+ }