session-orchestrator 3.24.0 → 4.0.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 (350) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +1 -1
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1125 -2
  81. package/NOTICE +11 -6
  82. package/README.md +127 -94
  83. package/agents/eval-judge.md +1 -1
  84. package/agents/skill-applied-judge.md +1 -1
  85. package/assets/wave-lifecycle.svg +98 -0
  86. package/commands/release.md +6 -3
  87. package/commands/session.md +18 -3
  88. package/docs/README.md +4 -0
  89. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  90. package/docs/baseline.md +67 -0
  91. package/docs/ci-setup.md +108 -62
  92. package/docs/codex-setup.md +65 -21
  93. package/docs/components.md +36 -15
  94. package/docs/cursor-setup.md +6 -2
  95. package/docs/events-schema.md +9 -6
  96. package/docs/instruction-delivery.md +62 -0
  97. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  98. package/docs/migration-v4.md +341 -0
  99. package/docs/pi-setup.md +6 -1
  100. package/docs/plugin-architecture-v3.md +1 -1
  101. package/docs/rule-authoring.md +85 -19
  102. package/docs/scope-collision-guard.md +5 -5
  103. package/docs/session-config-reference.md +57 -56
  104. package/docs/session-config-template.md +6 -29
  105. package/docs/telemetry.md +157 -3
  106. package/docs/vault-docs-architecture.md +50 -11
  107. package/hooks/_lib/hook-import-set.json +1487 -0
  108. package/hooks/_lib/subagent-transcript.mjs +562 -0
  109. package/hooks/config-protection.mjs +2 -2
  110. package/hooks/cwd-change-restore.mjs +2 -2
  111. package/hooks/enforce-commands.mjs +69 -0
  112. package/hooks/hooks-codex.json +1 -1
  113. package/hooks/hooks-cursor.json +10 -0
  114. package/hooks/hooks-pi.json +5 -0
  115. package/hooks/hooks.json +6 -1
  116. package/hooks/loop-guard.mjs +3 -3
  117. package/hooks/on-session-end.mjs +2 -2
  118. package/hooks/on-session-start.mjs +103 -2
  119. package/hooks/on-stop.mjs +36 -11
  120. package/hooks/operator-steer.mjs +2 -2
  121. package/hooks/post-bash-write-verify.mjs +85 -0
  122. package/hooks/post-edit-import-probe.mjs +344 -0
  123. package/hooks/post-subagent-discovery-validator.mjs +187 -431
  124. package/hooks/post-tool-batch-wave-signal.mjs +118 -4
  125. package/hooks/post-tool-failure-corrective-context.mjs +2 -2
  126. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  127. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  128. package/hooks/skill-invocation-telemetry.mjs +17 -5
  129. package/hooks/subagent-telemetry.mjs +13 -4
  130. package/monitors/monitors.json +3 -3
  131. package/package.json +9 -1
  132. package/pi/prompts/session.md +2 -2
  133. package/plugin.json +27 -0
  134. package/scripts/backfill-abandoned-sessions.mjs +50 -4
  135. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  136. package/scripts/dialectic-deriver.mjs +73 -8
  137. package/scripts/export-hw-learnings.mjs +113 -1
  138. package/scripts/generate-agents-skills.mjs +378 -0
  139. package/scripts/generate-cursor-adapter.mjs +45 -8
  140. package/scripts/generate-hook-import-set.mjs +249 -0
  141. package/scripts/lib/agent-status.mjs +13 -2
  142. package/scripts/lib/auto-dream.mjs +38 -36
  143. package/scripts/lib/autonomy/suitability.mjs +6 -0
  144. package/scripts/lib/autopilot/loop.mjs +2 -2
  145. package/scripts/lib/ci-status-banner.mjs +220 -75
  146. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  147. package/scripts/lib/config/auto-dream.mjs +2 -1
  148. package/scripts/lib/config/block-header.mjs +8 -0
  149. package/scripts/lib/config/block-preprocess.mjs +177 -0
  150. package/scripts/lib/config/broken-window.mjs +2 -1
  151. package/scripts/lib/config/cold-start.mjs +2 -1
  152. package/scripts/lib/config/config-protection.mjs +22 -2
  153. package/scripts/lib/config/context-coverage.mjs +2 -1
  154. package/scripts/lib/config/cross-repo.mjs +2 -1
  155. package/scripts/lib/config/custom-phases.mjs +2 -1
  156. package/scripts/lib/config/dialectic.mjs +2 -1
  157. package/scripts/lib/config/discovery-validator.mjs +2 -1
  158. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  159. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  160. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  161. package/scripts/lib/config/docs-staleness.mjs +2 -1
  162. package/scripts/lib/config/drift-check.mjs +2 -1
  163. package/scripts/lib/config/eval.mjs +2 -1
  164. package/scripts/lib/config/events-rotation.mjs +2 -1
  165. package/scripts/lib/config/evolve.mjs +8 -2
  166. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  167. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  168. package/scripts/lib/config/handover-gate.mjs +2 -1
  169. package/scripts/lib/config/health-endpoints.mjs +7 -2
  170. package/scripts/lib/config/issue-budget.mjs +2 -1
  171. package/scripts/lib/config/loop-guard.mjs +2 -1
  172. package/scripts/lib/config/memory.mjs +2 -1
  173. package/scripts/lib/config/moc-staleness.mjs +2 -1
  174. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  175. package/scripts/lib/config/private-config-dir.mjs +67 -0
  176. package/scripts/lib/config/reconcile.mjs +2 -1
  177. package/scripts/lib/config/remote-hosts.mjs +2 -1
  178. package/scripts/lib/config/section-extractor.mjs +7 -1
  179. package/scripts/lib/config/skill-evolution.mjs +2 -1
  180. package/scripts/lib/config/slopcheck.mjs +2 -1
  181. package/scripts/lib/config/state-md-lock.mjs +2 -1
  182. package/scripts/lib/config/templates-first.mjs +2 -1
  183. package/scripts/lib/config/test.mjs +2 -1
  184. package/scripts/lib/config/vault-integration.mjs +7 -1
  185. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  186. package/scripts/lib/config/vault-staleness.mjs +2 -1
  187. package/scripts/lib/config/vault-sync.mjs +2 -1
  188. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  189. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  190. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  191. package/scripts/lib/convergence-monitor.mjs +82 -16
  192. package/scripts/lib/dispatcher/rank.mjs +124 -48
  193. package/scripts/lib/ecosystem-health.mjs +16 -2
  194. package/scripts/lib/eval/engine.mjs +9 -1
  195. package/scripts/lib/eval/session-resolve.mjs +23 -4
  196. package/scripts/lib/events.mjs +22 -6
  197. package/scripts/lib/frontmatter-guard.mjs +131 -13
  198. package/scripts/lib/gates/gate-full.mjs +26 -0
  199. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  200. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  201. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  202. package/scripts/lib/host-identity.mjs +50 -11
  203. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  204. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  205. package/scripts/lib/learnings/io.mjs +60 -6
  206. package/scripts/lib/memory-proposals/store.mjs +30 -22
  207. package/scripts/lib/owner-config-banner.mjs +43 -6
  208. package/scripts/lib/owner-config-loader.mjs +21 -10
  209. package/scripts/lib/owner-interview.mjs +3 -3
  210. package/scripts/lib/owner-yaml.mjs +207 -14
  211. package/scripts/lib/platform.mjs +108 -15
  212. package/scripts/lib/plugin-update-banner.mjs +406 -0
  213. package/scripts/lib/project-hygiene.mjs +38 -2
  214. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  215. package/scripts/lib/quality-gate.mjs +133 -44
  216. package/scripts/lib/reconcile/emitter.mjs +68 -6
  217. package/scripts/lib/reconcile/engine.mjs +13 -4
  218. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  219. package/scripts/lib/reconcile/writer.mjs +40 -18
  220. package/scripts/lib/session-close-backfill.mjs +67 -9
  221. package/scripts/lib/session-id.mjs +12 -23
  222. package/scripts/lib/session-identity/own-session.mjs +125 -10
  223. package/scripts/lib/session-lock-shape.mjs +43 -0
  224. package/scripts/lib/session-lock.mjs +5 -10
  225. package/scripts/lib/session-registry.mjs +25 -9
  226. package/scripts/lib/session-schema/constants.mjs +36 -2
  227. package/scripts/lib/session-schema/validator.mjs +38 -4
  228. package/scripts/lib/session-start-probes.mjs +18 -1
  229. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  230. package/scripts/lib/skill-health/join.mjs +17 -4
  231. package/scripts/lib/state-md.mjs +78 -0
  232. package/scripts/lib/sunset/walker.mjs +6 -0
  233. package/scripts/lib/telemetry/schema.mjs +181 -9
  234. package/scripts/lib/telemetry/sync.mjs +368 -12
  235. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  236. package/scripts/lib/validate/check-agents.mjs +3 -3
  237. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  238. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  239. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  240. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  241. package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
  242. package/scripts/lib/validate/check-unwired-features.mjs +0 -2
  243. package/scripts/lib/validate/check-validator-registration.mjs +10 -4
  244. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  245. package/scripts/lib/vault-backfill/template.mjs +63 -6
  246. package/scripts/lib/vault-mirror/process.mjs +165 -42
  247. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  248. package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
  249. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  250. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  251. package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
  252. package/scripts/lib/wave-resource-gate.mjs +8 -2
  253. package/scripts/lib/wave-sizing.mjs +4 -1
  254. package/scripts/lib/wave-transcript-tail.mjs +118 -4
  255. package/scripts/materialize-wave-scope.mjs +12 -5
  256. package/scripts/memory-propose.mjs +19 -5
  257. package/scripts/migrate-cold-start-seed.mjs +4 -1
  258. package/scripts/parse-config.mjs +60 -3
  259. package/scripts/release.mjs +337 -29
  260. package/scripts/repair-invalid-sessions.mjs +3 -3
  261. package/scripts/run-quality-gate.mjs +128 -11
  262. package/scripts/sweep-expired-learnings.mjs +90 -0
  263. package/scripts/sync-vault-schema.mjs +3 -1
  264. package/scripts/telemetry.mjs +2 -2
  265. package/scripts/validate-plugin.mjs +161 -0
  266. package/scripts/validate-wave-scope.mjs +28 -8
  267. package/scripts/wave-scope-binding.mjs +215 -0
  268. package/skills/_shared/instruction-file-resolution.md +10 -0
  269. package/skills/_shared/parallel-aware-preamble.md +1 -0
  270. package/skills/_shared/platform-tools.md +1 -1
  271. package/skills/_shared/state-ownership.md +1 -1
  272. package/skills/architecture/SKILL.md +7 -5
  273. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  274. package/skills/autopilot/SKILL.md +4 -18
  275. package/skills/claude-md-drift-check/SKILL.md +5 -1
  276. package/skills/claude-md-drift-check/checker.mjs +62 -2
  277. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  278. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  279. package/skills/discovery/probes-arch.md +20 -18
  280. package/skills/dispatcher/SKILL.md +3 -2
  281. package/skills/evolve/SKILL.md +65 -26
  282. package/skills/frontmatter-guard/SKILL.md +11 -5
  283. package/skills/npm-publish/SKILL.md +1 -1
  284. package/skills/reconcile/SKILL.md +33 -0
  285. package/skills/remote-offload/SKILL.md +1 -1
  286. package/skills/session-end/SKILL.md +18 -905
  287. package/skills/session-end/phase-3-6-tail.md +10 -3
  288. package/skills/session-end/plan-verification.md +221 -155
  289. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  290. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  291. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  292. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  293. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  294. package/skills/session-end/references/session-summary-template.md +62 -0
  295. package/skills/session-plan/SKILL.md +49 -0
  296. package/skills/session-start/SKILL.md +22 -904
  297. package/skills/session-start/phase-8-5-express-path.md +1 -1
  298. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  299. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  300. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  301. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  302. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  303. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  304. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  305. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  306. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  307. package/skills/vault-sync/validator.mjs +21 -27
  308. package/skills/wave-executor/SKILL.md +15 -1
  309. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  310. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  311. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  312. package/skills/wave-executor/wave-loop.md +14 -1309
  313. package/templates/_shared/journey-manifest.md +10 -6
  314. package/.cursor/commands/autopilot-multi.md +0 -14
  315. package/.cursor/commands/contract-version-bump.md +0 -14
  316. package/.cursor/commands/journey-audit.md +0 -14
  317. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  318. package/.cursor/skills/daily/SKILL.md +0 -12
  319. package/.cursor/skills/domain-model/SKILL.md +0 -13
  320. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  321. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  322. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  323. package/commands/autopilot-multi.md +0 -74
  324. package/commands/contract-version-bump.md +0 -28
  325. package/commands/journey-audit.md +0 -43
  326. package/pi/prompts/autopilot-multi.md +0 -12
  327. package/pi/prompts/contract-version-bump.md +0 -12
  328. package/pi/prompts/journey-audit.md +0 -12
  329. package/scripts/autopilot-multi.mjs +0 -885
  330. package/scripts/backfill-learnings-expires.mjs +0 -196
  331. package/scripts/backfill-learnings.mjs +0 -203
  332. package/scripts/fleet-instruction-scan.mjs +0 -141
  333. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  334. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  335. package/scripts/lib/webhook-url.mjs +0 -105
  336. package/scripts/lifecycle-sim-v6.mjs +0 -347
  337. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  338. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  339. package/scripts/upload-social-preview.mjs +0 -316
  340. package/skills/_shared/model-selection.md +0 -64
  341. package/skills/contract-version-bump/SKILL.md +0 -219
  342. package/skills/daily/SKILL.md +0 -222
  343. package/skills/daily/generate.sh +0 -92
  344. package/skills/daily/templates/daily.md.tpl +0 -36
  345. package/skills/journey-audit/SKILL.md +0 -270
  346. package/skills/skill-creator/SKILL.md +0 -168
  347. package/skills/ubiquitous-language/SKILL.md +0 -97
  348. package/skills/vault-sync/package-lock.json +0 -40
  349. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  350. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -81,11 +81,12 @@
81
81
  * 1 — at least one failure (or usage error / unreadable root)
82
82
  */
83
83
 
84
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
84
+ import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
85
85
  import { join, extname, relative, basename, sep, resolve } from 'node:path';
86
86
  import { execFileSync } from 'node:child_process';
87
87
  import { argv } from 'node:process';
88
88
  import { fileURLToPath } from 'node:url';
89
+ import { createRequire } from 'node:module';
89
90
  // NOTE: the two host-local helper modules (../config/host-paths.mjs and
90
91
  // ./confidential-names.mjs) are imported DYNAMICALLY inside
91
92
  // getConfidentialNamePatterns(), NOT statically here. This scanner is a
@@ -93,7 +94,9 @@ import { fileURLToPath } from 'node:url';
93
94
  // "Reuse the same scanner") — the .husky/pre-commit E2E and any consumer that
94
95
  // copies ONLY this file into a fresh tree would otherwise crash at module load
95
96
  // with ERR_MODULE_NOT_FOUND, blocking clean commits. Dynamic import lets CP11 go
96
- // silently inert when the helpers are absent while CP1–CP10 run unchanged.
97
+ // inert (one WARN line, exit unchanged) when the helpers are absent while CP1–CP10
98
+ // run unchanged. That degrade is scoped to ERR_MODULE_NOT_FOUND ALONE (#1244) —
99
+ // every other CP11 load failure fails CLOSED; see getConfidentialNamePatterns().
97
100
 
98
101
  // ---------------------------------------------------------------------------
99
102
  // CLI / import-mode detection (#661)
@@ -103,7 +106,25 @@ import { fileURLToPath } from 'node:url';
103
106
  // AND an importable library (the canonicalization helpers are unit-tested in
104
107
  // isolation). When imported, the top-level scan + process.exit() must NOT run.
105
108
  // `isMain` is true only when this file is the node entry point.
106
- const isMain = argv[1] !== undefined && resolve(argv[1]) === fileURLToPath(import.meta.url);
109
+ // REALPATH BOTH SIDES (#1244 / same class as the #1153 argv[1] main-guard finding).
110
+ // `fileURLToPath(import.meta.url)` is already canonicalized by Node's ESM loader,
111
+ // so a plain `resolve(argv[1])` comparison silently fails whenever the invocation
112
+ // path traverses a symlink — on macOS every `/tmp/...` path does (`/tmp` →
113
+ // `/private/tmp`). The failure mode is the worst one a security guard has: isMain
114
+ // stays false, runScan() never runs, and the process prints NOTHING and exits 0,
115
+ // i.e. a clean-looking pass that never scanned a byte. realpathSync both sides so
116
+ // the two spellings of the same file compare equal; if realpathSync throws (path
117
+ // gone, permission denied) fall back to the historical comparison.
118
+ function canonicalPath(p) {
119
+ try {
120
+ return realpathSync(p);
121
+ } catch {
122
+ return p;
123
+ }
124
+ }
125
+ const isMain =
126
+ argv[1] !== undefined &&
127
+ canonicalPath(resolve(argv[1])) === canonicalPath(fileURLToPath(import.meta.url));
107
128
 
108
129
  // CLI: single positional arg required (only enforced when run directly).
109
130
  const pluginRoot = argv[2];
@@ -568,8 +589,10 @@ function escapeRegex(s) {
568
589
  // its cases red (#974).
569
590
  //
570
591
  // The dynamic-import degrade used by getConfidentialNamePatterns() is NOT
571
- // available here. That helper degrades to `[]` CP11 goes inert, CP1–CP10 keep
572
- // running, nothing leaks. A failed REDACTION has the opposite failure direction:
592
+ // available here. That helper degrades to `[]` for the standalone-copy case alone
593
+ // CP11 goes inert, CP1–CP10 keep running, nothing leaks (every OTHER load
594
+ // failure there is now a counted FAIL, #1244). A failed REDACTION has the opposite
595
+ // failure direction:
573
596
  // it prints confidential names verbatim into a PUBLIC GitHub-Actions log, which
574
597
  // is precisely the exposure this function exists to prevent (Fix 1 below). The
575
598
  // redaction sink must be unconditionally present, so it lives inline.
@@ -645,6 +668,78 @@ function redactSpans(line, patterns) {
645
668
  return out;
646
669
  }
647
670
 
671
+ /**
672
+ * The THREE modules getConfidentialNamePatterns() imports directly, as absolute
673
+ * URLs resolved against THIS file. Used only to classify an ERR_MODULE_NOT_FOUND:
674
+ * `err.url` carries the URL of the module that could not be found (measured on
675
+ * Node 24 — a relative specifier yields `url`, a bare package specifier yields
676
+ * none), so a miss on one of these three is the standalone single-file copy,
677
+ * while a miss anywhere DEEPER (a transitive of an in-tree helper, or a bare
678
+ * package) is a broken install that must fail CLOSED rather than go inert.
679
+ */
680
+ const CP11_DIRECT_SIBLING_URLS = new Set(
681
+ ['../config/host-paths.mjs', './confidential-names.mjs', '../owner-yaml.mjs'].map(
682
+ (spec) => new URL(spec, import.meta.url).href,
683
+ ),
684
+ );
685
+
686
+ /**
687
+ * True when an ERR_MODULE_NOT_FOUND names one of this scanner's own three DIRECT
688
+ * helper imports — i.e. the documented standalone-vendoring shape. False for a
689
+ * transitive relative module or a bare package (no `err.url` at all), which is a
690
+ * broken in-tree install: CP11 then fails closed instead of silently returning
691
+ * zero patterns while names ARE configured.
692
+ *
693
+ * @param {{ url?: string }} err
694
+ * @returns {boolean}
695
+ */
696
+ function isMissingDirectSibling(err) {
697
+ return typeof err?.url === 'string' && CP11_DIRECT_SIBLING_URLS.has(err.url);
698
+ }
699
+
700
+ /**
701
+ * Was `paths.confidential-names-file` actually written into owner.yaml?
702
+ *
703
+ * WHY THIS RE-READS THE FILE. `loadOwnerConfig()` reports an invalid OPTIONAL
704
+ * section only as `droppedSections: [{ section: 'paths', errors }]` and replaces
705
+ * `config.paths` with the DEFAULTS — the raw keys of the dropped section are not
706
+ * recoverable from its return value, and its `errors[]` name a key only for the
707
+ * per-key `paths.<key> must be a string` case (a `paths: 42` shape names none).
708
+ * So the one question CP11's verdict turns on — did the operator configure a
709
+ * names file AT ALL — has no answer in the loader's contract today. Re-parsing
710
+ * the file for that single key is the minimal derivation; widening the loader's
711
+ * return shape would touch every one of its callers.
712
+ *
713
+ * Only reached when the YAML already parsed once inside loadOwnerConfig (the
714
+ * dropped-section branch implies that), so `js-yaml` is resolvable here; 'unknown'
715
+ * is the defensive residue and makes the caller fail closed.
716
+ *
717
+ * Returns a CLASS, never the value: the configured path is host-local and this
718
+ * scanner's output is captured by a PUBLIC CI mirror (see the no-path rule above).
719
+ *
720
+ * @param {string} ownerYamlPath
721
+ * @returns {'configured'|'absent'|'unknown'}
722
+ */
723
+ function rawConfidentialNamesKeyState(ownerYamlPath) {
724
+ let parsed;
725
+ try {
726
+ const yaml = createRequire(import.meta.url)('js-yaml');
727
+ parsed = yaml.load(readFileSync(ownerYamlPath, 'utf8'));
728
+ } catch {
729
+ return 'unknown';
730
+ }
731
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'unknown';
732
+ const paths = parsed.paths;
733
+ // `paths:` absent, null, or not a mapping at all → the key cannot be in there.
734
+ if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) return 'absent';
735
+ const value = paths['confidential-names-file'];
736
+ if (value === undefined || value === null) return 'absent';
737
+ // A non-string (or empty-string) value is still an ATTEMPT to configure CP11 —
738
+ // except '' , which is the schema's documented "no override" spelling.
739
+ if (typeof value === 'string' && value.trim() === '') return 'absent';
740
+ return 'configured';
741
+ }
742
+
648
743
  /**
649
744
  * CP11: build word-boundary, case-insensitive regexes from the host-local
650
745
  * confidential-names list (#728a). Mirrors CP6_PATTERNS (private slugs), but the
@@ -667,24 +762,167 @@ function redactSpans(line, patterns) {
667
762
  * with ERR_MODULE_NOT_FOUND. When the helpers are unresolvable (or throw), CP11
668
763
  * degrades to inert ([] patterns) and CP1–CP10 run unchanged.
669
764
  *
670
- * @returns {Promise<RegExp[]>} one regex per configured name, or [] when
671
- * unconfigured / unusable / unresolvable (standalone copy).
765
+ * FAIL CLOSED WHEN CP11 WAS EXPECTED (GitLab #1244). The single bare
766
+ * `try { } catch { return [] }` this function used to be conflated three
767
+ * outcomes that must not share a verdict, and printed `PASS` for all three:
768
+ * (a) the helpers are unresolvable — the STANDALONE single-file copy. The
769
+ * intended degrade, and the ONLY one: inert + one WARN line, exit unchanged.
770
+ * (b) CP11 is not configured at all (no names file, no env) — the ~99% default:
771
+ * inactive, silent, PASS. Unchanged. This INCLUDES an owner.yaml whose
772
+ * `paths:` section was dropped as invalid for some OTHER key (the #1244
773
+ * fix over-reached here and failed every commit on such a host): the raw
774
+ * key is re-read (rawConfidentialNamesKeyState) and, when absent, CP11 is
775
+ * INACTIVE — one WARN naming the dropped section, no FAIL, exit unchanged.
776
+ * (c) CP11 IS configured — env set, a names file resolved, or the raw
777
+ * `paths.confidential-names-file` key present in a `paths:` section that
778
+ * was dropped as invalid — or its configuration is unknowable because
779
+ * owner.yaml exists but cannot be parsed (e.g. `js-yaml` missing) — and
780
+ * could not be loaded. Previously indistinguishable from (b): the scanner matched
781
+ * nothing and printed `PASS: no owner-privacy leakage found`. A guard that
782
+ * cannot run must say so and FAIL, never report the clean verdict it did
783
+ * not earn — so this returns a `disabledReason` that runScan turns into a
784
+ * `CP11 DISABLED` stderr line plus a counted FAIL (exit 1).
785
+ *
786
+ * The reason strings deliberately carry NO PATH. This scanner's stdout+stderr are
787
+ * captured by a PUBLIC GitHub-Actions mirror, and the confidential-names path is
788
+ * host-local — echoing it there would leak the very `/Users/<name>/…` shape CP1
789
+ * exists to block. The operator knows their own path; the CLASS of failure is what
790
+ * this line has to convey.
791
+ *
792
+ * @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
672
793
  */
673
794
  async function getConfidentialNamePatterns() {
795
+ let helpers;
796
+ try {
797
+ helpers = {
798
+ hostPaths: await import('../config/host-paths.mjs'),
799
+ confidentialNames: await import('./confidential-names.mjs'),
800
+ // Already a transitive dependency (host-paths.mjs imports it statically), so
801
+ // this adds no file to the standalone-copy chain the husky E2E mirrors.
802
+ ownerYaml: await import('../owner-yaml.mjs'),
803
+ };
804
+ } catch (err) {
805
+ if (err?.code === 'ERR_MODULE_NOT_FOUND' && isMissingDirectSibling(err)) {
806
+ // (a) standalone single-file vendoring — the documented degrade.
807
+ return {
808
+ patterns: [],
809
+ inertWarn: 'CP11 inert — confidential-names helpers not resolvable (standalone copy)',
810
+ };
811
+ }
812
+ // Any OTHER import failure is a broken in-tree install, not the vendoring case.
813
+ return { patterns: [], disabledReason: `confidential-names helpers failed to load (${err?.name ?? 'Error'})` };
814
+ }
815
+
674
816
  try {
675
- const { loadHostPaths, resolveHostPath } = await import('../config/host-paths.mjs');
676
- const { loadConfidentialNames } = await import('./confidential-names.mjs');
677
- const ctx = loadHostPaths();
678
- const namesPath = resolveHostPath('confidential-names-file', '', ctx);
817
+ const { loadHostPaths, resolveHostPath } = helpers.hostPaths;
818
+ const { loadConfidentialNames } = helpers.confidentialNames;
819
+ const { loadOwnerConfig, resolveOwnerYamlPath } = helpers.ownerYaml;
820
+
821
+ // Load owner.yaml ONCE and hand the same result to loadHostPaths, so the
822
+ // env>owner.yaml>default precedence is unchanged while the load's own health
823
+ // (reason / droppedSections) stays visible here.
824
+ const owner = loadOwnerConfig();
825
+ const namesPath = resolveHostPath('confidential-names-file', '', loadHostPaths({ ownerLoader: () => owner }));
826
+
827
+ if (typeof namesPath !== 'string' || namesPath.trim() === '') {
828
+ // Nothing resolves a names file. That is either (b) — genuinely
829
+ // unconfigured — or a state in which the answer is UNKNOWABLE because the
830
+ // owner.yaml that would carry it could not be read. Unknowable is (c).
831
+ if (existsSync(resolveOwnerYamlPath())) {
832
+ if (owner.reason === 'yaml-parser-missing') {
833
+ return {
834
+ patterns: [],
835
+ disabledReason:
836
+ "owner.yaml exists but 'js-yaml' is not installed, so a configured confidential-names-file cannot be resolved (run 'npm install')",
837
+ };
838
+ }
839
+ if (owner.reason === 'unparseable') {
840
+ return {
841
+ patterns: [],
842
+ disabledReason:
843
+ 'owner.yaml exists but could not be parsed, so a configured confidential-names-file cannot be resolved',
844
+ };
845
+ }
846
+ if (owner.droppedSections?.some((d) => d.section === 'paths')) {
847
+ // The paths: section was replaced by its default because SOME key in it
848
+ // is invalid — which says nothing yet about whether CP11 was configured.
849
+ // Re-read the RAW file for that one key (the loader does not expose it on
850
+ // this branch; see rawConfidentialNamesKeyState) and only then decide.
851
+ const rawKey = rawConfidentialNamesKeyState(resolveOwnerYamlPath());
852
+ if (rawKey === 'configured') {
853
+ return {
854
+ patterns: [],
855
+ disabledReason:
856
+ 'owner.yaml has an invalid paths: section, so a configured confidential-names-file cannot be resolved',
857
+ };
858
+ }
859
+ if (rawKey === 'unknown') {
860
+ return {
861
+ patterns: [],
862
+ disabledReason:
863
+ 'owner.yaml has an invalid paths: section and could not be re-read, so a configured confidential-names-file cannot be ruled out',
864
+ };
865
+ }
866
+ // 'absent' — CP11 was never configured here. Inactive, not disabled:
867
+ // ONE WARN naming the dropped section, no FAIL, exit unchanged.
868
+ return {
869
+ patterns: [],
870
+ inertWarn:
871
+ 'CP11 inactive — owner.yaml\'s paths: section was dropped as invalid, but it configures no confidential-names-file',
872
+ };
873
+ }
874
+ }
875
+ return { patterns: [] }; // (b) the ~99% default — inactive, silent.
876
+ }
877
+
878
+ // A names file IS configured. loadConfidentialNames() returns null for BOTH
879
+ // "unusable" and "deliberately empty list", so classify the file first — an
880
+ // empty list is an operator choice (inactive, silent), a missing/malformed
881
+ // one is (c).
882
+ const unusable = classifyNamesFile(namesPath);
883
+ if (unusable) return { patterns: [], disabledReason: unusable };
884
+
679
885
  const names = loadConfidentialNames({ namesPath });
680
- if (!names) return [];
681
- return names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i'));
682
- } catch {
683
- // Helper modules absent (standalone single-file vendoring) or a config read
684
- // failure CP11 goes silently inert; the CP1–CP10 rules need none of these
685
- // modules and continue to enforce the scan.
686
- return [];
886
+ if (!names) return { patterns: [] }; // readable, well-formed, zero usable entries.
887
+ return { patterns: names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')) };
888
+ } catch (err) {
889
+ // The helpers resolved but something below threw. CP11 cannot run — fail closed.
890
+ return { patterns: [], disabledReason: `confidential-names resolution failed (${err?.name ?? 'Error'})` };
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Classify a CONFIGURED confidential-names file as usable or not.
896
+ *
897
+ * `loadConfidentialNames()` collapses "file missing / unreadable / malformed /
898
+ * not an array" and "well-formed but empty" into one `null` return, which is
899
+ * exactly the distinction CP11's fail-closed verdict turns on. Rather than widen
900
+ * that module's contract (it has four other consumers of its `null`), this reads
901
+ * the file once more for classification only. It never returns file CONTENT — the
902
+ * reason string carries the error CLASS alone, matching the confidential-names
903
+ * privacy invariant and the no-path rule above.
904
+ *
905
+ * @param {string} namesPath
906
+ * @returns {string|null} a reason string when unusable, null when usable.
907
+ */
908
+ function classifyNamesFile(namesPath) {
909
+ let raw;
910
+ try {
911
+ if (!existsSync(namesPath)) {
912
+ return 'a confidential-names-file is configured but does not exist';
913
+ }
914
+ raw = readFileSync(namesPath, 'utf8');
915
+ } catch (err) {
916
+ return `the configured confidential-names-file is unreadable (${err?.name ?? 'Error'})`;
917
+ }
918
+ try {
919
+ if (!Array.isArray(JSON.parse(raw))) {
920
+ return 'the configured confidential-names-file is not a JSON array';
921
+ }
922
+ } catch (err) {
923
+ return `the configured confidential-names-file contains malformed JSON (${err?.name ?? 'Error'})`;
687
924
  }
925
+ return null;
688
926
  }
689
927
 
690
928
  // ---------------------------------------------------------------------------
@@ -945,7 +1183,24 @@ const scanFiles = textFiles.filter((f) => {
945
1183
  // unresolvable (standalone single-file copy) → the CP11 block below is a no-op.
946
1184
  // Awaited once, before the per-line loop, because the helpers are now dynamically
947
1185
  // imported (standalone-safe) — CP1–CP10 behaviour is unchanged.
948
- const cp11Patterns = await getConfidentialNamePatterns();
1186
+ //
1187
+ // #1244: a CP11 that was EXPECTED but could not load is a DISABLED guard, not a
1188
+ // clean scan — it is announced on stderr and counted as a FAIL so the run exits
1189
+ // non-zero. CP1–CP10 still run to completion either way: a disabled CP11 must not
1190
+ // suppress the findings the other ten rules can still make.
1191
+ const cp11 = await getConfidentialNamePatterns();
1192
+ const cp11Patterns = cp11.patterns;
1193
+ if (cp11.inertWarn) {
1194
+ console.error(`WARN: ${cp11.inertWarn}`);
1195
+ }
1196
+ if (cp11.disabledReason) {
1197
+ console.error(`CP11 DISABLED: ${cp11.disabledReason}`);
1198
+ // Shaped like the per-file FAIL lines (`<where> — <CPn label>: <content>`) so the
1199
+ // report's `— CPn` attribution parser sees CP11 here too; `<scan-wide>` stands in
1200
+ // for the file position because a disabled guard is a property of the RUN, not of
1201
+ // any one file. The reason carries no path (see getConfidentialNamePatterns).
1202
+ fail(`<scan-wide> — CP11 (guard disabled): ${cp11.disabledReason}`);
1203
+ }
949
1204
 
950
1205
  /** @type {Array<{relPath: string, lineNum: number, pattern: string, lineContent: string}>} */
951
1206
  const violations = [];
@@ -1089,7 +1344,13 @@ for (const filePath of scanFiles) {
1089
1344
  // The spec does not say to deduplicate, so keep as-is.
1090
1345
 
1091
1346
  if (violations.length === 0) {
1092
- pass(`no owner-privacy leakage found across ${scanFiles.length} scanned files`);
1347
+ // #1244: only claim the clean verdict the run actually earned. With CP11
1348
+ // disabled, ten of eleven rules ran — say that instead of "no leakage found".
1349
+ if (cp11.disabledReason) {
1350
+ console.log(` (CP1–CP10 found no leakage across ${scanFiles.length} scanned files; CP11 did not run)`);
1351
+ } else {
1352
+ pass(`no owner-privacy leakage found across ${scanFiles.length} scanned files`);
1353
+ }
1093
1354
  } else {
1094
1355
  for (const v of violations) {
1095
1356
  // Choke-point redaction (Fix 1 + Fix 2): scrub every configured confidential
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-skill-links.mjs — every relative markdown link under an instruction surface must resolve.
4
+ *
5
+ * WHY THIS EXISTS (measured 2026-09-06, session main-2026-09-06-deep-1):
6
+ * The #1157 `references/` splits moved 16 phase blocks out of three oversized skill bodies and
7
+ * verified each move with a sha256 of the moved block plus a full-reconstruction hash. That method
8
+ * proves the CONTENT is unchanged — and is blind by construction to the one defect class a move
9
+ * creates: a relative link whose correctness depends on the file's DEPTH in the tree.
10
+ *
11
+ * Three links broke and no gate saw it. The worst was
12
+ * `skills/session-end/references/phase-3-documentation-updates.md` → `./phase-3-6-tail.md`, which is
13
+ * the dispatcher for the six session-end tail phases (memory proposals, expired-learnings sweep,
14
+ * auto-dream, skill judge, auto-dialectic, reconciliation). From inside `references/` that path
15
+ * resolves one directory too deep; the target sits a level up. A coordinator following the prose
16
+ * would have found nothing there and silently skipped the tail.
17
+ *
18
+ * The existing neighbours cannot cover this: `check-skill-script-paths.mjs` scans only
19
+ * `scripts/**.mjs|.sh` and `hooks/**.sh` TARGETS, and `claude-md-drift-check` counts dead script
20
+ * citations, not intra-surface markdown links. Different predicate, different blind spot.
21
+ *
22
+ * WHAT IT CHECKS
23
+ * For every `.md` under the scanned surfaces, every inline link `[text](target)` whose target is
24
+ * relative (not http(s):, not mailto:, not `#anchor`, not an absolute path) must exist on disk,
25
+ * resolved against the LINKING FILE's own directory. A `#fragment` suffix is stripped before the
26
+ * existence check — anchors are out of scope (no heading index here); the path half is not.
27
+ *
28
+ * DELIBERATE NON-CHECKS
29
+ * - Link text, anchors, and http(s) reachability (a network check in a validator is a flake).
30
+ * - Reference-style links and bare `<...>` autolinks: not used by this repo's instruction files
31
+ * (measured: 0 occurrences). If one appears, this checker stays silent rather than guessing —
32
+ * recorded here so the gap is known rather than assumed absent.
33
+ * - Fenced code blocks AND inline-code spans are skipped: a path inside an example command or
34
+ * inside backticks is illustrative, not a link. The inline-code carve-out is not cosmetic —
35
+ * `skills/memory-cleanup/SKILL.md:163` documents the MEMORY.md index FORMAT as
36
+ * `` `- [Title](file.md) — hook` ``, and without it that literal template is the checker's
37
+ * only "finding", i.e. the guard's first act would be to demand a doc be made wrong.
38
+ *
39
+ * Exit 0 = every relative link resolves. Exit 1 = at least one does not; each is printed as
40
+ * `file:line target` so it can be fixed without a search.
41
+ */
42
+
43
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
44
+ import { join, dirname, resolve, relative, sep } from 'node:path';
45
+
46
+ /** Surfaces whose markdown is instruction, i.e. read and acted on. */
47
+ export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', '.claude/rules']);
48
+
49
+ /** Path segments that end the walk: vendored or machine-owned trees, never instruction. */
50
+ export const PRUNE_DIRS = new Set(['node_modules', '.git', 'coverage', 'dist', '.pnpm']);
51
+
52
+ const SKIP_TARGET = /^(https?:|mailto:|#|\/)/i;
53
+ const LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
54
+ const INLINE_CODE_RE = /(`+)[^`]*?\1/g;
55
+
56
+ /**
57
+ * Blank out inline-code spans, preserving column count so a reported line number still lines up.
58
+ * A `[x](y)` inside backticks is quoted TEXT, not a link — see DELIBERATE NON-CHECKS above.
59
+ */
60
+ function stripInlineCode(line) {
61
+ return line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length));
62
+ }
63
+
64
+ /** Does the path exist? A stat error (ENOENT, ELOOP, EACCES) is a non-resolving link, not a crash. */
65
+ function statOk(abs) {
66
+ try { statSync(abs); return true; } catch { return false; }
67
+ }
68
+
69
+ /** Lines inside fenced code blocks — a path in an example is not a link. */
70
+ function fencedLineNumbers(text) {
71
+ const fenced = new Set();
72
+ let inFence = false;
73
+ text.split('\n').forEach((line, i) => {
74
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; fenced.add(i + 1); return; }
75
+ if (inFence) fenced.add(i + 1);
76
+ });
77
+ return fenced;
78
+ }
79
+
80
+ /**
81
+ * Markdown files under the scanned surfaces, enumerated from the FILESYSTEM.
82
+ *
83
+ * Deliberately not `git ls-files`: that lists tracked files only, so a brand-new instruction file
84
+ * — the exact moment a split or a new skill lands — is invisible to the sweep until it is staged,
85
+ * and the check would report clean on the tree that carries the defect. This repo has the incident
86
+ * on record (`.claude/rules/measurement-discipline.md` § "A `git grep` drift sweep cannot see
87
+ * untracked files": a release sweep passed, then failed after the commit, from the same working
88
+ * tree with no edit in between). The cost of the filesystem walk is that a gitignored `.md` under
89
+ * these four directories would also be checked — there are none, and one would be a finding worth
90
+ * seeing anyway.
91
+ *
92
+ * @returns {string[]} repo-relative paths, sorted for stable output
93
+ */
94
+ export function listMarkdown(repoRoot) {
95
+ const out = [];
96
+ for (const dir of SCAN_DIRS) {
97
+ const abs = join(repoRoot, dir);
98
+ let entries;
99
+ try { entries = readdirSync(abs, { recursive: true, withFileTypes: true }); } catch { continue; }
100
+ for (const e of entries) {
101
+ if (!e.isFile() || !e.name.endsWith('.md')) continue;
102
+ // parentPath is absolute; make the record repo-relative with POSIX separators.
103
+ const rel = relative(repoRoot, join(e.parentPath ?? abs, e.name));
104
+ const posix = sep === '/' ? rel : rel.split(sep).join('/');
105
+ // Vendored trees are not an instruction surface: `skills/vault-sync/node_modules/` is real
106
+ // and its bundled READMEs link into their own upstream repo layout, which is not ours to fix.
107
+ if (posix.split('/').some((s) => PRUNE_DIRS.has(s))) continue;
108
+ out.push(posix);
109
+ }
110
+ }
111
+ return out.sort();
112
+ }
113
+
114
+ /**
115
+ * @returns {{ok: boolean, checked: number, files: number, findings: Array<{file: string, line: number, target: string}>}}
116
+ */
117
+ export function checkSkillLinks(repoRoot = process.cwd()) {
118
+ const findings = [];
119
+ let checked = 0;
120
+ const files = listMarkdown(repoRoot);
121
+
122
+ for (const rel of files) {
123
+ const abs = join(repoRoot, rel);
124
+ let text;
125
+ try { text = readFileSync(abs, 'utf8'); } catch { continue; }
126
+ const fenced = fencedLineNumbers(text);
127
+ const baseDir = dirname(abs);
128
+
129
+ text.split('\n').forEach((line, idx) => {
130
+ const lineNo = idx + 1;
131
+ if (fenced.has(lineNo)) return;
132
+ for (const m of stripInlineCode(line).matchAll(LINK_RE)) {
133
+ const raw = m[1];
134
+ if (!raw || SKIP_TARGET.test(raw)) continue;
135
+ const target = raw.split('#')[0];
136
+ if (!target) continue; // pure fragment
137
+ checked += 1;
138
+ const resolved = resolve(baseDir, target);
139
+ // Never let a link escape the repo: an out-of-tree target is a finding, not a pass.
140
+ const inside = !relative(repoRoot, resolved).startsWith('..');
141
+ if (!inside || !statOk(resolved)) findings.push({ file: rel, line: lineNo, target: raw });
142
+ }
143
+ });
144
+ }
145
+
146
+ return { ok: findings.length === 0, checked, files: files.length, findings };
147
+ }
148
+
149
+ function main() {
150
+ const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
151
+ const { ok, checked, files, findings } = checkSkillLinks(repoRoot);
152
+ if (!ok) {
153
+ for (const f of findings) {
154
+ process.stderr.write(` FAIL: ${f.file}:${f.line} → ${f.target} — relative link does not resolve from this file's directory\n`);
155
+ }
156
+ process.stderr.write(`Results: ${findings.length} unresolved relative link(s) in ${files} markdown file(s) under ${SCAN_DIRS.join(', ')}\n`);
157
+ process.exit(1);
158
+ }
159
+ // Two-space indent: validate-plugin.mjs tallies on /^ {2}(PASS|FAIL):/m.
160
+ process.stdout.write(` PASS: ${checked} relative link(s) in ${files} markdown file(s) resolve\n`);
161
+ }
162
+
163
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
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.
3
+ * Check: every `scripts/**.mjs` path cited in `skills/`, `commands/`,
4
+ * `agents/` and `docs/` either EXISTS or is annotated as deliberately absent
5
+ * (#1176). Extended (#1187) to also cite `scripts/**.sh` and `hooks/**.sh` —
6
+ * see "## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" below for why that
7
+ * half is advisory, not blocking. `docs/` joined `SCAN_DIRS` in #1208, after
8
+ * the 22 dead paths it carried at the time (9 `.mjs`, all ADR/reference
9
+ * prose) were annotated — see that section below for the census and why
10
+ * widening the scan root had to wait for the annotation pass, not precede it.
8
11
  *
9
12
  * ## Why
10
13
  *
@@ -12,8 +15,8 @@
12
15
  * `node scripts/lib/auto-commit.mjs` costs an operator a failed command and a
13
16
  * re-derivation of what the file was supposed to do — and nothing in the
14
17
  * 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.
18
+ * 2026-09-02 @ c3ab480: 237 distinct citations across the (then three) scan
19
+ * roots, 7 of them dead.
17
20
  *
18
21
  * ## Fences are skipped, and that is most of the answer
19
22
  *
@@ -58,27 +61,37 @@
58
61
  * The `.sh` half of the citation grammar (below) does not get that same
59
62
  * severity by default. A #1176 repo-wide grep (`scripts/hooks` prose across
60
63
  * `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.
64
+ * dead — but at the time only ONE of those 27 sat inside this checker's
65
+ * (then three) scan roots (`skills/contract-version-bump/SKILL.md:134`,
66
+ * itself arguably a cross-repo path — see the dry-run note at
67
+ * `scanSkillScriptPaths`'s `strictSh` option). The other 26 lived in `docs/`,
68
+ * which this checker did not yet scan.
69
+ *
70
+ * #1208 closed that gap in two steps, annotation before widening rather than
71
+ * the reverse: first, a `dirs: ['docs']` re-scan (530 citations, 66 files)
72
+ * found 50 findings 22 unique dead paths (9 `.mjs`, 15 `.sh`) across
73
+ * 24 (file, path) pairs, concentrated in `docs/adr/*.md` (ADR prose citing
74
+ * not-yet-built modules like `scripts/lib/tool-adapter.mjs`) and
75
+ * `docs/changelog/v2.md` (23 `.sh` citations to the pre-`.mjs`-migration
76
+ * shell scripts, #218/#317 historical by construction). Every one of the
77
+ * 22 was annotated (`planned #<iid>` for the ADR gaps, `historical` for the
78
+ * changelog, `example` for the one illustrative path in
79
+ * `docs/scope-collision-guard.md`) — zero of them were real defects. Only
80
+ * then did `SCAN_DIRS` gain `'docs'`, so the widening added zero new
81
+ * BLOCKING findings on arrival (re-verify: `scanSkillScriptPaths({
82
+ * pluginRoot, dirs: ['docs'] })` → `ok: true`, `findings: 0`). The wider
83
+ * `hooks/` `.sh` prose census (26 of the 27 `.sh` citations above are outside
84
+ * `SCAN_DIRS` even now, since `hooks/` prose itself is not a scanned root)
85
+ * remains a follow-up for whoever owns those files.
75
86
  *
76
87
  * A `.sh` finding is therefore `WARN:` by default (visible, never blocking —
77
88
  * `ok` and the CLI exit code ignore `severity: 'warn'` findings) and only
78
89
  * becomes `FAIL:`/blocking under the `--strict-sh` CLI flag (or
79
90
  * `strictSh: true` for `scanSkillScriptPaths()` callers) — flip that default
80
91
  * once the dead `.sh` citations this checker CAN see are fixed by their doc
81
- * owner (BV-004 revisit trigger).
92
+ * owner (BV-004 revisit trigger). `--strict-sh` gained a validate-plugin run
93
+ * surface in #1208 (advisory, non-blocking — see `scripts/validate-plugin.mjs`
94
+ * near its `check-skill-script-paths.mjs` call).
82
95
  *
83
96
  * @module scripts/lib/validate/check-skill-script-paths
84
97
  */
@@ -86,11 +99,11 @@
86
99
  import { existsSync, readFileSync } from 'node:fs';
87
100
  import path from 'node:path';
88
101
  import { pathToFileURL } from 'node:url';
89
- import { listRepoFiles } from './repo-files.mjs';
102
+ import { enumerateRepoFiles } from './enumerate-repo-files.mjs';
90
103
  import { forEachLine } from './markdown-fences.mjs';
91
104
 
92
105
  /** Documentation roots whose prose is treated as a claim about the repo. */
93
- export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents']);
106
+ export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', 'docs']);
94
107
 
95
108
  /**
96
109
  * A cited script path. One regex, one alternation, reused for every
@@ -246,10 +259,16 @@ export function scanSkillScriptPaths({ pluginRoot, dirs = SCAN_DIRS, strictSh =
246
259
  /** @type {string[]} */
247
260
  let files;
248
261
  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'] });
262
+ // The population is "exists in this repo, tracked or not" (#1248) NOT
263
+ // "is versioned". A doc that cites a dead script is a defect the moment it
264
+ // is written; the bare git index cannot see it until it is staged, so the
265
+ // check reported clean on the exact tree carrying the bug (measured: an
266
+ // untracked `skills/zz-probe/SKILL.md` → `1 passed, 0 failed` before
267
+ // `git add -A`, `0 passed, 1 failed` after). `enumerateRepoFiles` still
268
+ // honours `.gitignore`, so the #1143 exposure a bare `readdirSync` walk
269
+ // would reintroduce (a worktree under `.claude/worktrees/`, gitignored
270
+ // `docs/specs/*.md`) stays closed — see that module's header.
271
+ files = enumerateRepoFiles({ repoRoot: pluginRoot, dirs, exts: ['.md'] });
253
272
  } catch (error) {
254
273
  findings.push({
255
274
  kind: 'tool-error',
@@ -302,8 +302,6 @@ const ALLOWLIST = Object.freeze({
302
302
  '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',
303
303
  'instruction-budget':
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
- webhooks:
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
305
  });
308
306
 
309
307
  /**