session-orchestrator 3.22.0 → 3.23.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 (268) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -38,12 +38,20 @@
38
38
  * it, so nothing turns the YAML into a value.
39
39
  *
40
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.
41
+ * The exemplar it was written against was `express-path.enabled`: it passed S1 on
42
+ * the strength of ONE line — `scripts/lib/state-md/body-sections.mjs:699`, a
43
+ * log-message template literal interpolating a value its caller already had
44
+ * while no parser resolved `express-path` from config at all and the gate lived
45
+ * entirely in `skills/session-start/phase-8-5-express-path.md` prose. S1 called
46
+ * that wired; S2 called it what it was.
47
+ *
48
+ * FIXED 2026-08-23 (#1119): `scripts/lib/config.mjs` now parses the block, and
49
+ * `scripts/lib/express-path.mjs` makes the activation decision AND records it.
50
+ * Verified the same day — `express-path` no longer appears in this checker's
51
+ * stdout or stderr under either signal. **The exemplar is therefore historical.**
52
+ * It is kept because it is the clearest statement of what S2 detects, and because
53
+ * a rule that only cites live instances loses its explanation the moment it works.
54
+ * If you need a CURRENT S2 hit, run the checker; do not assume this one.
47
55
  *
48
56
  * S2 applies to TOP-LEVEL keys only — a nested key reaches code through its
49
57
  * parent — and its premise is structural: every Session Config key has to pass
@@ -109,6 +117,29 @@
109
117
  * (`soul-resolve.mjs`, whose only live claim is in `.claude/rules/owner-persona.md`
110
118
  * but whose symbols appear in a 2026-06 changelog entry).
111
119
  *
120
+ * ## S4 `unreachable-library-module` — the question the machine can answer
121
+ *
122
+ * S3 asks a document a question about GRAMMAR and accepts "the prose names an
123
+ * exported symbol" as wiring. That is generous by design, and it is where the
124
+ * largest instance of this defect class hid: `skills/session-start/SKILL.md`
125
+ * Phase 4 named 19 banner probes and their symbols, so S3 read every one of them
126
+ * as wired — while no hook, npm script, CI job or husky stage reached a single
127
+ * one. S1/S2 could not see them either, being config-key checks.
128
+ *
129
+ * S4 drops the grammar question and asks a reachability one: can any process
130
+ * that ACTUALLY STARTS arrive at this file? See `collectUnreachableLibraryModules`
131
+ * for the four conditions, the deliberate CLI-entrypoint boundary (with the
132
+ * measured cost of the alternative), the cluster-root collapse, and the named
133
+ * residuals. S4 does not subsume S3 and is not subsumed by it: S3 catches a
134
+ * REACHABLE module whose document lies about it, S4 catches an unreachable one
135
+ * whose document is honest about what it should do.
136
+ *
137
+ * Output shape is deliberately different from S1-S3 too. Those report a handful
138
+ * of lines; S4 measured 50 on the live tree (2026-08-23 @ 34321bc — a count, so read it as history) of 2026-08-23, which is a BACKLOG.
139
+ * The CLI therefore prints one aggregate WARN carrying the count, and the full
140
+ * census behind `--list` — see `runCheckUnwiredFeatures`. The `findings` array
141
+ * always carries every finding, so no programmatic consumer loses data.
142
+ *
112
143
  * ## Consumer scope, and why "prose-only" is a finding rather than an error
113
144
  *
114
145
  * Read sites are counted in `scripts/**` and `hooks/**` (`.mjs`/`.js`/`.cjs`),
@@ -186,7 +217,18 @@ const CONSUMER_DIRS = Object.freeze(['scripts', 'hooks']);
186
217
  const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
187
218
 
188
219
  /** Directory names excluded from the consumer scan at any depth. */
189
- const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'tests', 'test', '__tests__']);
220
+ // `worktrees` is here for a measured reason, not by analogy to node_modules.
221
+ // This walk uses `readdirSync`, NOT `git ls-files`, so `.gitignore` does not reach
222
+ // it — and `git worktree add` inside the repo (this repo's own convention is
223
+ // `.claude/worktrees/<name>`) drops a COMPLETE second checkout into the walk.
224
+ // Measured 2026-08-23 with one peer worktree present: +755 `.md` and +1209 `.mjs`
225
+ // files, 133 MB, `collectOrphanedProseModules` at 1727 ms of a 2270 ms total.
226
+ // The cost is the smaller half. The correctness half is worse: the peer's copy of
227
+ // `.claude/rules/owner-persona.md` was counted as an INDEPENDENT document naming
228
+ // the same module, so a finding cited two sources where one exists. Any scanner
229
+ // that walks the filesystem instead of the index has this bug; eight under
230
+ // `scripts/lib/validate/` use `readdirSync`.
231
+ const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'tests', 'test', '__tests__', 'worktrees']);
190
232
 
191
233
  /** Extension carrying prose claims (signal S3). */
192
234
  const PROSE_EXTENSIONS = Object.freeze(['.md']);
@@ -220,6 +262,20 @@ const PROSE_EXCLUDED_FILES = Object.freeze(['CHANGELOG.md', 'STATE.md']);
220
262
  */
221
263
  const SELF_REL = path.join('scripts', 'lib', 'validate', 'check-unwired-features.mjs');
222
264
 
265
+ /**
266
+ * Signal S4 entry surfaces: the NON-markdown files that mechanically invoke a
267
+ * module by path. Markdown is deliberately absent — that a SKILL.md names a
268
+ * module is precisely the claim S4 refuses to accept as wiring.
269
+ */
270
+ const WIRING_FILES = Object.freeze(['package.json', '.gitlab-ci.yml']);
271
+
272
+ /** Directory surfaces for S4, as `[dir, extensions]`. `''` catches husky's extensionless stages. */
273
+ const WIRING_DIRS = Object.freeze([
274
+ ['.github', Object.freeze(['.yml', '.yaml'])],
275
+ ['.husky', Object.freeze(['', '.sh'])],
276
+ ['hooks', Object.freeze(['.json', '.sh'])],
277
+ ]);
278
+
223
279
  /**
224
280
  * The config-parser layer: the files a Session Config key must pass through to
225
281
  * become a runtime value. Signal S2 (see header) checks top-level keys against
@@ -233,10 +289,11 @@ const PARSER_PATHS = Object.freeze([
233
289
  ]);
234
290
 
235
291
  /**
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.
292
+ * Declared-but-unread keys accepted on purpose. Key = full dotted path (S1/S2)
293
+ * or module path relative to the plugin root (S4), value = REASON naming the
294
+ * real consumer. See the header for the contract: an empty reason, a key that
295
+ * left every config surface, and a key that got wired are all reported so the
296
+ * list stays short and true.
240
297
  */
241
298
  const ALLOWLIST = Object.freeze({
242
299
  'auto-skill-dispatch':
@@ -247,6 +304,13 @@ const ALLOWLIST = Object.freeze({
247
304
  'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
248
305
  webhooks:
249
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)',
250
314
  });
251
315
 
252
316
  /**
@@ -261,7 +325,8 @@ const ALLOWLIST = Object.freeze({
261
325
  /**
262
326
  * @typedef {{
263
327
  * kind: 'unwired-config-key' | 'parser-orphan-config-key' | 'allowlist-missing-reason'
264
- * | 'allowlist-stale' | 'orphaned-prose-module' | 'tool-error',
328
+ * | 'allowlist-stale' | 'orphaned-prose-module' | 'unreachable-library-module'
329
+ * | 'tool-error',
265
330
  * key: string,
266
331
  * message: string,
267
332
  * }} Finding
@@ -542,17 +607,48 @@ export function collectOrphanedProseModules(pluginRoot) {
542
607
  body: readFileSync(absolute, 'utf8'),
543
608
  }));
544
609
 
610
+ // Both membership questions below were nested scans: (1) re-filtered EVERY prose
611
+ // document per module, (2) re-scanned EVERY other module's lines per module.
612
+ // At this repo's size that is 468 x ~700 full-body `includes` plus 468 x 468 x
613
+ // ~300 line tests — measured 1727 ms of `inspectUnwiredFeatures`'s 2270 ms, in a
614
+ // CLI that eight test files spawn under a 30 s hook timeout. Indexing both once
615
+ // is O(corpus) instead of O(corpus^2); the answers are unchanged by construction,
616
+ // and the acceptance criterion for the rewrite was byte-identical CLI output.
617
+ /** basename -> prose docs naming it */
618
+ const claimsByBase = new Map();
619
+ for (const doc of prose) {
620
+ for (const module of modules) {
621
+ if (!doc.body.includes(module.base)) continue;
622
+ const list = claimsByBase.get(module.base);
623
+ if (list) list.push(doc);
624
+ else claimsByBase.set(module.base, [doc]);
625
+ }
626
+ }
627
+ /** basename -> set of module paths referencing it OUTSIDE a comment */
628
+ const referencedBy = new Map();
629
+ for (const other of modules) {
630
+ for (const line of other.lines) {
631
+ if (isCommentLine(line)) continue;
632
+ for (const match of line.matchAll(/[A-Za-z0-9_.-]+\.(?:mjs|js|cjs)/g)) {
633
+ const set = referencedBy.get(match[0]);
634
+ if (set) set.add(other.relative);
635
+ else referencedBy.set(match[0], new Set([other.relative]));
636
+ }
637
+ }
638
+ }
639
+
545
640
  for (const module of modules) {
546
641
  // (1) named by a live document
547
- const claims = prose.filter((doc) => doc.body.includes(module.base));
642
+ const claims = claimsByBase.get(module.base) ?? [];
548
643
  if (claims.length === 0) continue;
549
644
 
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
- );
645
+ // (2) no production module references it outside a comment.
646
+ // Self-references do not count, which is why the index stores the referring
647
+ // path rather than a bare boolean.
648
+ const referrers = referencedBy.get(module.base);
649
+ const referenced =
650
+ referrers !== undefined &&
651
+ (referrers.size > 1 || !referrers.has(module.relative));
556
652
  if (referenced) continue;
557
653
 
558
654
  // (3) not invoked by path
@@ -580,6 +676,176 @@ export function collectOrphanedProseModules(pluginRoot) {
580
676
  return { findings, scanned: { modules: modules.length, prose: prose.length } };
581
677
  }
582
678
 
679
+ /**
680
+ * Extract every module-filename token a body mentions, ignoring comment lines.
681
+ *
682
+ * Token extraction beats a substring scan in BOTH directions. It is faster (one
683
+ * pass per file instead of one regex per candidate pair — 468² pair tests on the
684
+ * live tree), and it is more precise: `text.includes('writer.mjs')` is TRUE for
685
+ * `config-writer.mjs`, which silently marks an unrelated module as referenced.
686
+ * The character class stops at `/`, so `'./locks/state-md-lock.mjs'` yields
687
+ * exactly `state-md-lock.mjs`.
688
+ *
689
+ * @param {string[]} lines source lines
690
+ * @returns {Set<string>} module basenames mentioned outside comments
691
+ */
692
+ function mentionedModuleTokens(lines) {
693
+ /** @type {Set<string>} */
694
+ const tokens = new Set();
695
+ for (const line of lines) {
696
+ if (isCommentLine(line)) continue;
697
+ for (const match of line.matchAll(/[A-Za-z0-9_.-]+\.(?:mjs|js|cjs)/g)) tokens.add(match[0]);
698
+ }
699
+ return tokens;
700
+ }
701
+
702
+ /**
703
+ * Signal S4 — library modules no mechanical caller can reach.
704
+ *
705
+ * ## The gap this closes, in one line
706
+ *
707
+ * S3 asks "does a DOCUMENT name a symbol of this module?" and accepts a yes as
708
+ * wiring. S4 asks the question the machine can answer: "can any process that
709
+ * actually starts — a hook, an npm script, a CI job, a husky stage — arrive at
710
+ * this file?" Prose in a SKILL.md is an instruction to an LLM, not a caller: the
711
+ * measured instance is `skills/session-start/SKILL.md` Phase 4, which named 19
712
+ * banner probes that no `.mjs` reached from any entrypoint.
713
+ *
714
+ * ## Entry roots, and why CLI entrypoints are among them
715
+ *
716
+ * Roots are (a) every module named in a NON-markdown wiring surface
717
+ * (`package.json`, `.gitlab-ci.yml`, `.github/workflows/**`, `.husky/**`,
718
+ * `hooks/*.json`) and (b) every CLI entrypoint. Reachability then follows
719
+ * module→module references transitively.
720
+ *
721
+ * (b) is a DELIBERATE boundary, not an oversight. A CLI entrypoint is invoked by
722
+ * path, and "the operator types `/autopilot`, whose skill body runs
723
+ * `node scripts/autopilot.mjs`" IS this repo's architecture — reporting it would
724
+ * indict the design rather than a defect. Measured 2026-08-23: treating CLI
725
+ * entrypoints as non-roots moves the census from 71 to 268 of 468 modules
726
+ * (15.6% → 57.5%), i.e. straight into the broken-instrument band that
727
+ * `.claude/rules/host-resources.md` § HR-101 forbids. A LIBRARY module, by
728
+ * contrast, can only ever be reached by being imported — so "nothing imports it,
729
+ * transitively" is a fact about the machine, not a judgement about prose.
730
+ *
731
+ * ## Cluster roots — one defect, one line
732
+ *
733
+ * Only the ROOT of each unreachable cluster is reported: a module no OTHER
734
+ * unreachable module references. `scripts/lib/owner-config.mjs` has no importer
735
+ * and drags its whole 7-file `owner-config/` subtree down with it; reporting the
736
+ * six interior files would multiply one deletion into seven findings that all
737
+ * disappear together. Measured on the live tree: 71 unreachable modules collapse
738
+ * to 50 roots. This is category separation in the sense of
739
+ * `.claude/rules/development.md` § Guard & Threshold Design — a structural split,
740
+ * never a raised threshold.
741
+ *
742
+ * ## Named residuals
743
+ *
744
+ * - **Basename granularity.** 22 basenames collide across 56 files (7 × `schema.mjs`,
745
+ * 3 × `telemetry.mjs`, …), so a mention of `schema.mjs` marks every `schema.mjs`
746
+ * as referenced. This errs toward WIRED — it can hide a finding, never invent
747
+ * one, which is the right direction for a check whose failure mode is being
748
+ * switched off. Revisit if a real module-resolver (import-specifier resolution
749
+ * relative to the importing file) becomes cheap, or if a collided basename is
750
+ * ever confirmed to mask a true positive.
751
+ * - **Reachable ≠ executed.** A module imported by a hook that never takes that
752
+ * branch reads as wired here. Proving execution needs coverage data, not a graph.
753
+ * - **Reachable from SOME entrypoint is not reachable from the PROMISED one.**
754
+ * This is the sharpest limit and it cost real recall. `ci-status-banner.mjs` is
755
+ * imported by `dispatcher/rank.mjs`, so S4 stays silent — yet CLAUDE.md promises
756
+ * it runs at SESSION-START, and no SessionStart path reached it at `4f6404e`.
757
+ * Same shape for `sessions-integrity-banner.mjs` (via `session-record-repair.mjs`)
758
+ * and `historical-guard.mjs` (via `check-banner-parity.mjs`). Catching those needs
759
+ * a per-entrypoint reachability question ("is X reachable from the SessionStart
760
+ * hook?"), which is a different check with a different root set, not a tightening
761
+ * of this one. Revisit if a second promised-entrypoint claim is ever missed.
762
+ *
763
+ * @param {string} pluginRoot absolute plugin root
764
+ * @returns {{findings: Finding[], scanned: {modules: number, roots: number, unreachable: number}}}
765
+ */
766
+ export function collectUnreachableLibraryModules(pluginRoot) {
767
+ const absolute = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir))).sort();
768
+ const modules = absolute.map((file) => {
769
+ const body = readFileSync(file, 'utf8');
770
+ const lines = body.split('\n');
771
+ const relative = path.relative(pluginRoot, file);
772
+ return {
773
+ relative,
774
+ base: path.basename(file),
775
+ entrypoint: isCliEntrypoint(body),
776
+ exports: collectExportedSymbols(body),
777
+ // This file contributes NO edges — the S4 counterpart of the SELF_REL
778
+ // exclusion the S1/S2 corpus already applies, and for the identical
779
+ // reason. Every S4 `ALLOWLIST` key is a module path written here as a
780
+ // string literal, so counting it as a mention marks the allowlisted
781
+ // module reachable, S4 stops reporting it, and the entry is then reported
782
+ // `allowlist-stale` — the check silently blinding itself to exactly the
783
+ // module an operator flagged. Measured 2026-08-28 on the first S4
784
+ // allowlist entry: 52 → 51 unreachable modules plus one bogus stale line.
785
+ mentions: relative === SELF_REL ? new Set() : mentionedModuleTokens(lines),
786
+ };
787
+ });
788
+
789
+ // Non-markdown surfaces that mechanically invoke a module by path.
790
+ const wiringBodies = [
791
+ ...WIRING_FILES.map((rel) => path.join(pluginRoot, rel)).filter((file) => existsSync(file)),
792
+ ...WIRING_DIRS.flatMap(([dir, extensions]) =>
793
+ walkCode(path.join(pluginRoot, dir), [], extensions, EXCLUDED_DIRS),
794
+ ),
795
+ ].map((file) => readFileSync(file, 'utf8'));
796
+ const wiringTokens = mentionedModuleTokens(wiringBodies.join('\n').split('\n'));
797
+
798
+ /** @type {Set<string>} */
799
+ const reachable = new Set();
800
+ /** @type {string[]} */
801
+ const stack = [];
802
+ for (const module of modules) {
803
+ if (!module.entrypoint && !wiringTokens.has(module.base)) continue;
804
+ reachable.add(module.relative);
805
+ stack.push(module.relative);
806
+ }
807
+
808
+ const byRelative = new Map(modules.map((module) => [module.relative, module]));
809
+ while (stack.length > 0) {
810
+ const current = byRelative.get(/** @type {string} */ (stack.pop()));
811
+ if (!current) continue;
812
+ for (const module of modules) {
813
+ if (reachable.has(module.relative) || !current.mentions.has(module.base)) continue;
814
+ reachable.add(module.relative);
815
+ stack.push(module.relative);
816
+ }
817
+ }
818
+
819
+ const unreachable = modules.filter(
820
+ (module) => !reachable.has(module.relative) && !module.entrypoint && module.exports.length > 0,
821
+ );
822
+ const unreachableSet = new Set(unreachable.map((module) => module.relative));
823
+ const roots = unreachable.filter(
824
+ (module) =>
825
+ !unreachable.some((other) => other.relative !== module.relative && other.mentions.has(module.base)),
826
+ );
827
+
828
+ const findings = roots.map((module) => {
829
+ const dragged = [...module.mentions].filter(
830
+ (token) => token !== module.base && [...unreachableSet].some((rel) => path.basename(rel) === token),
831
+ );
832
+ const tail = dragged.length > 0 ? `, and drags ${dragged.length} further unreachable module(s)` : '';
833
+ return /** @type {Finding} */ ({
834
+ kind: 'unreachable-library-module',
835
+ key: module.relative,
836
+ message:
837
+ `exports ${module.exports.length} symbol(s) (${module.exports.slice(0, 3).join(', ')}) but no hook, ` +
838
+ `npm script, CI job or husky stage reaches it — transitively${tail}. Only markdown names it, and ` +
839
+ 'prose is an instruction to an LLM, not a caller: wire it, delete it, or allowlist it with a reason',
840
+ });
841
+ });
842
+
843
+ return {
844
+ findings,
845
+ scanned: { modules: modules.length, roots: roots.length, unreachable: unreachable.length },
846
+ };
847
+ }
848
+
583
849
  /**
584
850
  * Run the full census.
585
851
  *
@@ -598,7 +864,14 @@ export function inspectUnwiredFeatures(pluginRoot) {
598
864
  const findings = [];
599
865
  const result = {
600
866
  ok: false,
601
- summary: { declaredKeys: 0, consumerFiles: 0, unwired: 0, allowlisted: 0, orphanedModules: 0 },
867
+ summary: {
868
+ declaredKeys: 0,
869
+ consumerFiles: 0,
870
+ unwired: 0,
871
+ allowlisted: 0,
872
+ orphanedModules: 0,
873
+ unreachableModules: 0,
874
+ },
602
875
  /** @type {string[]} */
603
876
  sourcesScanned: [],
604
877
  findings,
@@ -613,6 +886,8 @@ export function inspectUnwiredFeatures(pluginRoot) {
613
886
  let parserBody;
614
887
  /** @type {ReturnType<typeof collectOrphanedProseModules>} */
615
888
  let orphans;
889
+ /** @type {ReturnType<typeof collectUnreachableLibraryModules>} */
890
+ let unreachable;
616
891
  try {
617
892
  declared = collectDeclaredKeys(pluginRoot);
618
893
  corpus = CONSUMER_DIRS.flatMap((dir) => walkCode(path.join(pluginRoot, dir)))
@@ -630,6 +905,7 @@ export function inspectUnwiredFeatures(pluginRoot) {
630
905
  .map((absolute) => readFileSync(absolute, 'utf8'))
631
906
  .join('\n');
632
907
  orphans = collectOrphanedProseModules(pluginRoot);
908
+ unreachable = collectUnreachableLibraryModules(pluginRoot);
633
909
  } catch (error) {
634
910
  result.toolError = true;
635
911
  findings.push({
@@ -691,6 +967,24 @@ export function inspectUnwiredFeatures(pluginRoot) {
691
967
  findings.push(issue);
692
968
  }
693
969
 
970
+ // S3 — prose promises a module nothing calls. Reported alongside the config
971
+ // census because it is the same defect class one level out: a claim with no
972
+ // mechanism behind it.
973
+ result.summary.orphanedModules = orphans.findings.length;
974
+ findings.push(...orphans.findings);
975
+
976
+ // S4 — no process that actually starts can reach these. Allowlistable on the
977
+ // same terms as a config key: the entry's reason must name the real consumer.
978
+ for (const finding of unreachable.findings) {
979
+ if (Object.prototype.hasOwnProperty.call(ALLOWLIST, finding.key)) {
980
+ result.summary.allowlisted += 1;
981
+ flagged.add(finding.key);
982
+ continue;
983
+ }
984
+ result.summary.unreachableModules += 1;
985
+ findings.push(finding);
986
+ }
987
+
694
988
  for (const key of Object.keys(ALLOWLIST).sort()) {
695
989
  if (flagged.has(key)) continue;
696
990
  findings.push({
@@ -702,12 +996,6 @@ export function inspectUnwiredFeatures(pluginRoot) {
702
996
  });
703
997
  }
704
998
 
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
999
  result.ok = !result.toolError && findings.length === 0;
712
1000
  return result;
713
1001
  }
@@ -721,7 +1009,7 @@ export function inspectUnwiredFeatures(pluginRoot) {
721
1009
  * @param {string} pluginRoot absolute plugin root
722
1010
  * @returns {number} 0 = scan completed (with or without findings), 2 = tool error
723
1011
  */
724
- export function runCheckUnwiredFeatures(pluginRoot) {
1012
+ export function runCheckUnwiredFeatures(pluginRoot, { list = false } = {}) {
725
1013
  console.log('--- Check: unwired config keys (declared-but-unread census, WARN-only) ---');
726
1014
  const inspection = inspectUnwiredFeatures(pluginRoot);
727
1015
 
@@ -732,14 +1020,32 @@ export function runCheckUnwiredFeatures(pluginRoot) {
732
1020
  return 2;
733
1021
  }
734
1022
 
735
- const { declaredKeys, consumerFiles, unwired, allowlisted, orphanedModules } = inspection.summary;
1023
+ const { declaredKeys, consumerFiles, unwired, allowlisted, orphanedModules, unreachableModules } =
1024
+ inspection.summary;
1025
+
1026
+ // S4 is a BACKLOG, not a per-run alarm: 50 findings on the live tree against
1027
+ // 1-2 WARN lines from every sibling check. Printing all 50 every run is the
1028
+ // "gate that prints 282 lines gets switched off in week two" failure this
1029
+ // file's own header names. So the default carries the NUMBER (which ratchets,
1030
+ // and which a reviewer can compare run to run) plus the first few paths; the
1031
+ // full census is one `--list` away. Nothing is suppressed — only deferred.
1032
+ const s4 = inspection.findings.filter((item) => item.kind === 'unreachable-library-module');
736
1033
  for (const item of inspection.findings) {
1034
+ if (!list && item.kind === 'unreachable-library-module') continue;
737
1035
  console.log(` WARN: [${item.kind}] ${item.key} — ${item.message}`);
738
1036
  }
1037
+ if (!list && s4.length > 0) {
1038
+ console.log(
1039
+ ` WARN: [unreachable-library-module] ${s4.length} library module(s) that no hook, npm script, ` +
1040
+ `CI job or husky stage can reach — e.g. ${s4.slice(0, 3).map((item) => item.key).join(', ')}. ` +
1041
+ 'Re-run with --list for the full census.',
1042
+ );
1043
+ }
1044
+
739
1045
  console.log(
740
1046
  ` PASS: censused ${declaredKeys} declared key(s) from ${inspection.sourcesScanned.join(' + ') || '(no source)'} ` +
741
1047
  `against ${consumerFiles} consumer file(s) — ${unwired} unwired, ${allowlisted} allowlisted, ` +
742
- `${orphanedModules} prose-orphaned module(s)`,
1048
+ `${orphanedModules} prose-orphaned module(s), ${unreachableModules} unreachable module(s)`,
743
1049
  );
744
1050
  console.log('');
745
1051
  console.log('Results: 1 passed, 0 failed');
@@ -748,10 +1054,12 @@ export function runCheckUnwiredFeatures(pluginRoot) {
748
1054
 
749
1055
  const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
750
1056
  if (isMain) {
751
- const pluginRoot = process.argv[2];
1057
+ const args = process.argv.slice(2);
1058
+ const pluginRoot = args.find((arg) => !arg.startsWith('-'));
752
1059
  if (!pluginRoot) {
753
- console.error('Usage: check-unwired-features.mjs <plugin-root>');
1060
+ console.error('Usage: check-unwired-features.mjs <plugin-root> [--list]');
1061
+ console.error(' --list print every unreachable-library-module finding instead of the aggregate');
754
1062
  process.exit(2);
755
1063
  }
756
- process.exit(runCheckUnwiredFeatures(path.resolve(pluginRoot)));
1064
+ process.exit(runCheckUnwiredFeatures(path.resolve(pluginRoot), { list: args.includes('--list') }));
757
1065
  }