session-orchestrator 5.2.0 → 5.3.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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -0,0 +1,118 @@
1
+ /**
2
+ * vault-yaml.mjs — Reader for a repo's `.vault.yaml` registration slug (#1131).
3
+ *
4
+ * A repo registered in the Meta-Vault carries `.vault.yaml` at its root, whose
5
+ * `metadata.slug` is the CANONICAL vault slug for that project — the name of
6
+ * its `01-projects/<slug>/` folder. Until #1131 nothing READ that key: every
7
+ * vault writer derived the slug from the checkout DIRECTORY name instead
8
+ * (`scripts/lib/vault-status/narrative-mirror.mjs` via `subjectToSlug`,
9
+ * `scripts/lib/vault-mirror/namespace.mjs` via `basename(process.cwd())`), so a
10
+ * repo whose directory name differs lexically from its registered slug had its
11
+ * narrative and its learnings/sessions filed under a second, wrong folder — and
12
+ * the loose-slug healer in narrative-mirror (#829) only repairs case and
13
+ * punctuation drift, never a genuinely different name.
14
+ *
15
+ * Exports:
16
+ * VAULT_YAML_FILE — the basename this module reads
17
+ * readVaultSlug — repoRoot → validated `metadata.slug` string, or null
18
+ *
19
+ * Contract: NEVER throws. Absent file, unreadable file, malformed YAML, missing
20
+ * key, non-string value, or a value that fails the repo's slug predicate all
21
+ * return `null`, leaving every caller on its pre-#1131 derivation chain.
22
+ *
23
+ * Import closure is deliberately tiny (`node:fs`, `node:path`, `node:module`,
24
+ * `./vault-mirror/utils.mjs`) — `namespace.mjs` is documented as a leaf-ward
25
+ * module and must stay one.
26
+ */
27
+
28
+ import { readFileSync } from 'node:fs';
29
+ import path from 'node:path';
30
+ import { createRequire } from 'node:module';
31
+
32
+ import { isValidSlug } from './vault-mirror/utils.mjs';
33
+
34
+ /** Basename of the per-repo vault registration file. */
35
+ export const VAULT_YAML_FILE = '.vault.yaml';
36
+
37
+ // ── Lazy js-yaml resolution ──────────────────────────────────────────────────
38
+ //
39
+ // Same shape (and same reason) as `getYaml()` in scripts/lib/owner-yaml.mjs:
40
+ // a static `import yaml from 'js-yaml'` crashes any consumer running without
41
+ // `node_modules`, at MODULE LOAD, where the failure is not catchable. Both
42
+ // consumers of this reader are synchronous, so `createRequire` — not
43
+ // `await import()` — is the resolution mechanism.
44
+
45
+ /** @type {null | false | { load: Function }} */
46
+ let _yaml = null;
47
+ let _yamlWarned = false;
48
+
49
+ function getYaml() {
50
+ if (_yaml !== null) return _yaml === false ? null : _yaml;
51
+ try {
52
+ _yaml = createRequire(import.meta.url)('js-yaml');
53
+ } catch {
54
+ _yaml = false;
55
+ }
56
+ return _yaml === false ? null : _yaml;
57
+ }
58
+
59
+ /**
60
+ * One-per-process actionable WARN, emitted ONLY when a `.vault.yaml` actually
61
+ * exists and could therefore have changed the resolved slug. A repo without the
62
+ * file loses nothing by the parser being absent, so it stays silent.
63
+ */
64
+ function warnYamlMissing() {
65
+ if (_yamlWarned) return;
66
+ _yamlWarned = true;
67
+ process.stderr.write(
68
+ "WARN vault-yaml: 'js-yaml' is not installed — .vault.yaml cannot be parsed; " +
69
+ 'the vault slug falls back to the directory-derived name (run \'npm install\' in the plugin directory)\n',
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Read the canonical vault slug declared at `metadata.slug` in
75
+ * `<repoRoot>/.vault.yaml`.
76
+ *
77
+ * The value becomes a FILESYSTEM PATH SEGMENT under the operator's vault, so it
78
+ * is validated with the repo's existing slug predicate ({@link isValidSlug},
79
+ * `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`) rather than trusted. That rejects `/`, `..`,
80
+ * leading dots, whitespace and uppercase — this validation is a trust-boundary
81
+ * control, not hygiene: `.vault.yaml` is repo-supplied data, and a slug such as
82
+ * `../../etc` would otherwise escape the vault root.
83
+ *
84
+ * @param {string} repoRoot - absolute or relative path to the repository root.
85
+ * @returns {string|null} the validated slug, or `null` when it cannot be read.
86
+ */
87
+ export function readVaultSlug(repoRoot) {
88
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return null;
89
+
90
+ let text;
91
+ try {
92
+ text = readFileSync(path.join(repoRoot, VAULT_YAML_FILE), 'utf8');
93
+ } catch {
94
+ // Absent, unreadable, a directory, … — all "no declared slug".
95
+ return null;
96
+ }
97
+
98
+ const yaml = getYaml();
99
+ if (!yaml) {
100
+ warnYamlMissing();
101
+ return null;
102
+ }
103
+
104
+ let doc;
105
+ try {
106
+ doc = yaml.load(text);
107
+ } catch {
108
+ // Malformed YAML must never break a vault write.
109
+ return null;
110
+ }
111
+
112
+ if (!doc || typeof doc !== 'object') return null;
113
+ const metadata = doc.metadata;
114
+ if (!metadata || typeof metadata !== 'object') return null;
115
+
116
+ const slug = metadata.slug;
117
+ return isValidSlug(slug) ? slug : null;
118
+ }
@@ -46,6 +46,15 @@ async function _exists(p) {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * The one directory session-orchestrator worktrees may live in.
51
+ * Host-shared: several sessions run concurrently against the same base.
52
+ * @returns {string}
53
+ */
54
+ function _worktreeBaseDir() {
55
+ return path.join(os.tmpdir(), 'so-worktrees');
56
+ }
57
+
49
58
  /**
50
59
  * Compute the standard branch name and tmp path for a given suffix.
51
60
  * @param {string} suffix
@@ -53,10 +62,146 @@ async function _exists(p) {
53
62
  */
54
63
  function _worktreeInfo(suffix) {
55
64
  const branch = `so-worktree-${suffix}`;
56
- const wtPath = path.join(os.tmpdir(), 'so-worktrees', branch);
65
+ const wtPath = path.join(_worktreeBaseDir(), branch);
57
66
  return { branch, wtPath };
58
67
  }
59
68
 
69
+ /**
70
+ * Resolve symlinks, returning null when the path does not exist.
71
+ * @param {string} p
72
+ * @returns {Promise<string|null>}
73
+ */
74
+ async function _realpathOrNull(p) {
75
+ try {
76
+ return await fs.realpath(p);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * True when `wtPath` is NOT a live working tree of any repository — i.e. either
84
+ * it carries no `.git` entry at all, or its `.git` file points at an
85
+ * administrative directory that no longer exists (the registration was dropped).
86
+ *
87
+ * A directory whose registration is intact is never an orphan: `git worktree
88
+ * remove --force` handles that case, so this predicate is what keeps a live
89
+ * worktree — including one owned by a PEER session, or by another repository —
90
+ * out of the delete path below.
91
+ *
92
+ * @param {string} wtPath
93
+ * @returns {Promise<boolean>}
94
+ */
95
+ async function _isUnregisteredWorktreeDir(wtPath) {
96
+ const dotGit = path.join(wtPath, '.git');
97
+ let st;
98
+ try {
99
+ st = await fs.lstat(dotGit);
100
+ } catch {
101
+ return true; // No .git entry — not a working tree of anything.
102
+ }
103
+ // A real .git DIRECTORY means a full repository lives here; never ours to delete.
104
+ if (!st.isFile()) return false;
105
+
106
+ const content = await fs.readFile(dotGit, 'utf8').catch(() => '');
107
+ const match = /^gitdir:\s*(.+)$/m.exec(content);
108
+ if (!match) return false; // Unparseable — treat as live, refuse.
109
+
110
+ const adminDir = path.resolve(wtPath, match[1].trim());
111
+ // Admin dir still present → git knows this worktree → not an orphan.
112
+ return !(await _exists(adminDir));
113
+ }
114
+
115
+ /**
116
+ * Remove an ORPHAN worktree directory (issue #984).
117
+ *
118
+ * An interrupted `removeWorktree` drops the git registration first and can die
119
+ * before the directory is gone. `git worktree add` then fails with
120
+ * "'…' already exists" while `git worktree remove --force` fails with
121
+ * "'…' is not a working tree" (swallowed by nothrow) — the retry loops on the
122
+ * same error until someone deletes the directory by hand.
123
+ *
124
+ * SAFETY — this is a recursive delete inside library code, so every condition
125
+ * below is asserted in code before `fs.rm` runs. Any failed condition returns
126
+ * false and leaves the directory alone (the retry then fails with git's own
127
+ * error, which is the pre-#984 behaviour):
128
+ * 1. `wtPath` itself is a real directory and NOT a symlink — the LEAF is never
129
+ * followed. (A symlink one level up is covered by condition 2.)
130
+ * 2. The literal base `<os.tmpdir()>/so-worktrees` is itself a real directory
131
+ * and NOT a symlink. The base lives on a host-shared /tmp where a co-tenant
132
+ * can pre-create it; without this check the realpath of a planted base
133
+ * symlink BECOMES the delete root, so a `so-worktree-*` directory anywhere
134
+ * on the filesystem would qualify.
135
+ * 3. The REALPATH of `wtPath` is a DIRECT child of the REALPATH of that base
136
+ * — both sides resolved, because macOS resolves /tmp → /private/tmp and
137
+ * os.tmpdir() itself may be a symlinked path.
138
+ * 4. The resolved basename starts with `so-worktree-`.
139
+ * 5. It is not a live working tree of ANY repository (see above).
140
+ * Note what is NOT asserted: an intermediate component of `wtPath` other than
141
+ * the base cannot exist — the path is `<base>/so-worktree-<suffix>`, computed by
142
+ * `_worktreeInfo`, never caller-supplied.
143
+ * `git worktree prune` is deliberately NOT used: it is repo-global and would
144
+ * drop a peer session's stale registration.
145
+ *
146
+ * CEILING (BV-004): the delete is bounded to one directory directly under the
147
+ * fixed base `<os.tmpdir()>/so-worktrees` whose name carries the `so-worktree-`
148
+ * prefix. Revisit when worktree paths become caller-supplied (the
149
+ * `validateWorkspacePath` follow-up noted above) or move out of os.tmpdir() —
150
+ * at that point the containment base is no longer a constant and must be passed
151
+ * in and validated by the caller.
152
+ *
153
+ * @param {string} wtPath
154
+ * @returns {Promise<boolean>} true when a directory was actually removed.
155
+ */
156
+ async function _removeOrphanWorktreeDir(wtPath) {
157
+ let lst;
158
+ try {
159
+ lst = await fs.lstat(wtPath);
160
+ } catch {
161
+ return false; // Nothing there.
162
+ }
163
+ if (lst.isSymbolicLink() || !lst.isDirectory()) return false;
164
+
165
+ // The containment BASE must be a real directory too: on a host-shared /tmp a
166
+ // co-tenant can pre-create `<os.tmpdir()>/so-worktrees` as a symlink, and a
167
+ // realpath-resolved base would then relocate the delete root off-tmpdir.
168
+ const baseDir = _worktreeBaseDir();
169
+ let baseLst;
170
+ try {
171
+ baseLst = await fs.lstat(baseDir);
172
+ } catch {
173
+ return false; // No base directory — nothing under it to remove.
174
+ }
175
+ if (baseLst.isSymbolicLink() || !baseLst.isDirectory()) return false;
176
+
177
+ const realWt = await _realpathOrNull(wtPath);
178
+ const realBase = await _realpathOrNull(baseDir);
179
+ if (!realWt || !realBase) return false;
180
+ if (!isPathInside(realWt, realBase)) return false;
181
+ // Direct child only — matches the CEILING below; no nested path qualifies.
182
+ if (path.dirname(realWt) !== realBase) return false;
183
+ if (!path.basename(realWt).startsWith('so-worktree-')) return false;
184
+
185
+ if (!(await _isUnregisteredWorktreeDir(realWt))) return false;
186
+
187
+ // RACE WINDOW (accepted, #1390 P7): `_isUnregisteredWorktreeDir` above and
188
+ // `fs.rm` below are two separate syscalls, not one atomic check-then-act.
189
+ // Both plausible racers here are session-orchestrator sessions on the SAME
190
+ // host, contending over the same `<os.tmpdir()>/so-worktrees` base: one
191
+ // could register (`git worktree add`) or delete `realWt` in the gap between
192
+ // the two calls. No lock primitive guards this sub-millisecond window
193
+ // between our own sessions — the worst case is a benign retry (the caller's
194
+ // `git worktree add` above already retries once on failure), never data
195
+ // loss, because neither racer ever touches a path outside its own
196
+ // `so-worktree-*` name.
197
+ // REVISIT TRIGGER (BV-004): an UNRELATED process gains the ability to
198
+ // create or delete `so-worktree-*` directories under this base — at that
199
+ // point the race is no longer bounded to our own retry semantics and this
200
+ // window needs an actual lock, not an accepted gap.
201
+ await fs.rm(realWt, { recursive: true, force: true });
202
+ return true;
203
+ }
204
+
60
205
  // ---------------------------------------------------------------------------
61
206
  // validateWorkspacePath
62
207
  // ---------------------------------------------------------------------------
@@ -136,6 +281,13 @@ export async function createWorktree(suffix, baseRef = 'HEAD', options = {}) {
136
281
  await nothrow(git`git worktree remove ${wtPath} --force`);
137
282
  await nothrow(git`git branch -D ${branch}`);
138
283
 
284
+ // Still on disk after the remove attempt → git has no registration for it
285
+ // (issue #984). Delete the orphan directory under the guards documented on
286
+ // _removeOrphanWorktreeDir; a live/registered worktree never reaches here.
287
+ if (await _exists(wtPath)) {
288
+ await _removeOrphanWorktreeDir(wtPath);
289
+ }
290
+
139
291
  try {
140
292
  await git`git worktree add -b ${branch} ${wtPath} ${baseRef}`;
141
293
  } catch (secondErr) {
@@ -0,0 +1,305 @@
1
+ /**
2
+ * release-session-lock.mjs — CLI: release THIS session's `session.lock`, emit the
3
+ * terminal `orchestrator.session.lock.released` breadcrumb, and PROVE the lock is
4
+ * gone before exiting 0.
5
+ *
6
+ * ## Why a CLI and not one more `release()` call site (#1395)
7
+ *
8
+ * Measured 2026-09-19: three of the four `release()` call sites already emit
9
+ * `orchestrator.session.lock.released` — `hooks/on-session-end.mjs`,
10
+ * `scripts/lib/autopilot/worktree-pipeline.mjs` and
11
+ * `scripts/lib/session-transition.mjs`. The silent one was never code at all: it
12
+ * is the PROSE step `skills/session-end/SKILL.md` § Phase 3.8, executed by hand
13
+ * by the coordinator LLM. It deletes the lock, so the SessionEnd hook later reads
14
+ * `status: 'absent'` and emits nothing — this repo recorded 5 `lock.acquired`
15
+ * against 0 `lock.released`. A prose "call X, then check Y" step is the shape
16
+ * that gets skipped (`.claude/rules/process-contracts.md`: 339 of 351 issues
17
+ * closed without the prose-mandated pre-step); the repair is ONE command that
18
+ * verifies its own effect and exits non-zero when it cannot.
19
+ *
20
+ * ## Why the emit is not inside `release()`
21
+ *
22
+ * Three structural reasons, none of them fixable here:
23
+ * 1. Import cycle — `scripts/lib/events.mjs` imports `session-lock.mjs`
24
+ * (`sessionAttribution()` reads the lock), so `session-lock.mjs` cannot
25
+ * import `events.mjs`.
26
+ * 2. `release()` is synchronous; `emitEvent()` is async.
27
+ * 3. After the unlink the event envelope can no longer read the lock for
28
+ * correlation — which is exactly why this CLI captures `session_id` and
29
+ * `semantic_session_id` from the lock BEFORE releasing and passes both
30
+ * EXPLICITLY in the payload.
31
+ *
32
+ * ## Reuse note (BV-001.2)
33
+ *
34
+ * `leaveSourceRoot()` in `scripts/lib/session-transition.mjs` is the closest
35
+ * existing helper and is deliberately NOT reused: it also deregisters the
36
+ * host-wide registry entry, which a session closing in place must NOT do at
37
+ * Phase 3.8 (the SessionEnd hook owns deregistration, at the real end). Its
38
+ * `LOCK_RELEASED_EVENT` constant IS reused, so the event name has one writer.
39
+ *
40
+ * Exit codes: 0 verified · 1 user/input or ownership error · 2 system error.
41
+ *
42
+ * Exports (for tests): main.
43
+ */
44
+
45
+ import { parseArgs } from 'node:util';
46
+
47
+ import { readLockDetailed, loadOwnerProof, release } from './lib/session-lock.mjs';
48
+ import { LOCK_RELEASED_EVENT } from './lib/session-transition.mjs';
49
+ import { emitEvent } from './lib/events.mjs';
50
+ import { isMainModule } from './lib/is-main-module.mjs';
51
+
52
+ /**
53
+ * `caller` value stamped into the breadcrumb, so this fourth release site stays
54
+ * distinguishable from `on-session-end`, `session-transition` and the autopilot
55
+ * worktree pipeline in the single `orchestrator.session.lock.*` stream.
56
+ */
57
+ export const PHASE_CALLER = 'session-end-phase-3-8';
58
+
59
+ const HELP = `release-session-lock.mjs — release this session's lock and prove it is gone
60
+
61
+ USAGE
62
+ node scripts/release-session-lock.mjs --session-id <raw-id> [--repo-root <path>] [--json]
63
+
64
+ WHAT IT DOES
65
+ 1. Reads <repo-root>/.orchestrator/session.lock.
66
+ 2. Releases it IFF its raw session_id equals --session-id (owner-proof gated,
67
+ exactly as hooks/on-session-end.mjs gates it).
68
+ 3. Emits orchestrator.session.lock.released with the ids read BEFORE the
69
+ delete, plus caller="${PHASE_CALLER}".
70
+ 4. RE-READS the lock path and exits 0 only when our lock is provably gone.
71
+
72
+ FLAGS
73
+ --session-id <id> REQUIRED. The RAW (physical) session id that owns the lock
74
+ — .orchestrator/session.lock "session_id", never STATE.md
75
+ "session" and never the semantic id. A lock owned by a
76
+ different id is NEVER released.
77
+ --repo-root <path> Repo whose lock is released (default: cwd).
78
+ --json Machine-readable result on stdout.
79
+ -h, --help Show this help and exit 0.
80
+
81
+ OUTCOMES
82
+ absent no lock file — nothing released, NO event, exit 0.
83
+ deleted our lock was removed and re-read as gone.
84
+ already-gone ownership matched but the lock had already vanished; the event
85
+ is still written, because a lock removed by a third party while
86
+ we held it is the forensically interesting case.
87
+
88
+ EXIT CODES
89
+ 0 — the lock is provably absent (released now, or nothing to release)
90
+ 1 — user/input or ownership error: missing/empty --session-id, unknown flag,
91
+ or a lock owned by a different session (nothing is touched)
92
+ 2 — system error: unreadable/corrupt lock, filesystem error, owner-proof
93
+ mismatch, the lock still present after release, or the breadcrumb could
94
+ not be written
95
+ `;
96
+
97
+ /**
98
+ * Print the one-line human summary (stdout = data, stderr = diagnostics).
99
+ *
100
+ * @param {{outcome: string, verified: boolean, session_id: string, event_emitted: boolean}} result
101
+ */
102
+ function printHuman(result) {
103
+ if (result.outcome === 'absent') {
104
+ process.stdout.write('session-lock: nothing to release — no lock file (no event written).\n');
105
+ return;
106
+ }
107
+ const verified = result.verified ? 'verified gone' : 'NOT verified — lock still present';
108
+ const event = result.event_emitted ? 'breadcrumb written' : 'breadcrumb FAILED';
109
+ process.stdout.write(
110
+ `session-lock ${result.outcome} for session ${result.session_id} — ${verified}, ${event}.\n`,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Did OUR lock really vanish?
116
+ *
117
+ * Computed from a FRESH read after `release()` returned, deliberately not from
118
+ * `release()`'s own `verified` field: that field is absent on the benign
119
+ * `no-lock` branch (so a caller copying it reports `verified: false` for a lock
120
+ * that IS gone), and it was computed before this process did anything else. The
121
+ * CLI's whole contract is "exit 0 proves absence", so it re-reads.
122
+ *
123
+ * A FOREIGN lock present at the re-read counts as verified — ours is gone and a
124
+ * sibling legitimately acquired the path in the race window (same rule as
125
+ * `release()`'s post-delete verify; PSA-005: never touch a lock we do not own).
126
+ *
127
+ * @param {string} repoRoot
128
+ * @param {string} sessionId
129
+ * @returns {boolean}
130
+ */
131
+ function ourLockIsGone(repoRoot, sessionId) {
132
+ const after = readLockDetailed({ repoRoot });
133
+ if (after.status === 'absent') return true;
134
+ if (after.status === 'ok') return after.lock.session_id !== sessionId;
135
+ // unreadable / corrupt — absence cannot be confirmed, so it is not claimed.
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * CLI entry point.
141
+ *
142
+ * @param {{argv?: string[]}} [opts]
143
+ * @returns {Promise<number>} process exit code
144
+ */
145
+ export async function main({ argv = process.argv.slice(2) } = {}) {
146
+ let values;
147
+ try {
148
+ ({ values } = parseArgs({
149
+ args: argv,
150
+ options: {
151
+ 'repo-root': { type: 'string' },
152
+ 'session-id': { type: 'string' },
153
+ json: { type: 'boolean', default: false },
154
+ help: { type: 'boolean', short: 'h', default: false },
155
+ },
156
+ strict: true,
157
+ allowPositionals: false,
158
+ }));
159
+ } catch (err) {
160
+ process.stderr.write(`release-session-lock: ${err?.message ?? 'bad arguments'}\n`);
161
+ process.stderr.write('Run with --help for usage.\n');
162
+ return 1;
163
+ }
164
+
165
+ if (values.help) {
166
+ process.stdout.write(HELP);
167
+ return 0;
168
+ }
169
+
170
+ const sessionId = values['session-id'];
171
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
172
+ process.stderr.write('release-session-lock: --session-id <raw-id> is required.\n');
173
+ process.stderr.write('Run with --help for usage.\n');
174
+ return 1;
175
+ }
176
+
177
+ const repoRoot = values['repo-root'] ?? process.cwd();
178
+
179
+ const emitResult = async (result) => {
180
+ if (values.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
181
+ else printHuman(result);
182
+ };
183
+
184
+ // -- Step 1: read the lock, and capture the ids BEFORE anything is deleted --
185
+ const lockDetail = readLockDetailed({ repoRoot });
186
+
187
+ if (lockDetail.status === 'absent') {
188
+ // Nothing was released, so nothing terminal happened: NO event. Writing one
189
+ // here would put a fictional lifecycle end into the stream — the idempotent
190
+ // re-run case is not a release.
191
+ const result = { ok: true, outcome: 'absent', verified: true, session_id: sessionId, event_emitted: false };
192
+ await emitResult(result);
193
+ return 0;
194
+ }
195
+
196
+ if (lockDetail.status !== 'ok') {
197
+ process.stderr.write(`release-session-lock: lock is ${lockDetail.status} — ownership cannot be established; nothing touched.\n`);
198
+ await emitResult({
199
+ ok: false, outcome: `lock-${lockDetail.status}`, verified: false, session_id: sessionId, event_emitted: false,
200
+ });
201
+ return 2;
202
+ }
203
+
204
+ const lock = lockDetail.lock;
205
+ if (lock.session_id !== sessionId) {
206
+ // A raw-id mismatch never grants release ownership (same gate as
207
+ // hooks/on-session-end.mjs). Reconciliation of a foreign lease is the
208
+ // reaper's job, not this command's.
209
+ process.stderr.write(`release-session-lock: lock is owned by a different session — not released.\n`);
210
+ await emitResult({
211
+ ok: false, outcome: 'session-mismatch', verified: false, session_id: sessionId, event_emitted: false,
212
+ });
213
+ return 1;
214
+ }
215
+
216
+ const semanticSessionId = typeof lock.semantic_session_id === 'string' && lock.semantic_session_id.length > 0
217
+ ? lock.semantic_session_id
218
+ : null;
219
+
220
+ // -- Step 2: release, owner-proof gated (#987/#989) ------------------------
221
+ // `proof` is null whenever ownership cannot be proven; release() treats null
222
+ // as absent and degrades to the session_id-only path, so it is passed
223
+ // through unguarded.
224
+ const proof = loadOwnerProof({ repoRoot });
225
+ const releaseResult = release({ sessionId, repoRoot, proof });
226
+
227
+ if (releaseResult.ok !== true) {
228
+ process.stderr.write(`release-session-lock: release failed — ${releaseResult.reason ?? 'fs-error'}\n`);
229
+ await emitResult({
230
+ ok: false, outcome: `release-${releaseResult.reason ?? 'fs-error'}`, verified: false, session_id: sessionId, event_emitted: false,
231
+ });
232
+ return 2;
233
+ }
234
+
235
+ const alreadyGone = releaseResult.deleted !== true && releaseResult.reason === 'no-lock';
236
+ if (releaseResult.deleted !== true && !alreadyGone) {
237
+ // 'proof-mismatch' (and any future non-delete reason): ownership matched on
238
+ // the raw id but the second factor contradicted it. Contradictory on-disk
239
+ // state, not a bad argument — exit 2.
240
+ process.stderr.write(`release-session-lock: lock not released — ${releaseResult.reason ?? 'not-deleted'}\n`);
241
+ await emitResult({
242
+ ok: false, outcome: `release-${releaseResult.reason ?? 'not-deleted'}`, verified: false, session_id: sessionId, event_emitted: false,
243
+ });
244
+ return 2;
245
+ }
246
+
247
+ // -- Step 3: prove it ------------------------------------------------------
248
+ const verified = ourLockIsGone(repoRoot, sessionId);
249
+ const outcome = alreadyGone ? 'already-gone' : 'deleted';
250
+
251
+ // -- Step 4: the terminal breadcrumb --------------------------------------
252
+ // The ids are passed EXPLICITLY: the lock is gone by now, so emitEvent's
253
+ // lock-derived correlation would find nothing to attribute this record to.
254
+ let eventEmitted = true;
255
+ let emitError = null;
256
+ try {
257
+ await emitEvent(LOCK_RELEASED_EVENT, {
258
+ session_id: sessionId,
259
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
260
+ caller: PHASE_CALLER,
261
+ outcome,
262
+ verified,
263
+ }, { repoRoot });
264
+ } catch (err) {
265
+ eventEmitted = false;
266
+ emitError = err?.message ?? String(err);
267
+ }
268
+
269
+ const result = {
270
+ ok: verified && eventEmitted,
271
+ outcome,
272
+ verified,
273
+ session_id: sessionId,
274
+ ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
275
+ event_emitted: eventEmitted,
276
+ ...(emitError !== null ? { event_error: emitError } : {}),
277
+ };
278
+ await emitResult(result);
279
+
280
+ if (!verified) {
281
+ process.stderr.write('release-session-lock: the lock is STILL present after release — not verified.\n');
282
+ return 2;
283
+ }
284
+ if (!eventEmitted) {
285
+ // The release itself succeeded; what failed is the one thing this command
286
+ // exists to guarantee, so it is reported as a system error rather than
287
+ // swallowed. Phase 3.8 treats a non-zero exit as a WARN and continues.
288
+ process.stderr.write(`release-session-lock: breadcrumb could not be written — ${emitError}\n`);
289
+ return 2;
290
+ }
291
+ return 0;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // CLI guard — prevents execution during test-time imports.
296
+ // ---------------------------------------------------------------------------
297
+
298
+ if (isMainModule(import.meta.url)) {
299
+ main()
300
+ .then((code) => process.exit(code))
301
+ .catch((err) => {
302
+ process.stderr.write(`release-session-lock: unexpected error: ${err?.stack ?? err}\n`);
303
+ process.exit(2);
304
+ });
305
+ }
@@ -384,7 +384,10 @@ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
384
384
  // September 10 campaign snapshot: inputs and receipt reproduce the dated 4.3-planned
385
385
  // render made while 4.2.0 was current. Exact files only; neighboring marketing
386
386
  // remains checked. Do not bump receipt props without a newly reviewed render.
387
- export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
387
+ // `scripts/lib/locks/index.mjs` is the one-cycle deprecation shim (5.2.0 CHANGELOG, Changed): its
388
+ // runtime warning names the version it was deprecated IN, so it legitimately carries the previous
389
+ // release literal until its 6.0.0 removal — version history by construction, not drift.
390
+ export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|scripts\/lib\/locks\/index\.mjs|CLAUDE\.md|AGENTS\.md|CONTRIBUTING\.md|NOTICE|\.husky\/pre-push|docs\/|tests\/|skills\/npm-publish\/|skills\/architecture\/references\/domain-model\.md|skills\/autopilot\/SKILL\.md|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|site\/llms-full\.txt|skills\/release\/SKILL\.md|marketing\/remotion\/(?:README\.md|campaign\.json|render-receipt\.json|src\/ReleaseFilm\.tsx)$)/;
388
391
 
389
392
  /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
390
393
  export function checkLeakage(lines) {
@@ -881,14 +884,36 @@ export function evaluateNpmAuth(whoami) {
881
884
  * never found out". Only an actual `status: 'green'` reading passes; a release
882
885
  * must never proceed on an unknown CI state.
883
886
  *
884
- * @param {null | {status?: string, failingJobName?: string, degraded?: string}} ci
887
+ * The row also carries the CAUSE the probe already knows, because the three
888
+ * ways this row goes red are indistinguishable without it (#1384 P5): a short
889
+ * `sha`, a commit the mirror has never seen, and a HEAD with no pipeline all
890
+ * printed `CI status unknown (query-failed)` / `status: unknown`. The probe's
891
+ * `detail` (degraded branch, `degradedResult`) and `details.reason` /
892
+ * `failingJobName` (read branch, `sanitizeApiText`) are clamped and
893
+ * control-byte-escaped AT THEIR GENERATION in `ci-status-banner.mjs` —
894
+ * appended here, never re-formatted. That escaping is load-bearing, not
895
+ * hygiene: the read branch interpolates a RAW API `status` value into
896
+ * `reason`, and until #1384 f-4 a status carrying `\r` + an ANSI sequence
897
+ * could repaint THIS row green while its verdict stayed `ok:false`. Do not
898
+ * append a further probe field here without checking it goes through one of
899
+ * those two. With neither present the text is byte-identical to
900
+ * before, so a plain green or a named failing job reads exactly as it did.
901
+ *
902
+ * @param {null | {status?: string, failingJobName?: string, degraded?: string,
903
+ * detail?: string, details?: {reason?: string}}} ci
885
904
  * @returns {{ok: boolean, detail: string}}
886
905
  */
887
906
  export function evaluateCiRow(ci) {
888
907
  if (ci === null || ci === undefined) return { ok: false, detail: 'CI status unavailable' };
889
- if (ci.degraded) return { ok: false, detail: `CI status unknown (${ci.degraded})` };
890
- const job = ci.failingJobName ? ` (${ci.failingJobName})` : '';
891
- return { ok: ci.status === 'green', detail: `status: ${ci.status}${job}` };
908
+ if (ci.degraded) {
909
+ const why = ci.detail ? `: ${ci.detail}` : '';
910
+ return { ok: false, detail: `CI status unknown (${ci.degraded}${why})` };
911
+ }
912
+ // A failing job NAMES the failure, so it wins over the generic reason;
913
+ // `reason` is what fills the gap when there is no job to name (`unknown`).
914
+ const reason = ci.failingJobName || ci.details?.reason || '';
915
+ const why = reason ? ` (${reason})` : '';
916
+ return { ok: ci.status === 'green', detail: `status: ${ci.status}${why}` };
892
917
  }
893
918
 
894
919
  /**