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
@@ -39,7 +39,18 @@
39
39
  * 0.0961 ms/call.
40
40
  */
41
41
 
42
- import { promises as fs, existsSync, readFileSync } from 'node:fs';
42
+ import {
43
+ promises as fs,
44
+ closeSync,
45
+ existsSync,
46
+ fstatSync,
47
+ openSync,
48
+ readFileSync,
49
+ readSync,
50
+ readdirSync,
51
+ realpathSync,
52
+ } from 'node:fs';
53
+ import { tmpdir } from 'node:os';
43
54
  import path from 'node:path';
44
55
  import { getProjectDir, SO_SHARED_DIR } from './platform.mjs';
45
56
  import { readLock } from './session-lock.mjs';
@@ -49,8 +60,14 @@ import {
49
60
  readProcessLocalSessionIds,
50
61
  } from './session-identity/own-session.mjs';
51
62
  import {
63
+ ARCHIVE_DIR_NAME,
64
+ ARCHIVE_NAME_RE,
52
65
  EventValidationError,
66
+ LEGACY_RING_MAX,
67
+ ROTATION_EVENT,
68
+ parseEventLines,
53
69
  stampEventSchemaVersion,
70
+ summarizeEventRecords,
54
71
  validateEventRecord,
55
72
  } from './events-schema.mjs';
56
73
 
@@ -58,6 +75,67 @@ import {
58
75
  // Public API
59
76
  // ---------------------------------------------------------------------------
60
77
 
78
+ /**
79
+ * Test seam (#1397 item 11): the absolute path of a sandbox ledger that the
80
+ * DEFAULT destination is redirected to whenever that default would land outside
81
+ * the OS temp root. Set by `tests/setup/events-ledger-guard.mjs` in every
82
+ * vitest worker, and inherited by every child process the suite spawns — which
83
+ * is the point: the default resolves via `SO_PROJECT_DIR` (env, else a walk up
84
+ * from the cwd), so a spawned script with `cwd = <repo>` otherwise appended
85
+ * synthetic records, stamped with the LIVE session id, to the real ledger.
86
+ * Nothing in production sets it.
87
+ */
88
+ export const EVENTS_LEDGER_SANDBOX_ENV = 'SO_EVENTS_LEDGER_SANDBOX';
89
+
90
+ /**
91
+ * OS temp root in both spellings (macOS `os.tmpdir()` is `/var/folders/…`, a
92
+ * symlink to `/private/var/folders/…`; `process.cwd()` inside it reports the
93
+ * canonical form). Only called while the sandbox variable is set.
94
+ * @returns {string[]}
95
+ */
96
+ function tmpRoots() {
97
+ const raw = path.resolve(tmpdir());
98
+ let real = raw;
99
+ try { real = realpathSync(raw); } catch { /* keep the raw spelling */ }
100
+ return real === raw ? [raw] : [raw, real];
101
+ }
102
+
103
+ /** @param {string} p @param {string[]} roots @returns {boolean} */
104
+ function isUnderAny(p, roots) {
105
+ const abs = path.resolve(p);
106
+ return roots.some((r) => abs === r || abs.startsWith(r + path.sep));
107
+ }
108
+
109
+ /**
110
+ * Apply the sandbox redirect to a DEFAULT-resolved ledger path.
111
+ *
112
+ * Rule: redirect only a default that would leave the temp root. A fixture
113
+ * project dir under tmp (`CLAUDE_PROJECT_DIR=<mkdtemp>`, or `cwd: <mkdtemp>`) is
114
+ * where a test EXPECTS its records — measured 2026-09-19 @ d92c2ca4, 38 test
115
+ * files set a project-dir env var and read `events.jsonl` back — so it is kept
116
+ * byte-identical. Keying on "outside tmp" rather than on "is this repo's ledger"
117
+ * is deliberate: an allow-list of protected roots fails open on the root it did
118
+ * not name (a sibling repo, a copied plugin tree), this invariant does not.
119
+ *
120
+ * Why the sandbox value must itself sit under the temp root: a stray or
121
+ * malformed export in an operator's shell must never be able to aim real
122
+ * telemetry at another tracked file. A value outside tmp, a relative value, or
123
+ * a whitespace-only value is IGNORED → production resolution, unchanged. What
124
+ * this cannot prevent (BV-004 ceiling): a stray export that DOES point under
125
+ * tmp diverts a real session's records there. The name marks it a test seam;
126
+ * revisit if it is ever found set outside a vitest process.
127
+ *
128
+ * @param {string} defaultPath
129
+ * @returns {string}
130
+ */
131
+ function sandboxedDefault(defaultPath) {
132
+ const sandbox = (process.env[EVENTS_LEDGER_SANDBOX_ENV] || '').trim();
133
+ if (!sandbox || !path.isAbsolute(sandbox)) return defaultPath;
134
+ const roots = tmpRoots();
135
+ if (!isUnderAny(sandbox, roots) || isUnderAny(defaultPath, roots)) return defaultPath;
136
+ return path.resolve(sandbox);
137
+ }
138
+
61
139
  /**
62
140
  * Returns the absolute path to `.orchestrator/metrics/events.jsonl` under `repoRoot`.
63
141
  *
@@ -67,11 +145,17 @@ import {
67
145
  * CWD/env-resolved project — e.g. a unit test running the gate against a tmp
68
146
  * repo, which must NOT append synthetic records to the real fleet telemetry.
69
147
  *
148
+ * Only the zero-arg (default) form honours {@link EVENTS_LEDGER_SANDBOX_ENV};
149
+ * an explicit `repoRoot` is never redirected. Readers that resolve the default
150
+ * (session-start rotation) and raw writers that use it (the discovery-validator
151
+ * hook) therefore see the same sandbox `emitEvent()` writes to.
152
+ *
70
153
  * @param {string} [repoRoot=SO_PROJECT_DIR] — project root the events log lives under.
71
154
  * @returns {string}
72
155
  */
73
- export function eventsFilePath(repoRoot = getProjectDir()) {
74
- return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
156
+ export function eventsFilePath(repoRoot) {
157
+ if (repoRoot !== undefined) return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
158
+ return sandboxedDefault(path.join(getProjectDir(), SO_SHARED_DIR, 'metrics', 'events.jsonl'));
75
159
  }
76
160
 
77
161
  /**
@@ -352,8 +436,9 @@ export async function emitEvent(type, payload = {}, opts = {}) {
352
436
  // 1. explicit opts.filePath (a pre-resolved path — #611)
353
437
  // 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941)
354
438
  // 3. the SO_PROJECT_DIR default (unchanged for 2-arg callers)
355
- // eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default param,
356
- // so a caller passing neither behaves EXACTLY as before (additive).
439
+ // eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default, so a
440
+ // caller passing neither behaves EXACTLY as before (additive) — except under
441
+ // the test-only EVENTS_LEDGER_SANDBOX_ENV seam, which only 3. honours.
357
442
  const filePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
358
443
  await fs.mkdir(path.dirname(filePath), { recursive: true });
359
444
  await fs.appendFile(filePath, line, 'utf8');
@@ -379,3 +464,437 @@ export async function emitEvent(type, payload = {}, opts = {}) {
379
464
  }).catch(() => {});
380
465
  }
381
466
  }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Rotation-aware reading (#1401)
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Read one JSONL source into a descriptor — never throws.
474
+ *
475
+ * @param {string} filePath
476
+ * @param {'active'|'archive'|'legacy-ring'} kind
477
+ * @returns {{path: string, kind: string, readable: boolean, records: object[],
478
+ * malformed_lines: number, first_ts: string|null, last_ts: string|null,
479
+ * error?: string}}
480
+ */
481
+ function readEventSource(filePath, kind) {
482
+ let text;
483
+ try {
484
+ text = readFileSync(filePath, 'utf8');
485
+ } catch (err) {
486
+ return {
487
+ path: filePath,
488
+ kind,
489
+ readable: false,
490
+ records: [],
491
+ malformed_lines: 0,
492
+ first_ts: null,
493
+ last_ts: null,
494
+ error: err?.message ?? String(err),
495
+ };
496
+ }
497
+ const { records, malformedLines } = parseEventLines(text);
498
+ const { firstTs, lastTs } = summarizeEventRecords(records);
499
+ return {
500
+ path: filePath,
501
+ kind,
502
+ readable: true,
503
+ records,
504
+ malformed_lines: malformedLines,
505
+ first_ts: firstTs,
506
+ last_ts: lastTs,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Every archive belonging to `logPath`, in BOTH schemes.
512
+ *
513
+ * Reading the legacy `.1`..`.N` ring is not politeness towards old code — it is
514
+ * a live requirement: measured 2026-09-19, two fleet repos hold a ~10 MB
515
+ * `events.jsonl.1` written before #1401 switched the writer to `_archive/`. A
516
+ * reader that saw only the new scheme would drop that history and call the
517
+ * result complete.
518
+ *
519
+ * `unindexed` (#1423) names every OTHER `.jsonl` in that directory — a file the
520
+ * reader will NOT fold into the timeline because rotation did not write it.
521
+ * Reporting it is not the same as counting it as missing: this repo's own
522
+ * `_archive/` holds a hand-placed `events-worktree-vault-session-analysis-…`
523
+ * (80 records), and calling that a GAP would pin the ledger at
524
+ * `complete: false` forever, which is the instrument HR-101 calls broken. It is
525
+ * a NOTICE — visible, never a verdict.
526
+ *
527
+ * @param {string} logPath — absolute path of the ACTIVE log.
528
+ * @returns {{archives: string[], legacy: string[], ringHoles: number[], unindexed: string[]}}
529
+ */
530
+ function discoverArchives(logPath) {
531
+ const dir = path.dirname(logPath);
532
+
533
+ const archives = [];
534
+ const unindexed = [];
535
+ const archiveDir = path.join(dir, ARCHIVE_DIR_NAME);
536
+ try {
537
+ for (const name of readdirSync(archiveDir).sort()) {
538
+ const abs = path.join(archiveDir, name);
539
+ if (ARCHIVE_NAME_RE.test(name)) archives.push(abs);
540
+ else if (name.endsWith('.jsonl')) unindexed.push(abs);
541
+ }
542
+ } catch {
543
+ /* no archive directory yet — not a gap, just nothing rotated here */
544
+ }
545
+
546
+ // The ring was contiguous BY CONSTRUCTION (each rotation shifted every slot
547
+ // up by one), so a hole between two present slots can only mean a backup was
548
+ // removed out of band. Slots above the highest present one are simply
549
+ // "not rotated that many times" and are not holes.
550
+ const present = [];
551
+ for (let i = 1; i <= LEGACY_RING_MAX; i += 1) {
552
+ if (existsSync(`${logPath}.${i}`)) present.push(i);
553
+ }
554
+ const highest = present.length > 0 ? present[present.length - 1] : 0;
555
+ const ringHoles = [];
556
+ for (let i = 1; i < highest; i += 1) {
557
+ if (!present.includes(i)) ringHoles.push(i);
558
+ }
559
+ return { archives, legacy: present.map((i) => `${logPath}.${i}`), ringHoles, unindexed };
560
+ }
561
+
562
+ /**
563
+ * Read the events ledger ACROSS rotation boundaries — active file plus every
564
+ * archive still on disk — in time order, reporting what is missing instead of
565
+ * silently returning less.
566
+ *
567
+ * ## Why this exists (#1401)
568
+ *
569
+ * Before it, nothing in `scripts/` or `hooks/` read a rotated backup at all
570
+ * (census 2026-09-19 @ `8f15f77b`: `rg -n 'jsonl\.1|jsonl\.[0-9]' scripts/ hooks/`
571
+ * excluding tests → zero code hits). Every window analysis therefore lost its
572
+ * whole history at each rotation, silently — which is what reduced the #1037
573
+ * guard-attribution join to 2 of 38 sessions.
574
+ *
575
+ * ## The three honesty rules
576
+ *
577
+ * 1. **A missing archive is a FINDING, not an empty result.** When a record in
578
+ * a later file names `archived_as: X` and X is not on disk, that appears in
579
+ * `gaps` with the range X covered, and `complete` is `false`. This is the
580
+ * exact shape of the 2026-09-19 loss, and it is detectable only because the
581
+ * rotation writes that pointer (see `events-rotation.mjs`) — an archive
582
+ * deleted before #1401 left no trace and is undetectable by construction.
583
+ * EXISTENCE IS NOT READING (#1423): the sibling counts only when it was an
584
+ * actual SOURCE of this read. A file that exists beside the active log but
585
+ * whose name misses `ARCHIVE_NAME_RE` is never read, so treating
586
+ * `existsSync` as proof silenced the tombstone while its records stayed
587
+ * out — that case is its own gap kind, `unindexed-archive`.
588
+ * 2. **Unreadable lines are COUNTED** (`malformed_lines`, per source and total).
589
+ * A silently skipping JSONL parser turns a partial result into a clean
590
+ * verdict.
591
+ * 3. **Order is by measured time, not by filename.** Sources are sorted on
592
+ * their earliest parseable timestamp; records within a source keep file
593
+ * (append) order. A source with no parseable timestamp sorts last rather
594
+ * than being dropped.
595
+ * 4. **`complete` has THREE states, because the answer does (#1423).** `true` =
596
+ * measured and whole, `false` = measured with a named gap, `null` = NOT
597
+ * MEASURED: no source existed at all (no active file, no archive, no legacy
598
+ * ring), so `events: []` is the absence of a ledger and not the absence of
599
+ * events. `complete: true` on a repo with no ledger is the house failure
600
+ * class "a missing measurement looks like zero".
601
+ *
602
+ * Separate from `gaps`, `notices` carries what was SEEN but deliberately not
603
+ * read — today only `unindexed-archive-file` (see {@link discoverArchives}).
604
+ * A notice never moves `complete`.
605
+ *
606
+ * CEILING (BV-004): every source is read fully into memory — at the default
607
+ * `max-size-mb: 10` / `max-backups: 5` that is up to ~60 MB transient. There is
608
+ * no windowing parameter because no caller has asked for one; revisit when a
609
+ * consumer needs a `since` filter or `max-size-mb` is raised past ~100.
610
+ *
611
+ * @param {string} [repoRoot] — project root; defaults exactly as
612
+ * {@link eventsFilePath} does (and only the default form honours the test
613
+ * sandbox seam).
614
+ * @param {object} [opts={}]
615
+ * @param {string} [opts.filePath] — override the active-log path outright.
616
+ * @returns {{events: object[], sources: object[], malformed_lines: number,
617
+ * gaps: object[], notices: object[], complete: boolean|null,
618
+ * active_path: string}}
619
+ */
620
+ export function readEventsWithRotations(repoRoot, opts = {}) {
621
+ const activePath = opts.filePath ?? eventsFilePath(repoRoot);
622
+ const { archives, legacy, ringHoles, unindexed } = discoverArchives(activePath);
623
+
624
+ const sources = [
625
+ ...archives.map((p) => readEventSource(p, 'archive')),
626
+ ...legacy.map((p) => readEventSource(p, 'legacy-ring')),
627
+ ];
628
+ if (existsSync(activePath)) sources.push(readEventSource(activePath, 'active'));
629
+
630
+ // Time order across sources; undatable sources last, stable by path.
631
+ sources.sort((a, b) => {
632
+ const am = a.first_ts ? Date.parse(a.first_ts) : Infinity;
633
+ const bm = b.first_ts ? Date.parse(b.first_ts) : Infinity;
634
+ if (am !== bm) return am - bm;
635
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
636
+ });
637
+
638
+ const gaps = [];
639
+ const onDisk = new Set(sources.map((s) => s.path));
640
+ // Same construction `discoverArchives` uses, so its unresolved entries in
641
+ // `onDisk` still match by string.
642
+ const ownArchiveDir = path.join(path.dirname(activePath), ARCHIVE_DIR_NAME);
643
+
644
+ // Rule 1 — every rotation tombstone must still point at a file.
645
+ for (const source of sources) {
646
+ for (const record of source.records) {
647
+ if (record?.event !== ROTATION_EVENT) continue;
648
+ const target = record.archived_as;
649
+ if (typeof target !== 'string' || target.length === 0) continue;
650
+ // #1411 — resolve the tombstone against THIS ledger's own `_archive/`,
651
+ // by BASENAME, and never against the absolute value it stores.
652
+ //
653
+ // The writer keeps `archived_as` absolute (`events-rotation.mjs` joins
654
+ // the repo root) and that value stays in the record as PROVENANCE. It is
655
+ // not a lookup key: a moved checkout, a clone, or a sibling git worktree
656
+ // — routine here — makes every tombstone name a path that does not exist
657
+ // in THIS tree, so the old exact comparison reported a phantom
658
+ // `missing-archive` for an archive sitting right beside the active file.
659
+ // Basename, not `realpath`: realpath cannot resolve a path that no longer
660
+ // exists, which IS the failure mode.
661
+ //
662
+ // ORDER (why the absolute value has no second chance): the sibling answer
663
+ // is consulted first, and an absolute hit would only be trustworthy while
664
+ // it pointed INSIDE this ledger's own archive dir — but any such path
665
+ // resolves to exactly the sibling path already tested, so once the
666
+ // sibling misses, an absolute hit can ONLY be a still-present OLD
667
+ // checkout. Honouring it would validate THIS ledger against a FOREIGN
668
+ // repo's archive: a silent false negative, worse than the phantom gap.
669
+ const sibling = path.join(ownArchiveDir, path.basename(target));
670
+ if (onDisk.has(sibling)) continue;
671
+ gaps.push({
672
+ // #1423 — THREE outcomes, not two: read (above), present-but-unread
673
+ // (here), gone (below). The middle one used to be silently folded into
674
+ // the first by `existsSync`, so a tombstone whose archive had been
675
+ // RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its
676
+ // records were absent from `events`.
677
+ kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
678
+ path: sibling,
679
+ archived_as: target,
680
+ first_ts: record.first_ts ?? null,
681
+ last_ts: record.last_ts ?? null,
682
+ lines: record.lines ?? null,
683
+ size_before: record.size_before ?? null,
684
+ reported_by: source.path,
685
+ rotated_at: record.timestamp ?? null,
686
+ });
687
+ }
688
+ }
689
+
690
+ for (const slot of ringHoles) {
691
+ gaps.push({
692
+ kind: 'ring-hole',
693
+ archived_as: `${activePath}.${slot}`,
694
+ first_ts: null,
695
+ last_ts: null,
696
+ reported_by: activePath,
697
+ });
698
+ }
699
+
700
+ for (const source of sources) {
701
+ if (source.readable) continue;
702
+ gaps.push({
703
+ kind: 'unreadable-source',
704
+ archived_as: source.path,
705
+ first_ts: null,
706
+ last_ts: null,
707
+ reported_by: source.path,
708
+ error: source.error ?? null,
709
+ });
710
+ }
711
+
712
+ const events = [];
713
+ let malformed = 0;
714
+ for (const source of sources) {
715
+ events.push(...source.records);
716
+ malformed += source.malformed_lines;
717
+ }
718
+
719
+ return {
720
+ events,
721
+ sources: sources.map(({ records, ...rest }) => ({ ...rest, records: records.length })),
722
+ malformed_lines: malformed,
723
+ gaps,
724
+ notices: unindexed.map((p) => ({ kind: 'unindexed-archive-file', path: p })),
725
+ // Honesty rule 4: no source at all ⇒ NOT MEASURED (`null`), never a clean
726
+ // `true` over an empty read.
727
+ complete: sources.length === 0 ? null : gaps.length === 0,
728
+ active_path: activePath,
729
+ };
730
+ }
731
+
732
+ // ---------------------------------------------------------------------------
733
+ // Streaming reads across rotations (#1414)
734
+ // ---------------------------------------------------------------------------
735
+
736
+ /**
737
+ * Default chunk for the streaming readers — 256 KiB, ~800 records in this
738
+ * repo's ledger.
739
+ *
740
+ * NAMED CEILING (BV-004): a line LONGER than one chunk is carried, never
741
+ * dropped, so the constant bounds memory and not correctness. REVISIT when one
742
+ * repo's `events.jsonl` passes 50 MB — at that size the backwards "never
743
+ * happened" walk needs an index rather than a scan.
744
+ */
745
+ export const SCAN_CHUNK_BYTES = 256 * 1024;
746
+
747
+ /**
748
+ * Every source of this ledger that EXISTS, NEWEST FIRST — without reading one
749
+ * byte of their contents (#1414).
750
+ *
751
+ * Order: the active file, then `_archive/` archives by name DESCENDING (the
752
+ * name encodes `first_ts`/`last_ts`, so lexical order is chronological), then
753
+ * the legacy ring ASCENDING (`.1` is the most recent backup, `.N` the oldest).
754
+ * Reverse the result for an oldest-first pass.
755
+ *
756
+ * Hand-placed files in `_archive/` that rotation did not write are NOT sources
757
+ * here, exactly as in {@link readEventsWithRotations} — one definition of "what
758
+ * belongs to this ledger", not two.
759
+ *
760
+ * @param {object} [opts={}]
761
+ * @param {string} [opts.filePath] — the ACTIVE log path; defaults as {@link eventsFilePath}.
762
+ * @param {string} [opts.repoRoot] — used only when `filePath` is absent.
763
+ * @returns {Array<{path: string, kind: 'active'|'archive'|'legacy-ring'}>}
764
+ */
765
+ export function listEventSourcesNewestFirst(opts = {}) {
766
+ const activePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
767
+ const { archives, legacy } = discoverArchives(activePath);
768
+ const out = [];
769
+ if (existsSync(activePath)) out.push({ path: activePath, kind: 'active' });
770
+ for (const p of [...archives].reverse()) out.push({ path: p, kind: 'archive' });
771
+ for (const p of legacy) out.push({ path: p, kind: 'legacy-ring' });
772
+ return out;
773
+ }
774
+
775
+ /**
776
+ * Walk the ledger BACKWARDS across rotation boundaries, newest record first,
777
+ * stopping at the first record the caller accepts (#1414).
778
+ *
779
+ * ## Why this exists
780
+ *
781
+ * `readEventsWithRotations` loads every source fully — up to ~60 MB transient
782
+ * at the default `max-size-mb: 10` × `max-backups: 5`. That is the right shape
783
+ * for a cold analysis and the wrong one for a session-start probe, so the two
784
+ * HOT-PATH readers (`maintenance-due-banner.mjs`, `backfill-abandoned-sessions.mjs`)
785
+ * stayed single-file and answered "never happened" from the active file alone.
786
+ * This reader gives them the whole history at a bounded memory cost.
787
+ *
788
+ * A record SPLIT across a chunk boundary is CARRIED, never parsed twice or
789
+ * dropped: the bytes before a chunk's first newline are the tail of a line
790
+ * whose head sits in the chunk not read yet. Splitting on the 0x0A byte is safe
791
+ * on UTF-8 (no continuation byte equals a newline), and the carry is joined as
792
+ * a BUFFER, so a multibyte character never splits either.
793
+ *
794
+ * @param {object} opts
795
+ * @param {string} [opts.filePath] — the ACTIVE log path; defaults as {@link eventsFilePath}.
796
+ * @param {string} [opts.repoRoot] — used only when `filePath` is absent.
797
+ * @param {number} [opts.chunkBytes={@link SCAN_CHUNK_BYTES}]
798
+ * @param {(record: object, source: {path: string, kind: string}) => boolean} opts.onRecord —
799
+ * called with each PARSED record, newest first; returning `true` stops the walk.
800
+ * @param {string} [opts.filter] — cheap substring pre-filter applied to the RAW
801
+ * line before `JSON.parse`. The measured reason it exists: the caller that
802
+ * this replaced pre-filtered on the event name, and parsing every line of a
803
+ * 7.9 MB ledger to answer one question is the cost that made it do so.
804
+ * NOTE: with a filter set, `malformed_lines` counts only unreadable lines
805
+ * AMONG THE MATCHING ones — the rest are never parsed.
806
+ * @param {number} [opts.budgetMs] — wall-clock budget. When it runs out before
807
+ * a stop, the walk ends with `truncated: true` — which is NOT "not found":
808
+ * the caller must report undeterminable, never a clean negative.
809
+ * @returns {{stopped: boolean, truncated: boolean, malformed_lines: number,
810
+ * sources: string[], unreadable: string[]}}
811
+ */
812
+ export function scanEventsBackwards(opts = {}) {
813
+ const { onRecord, filter, budgetMs } = opts;
814
+ if (typeof onRecord !== 'function') {
815
+ throw new TypeError('scanEventsBackwards: opts.onRecord must be a function');
816
+ }
817
+ const chunkBytes =
818
+ Number.isInteger(opts.chunkBytes) && opts.chunkBytes > 0 ? opts.chunkBytes : SCAN_CHUNK_BYTES;
819
+ const deadline = Number.isFinite(budgetMs) ? Date.now() + budgetMs : null;
820
+ const outOfTime = () => deadline !== null && Date.now() > deadline;
821
+
822
+ const scanned = [];
823
+ const unreadable = [];
824
+ let malformed = 0;
825
+ let stopped = false;
826
+ let truncated = false;
827
+
828
+ /** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
829
+ const consume = (block, source) => {
830
+ const lines = block.toString('utf8').split('\n');
831
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
832
+ const line = lines[i];
833
+ if (!line) continue;
834
+ if (typeof filter === 'string' && !line.includes(filter)) continue;
835
+ let record;
836
+ try {
837
+ record = JSON.parse(line);
838
+ } catch {
839
+ malformed += 1; // counted, never silently skipped (honesty rule 2)
840
+ continue;
841
+ }
842
+ if (onRecord(record, source) === true) return true;
843
+ }
844
+ return false;
845
+ };
846
+
847
+ for (const source of listEventSourcesNewestFirst(opts)) {
848
+ if (stopped || truncated) break;
849
+ if (outOfTime()) {
850
+ truncated = true;
851
+ break;
852
+ }
853
+ scanned.push(source.path);
854
+ let fd;
855
+ try {
856
+ fd = openSync(source.path, 'r');
857
+ let pos = fstatSync(fd).size;
858
+ /** Partial line at the FRONT of everything read from THIS source so far. */
859
+ let carry = Buffer.alloc(0);
860
+ while (pos > 0) {
861
+ if (outOfTime()) {
862
+ truncated = true;
863
+ break;
864
+ }
865
+ const length = Math.min(chunkBytes, pos);
866
+ pos -= length;
867
+ const buf = Buffer.alloc(length);
868
+ readSync(fd, buf, 0, length, pos);
869
+ const block = carry.length > 0 ? Buffer.concat([buf, carry]) : buf;
870
+ const firstNewline = block.indexOf(0x0a);
871
+ if (firstNewline === -1) {
872
+ carry = block; // a line longer than one chunk — no complete line yet
873
+ continue;
874
+ }
875
+ if (consume(block.subarray(firstNewline + 1), source)) {
876
+ stopped = true;
877
+ break;
878
+ }
879
+ carry = block.subarray(0, firstNewline);
880
+ }
881
+ // pos === 0: the carry is this source's FIRST line, complete by construction.
882
+ if (!stopped && !truncated && consume(carry, source)) stopped = true;
883
+ } catch (err) {
884
+ // An unreadable source is a FINDING, not an empty one: it is exactly the
885
+ // case where "no record found" must not be reported as "never happened".
886
+ unreadable.push(source.path);
887
+ void err;
888
+ } finally {
889
+ if (fd !== undefined) {
890
+ try {
891
+ closeSync(fd);
892
+ } catch {
893
+ /* best-effort */
894
+ }
895
+ }
896
+ }
897
+ }
898
+
899
+ return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable };
900
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { digestSha256Short } from './crypto-digest-utils.mjs';
14
14
  import { resolveHostPath } from './config/host-paths.mjs';
15
+ import { maskSource } from './js-mask.mjs';
15
16
  import { readFileSync, statSync } from 'node:fs';
16
17
  import { homedir } from 'node:os';
17
18
  import { dirname, join, resolve } from 'node:path';
@@ -117,25 +118,34 @@ let _warnedFallback = false;
117
118
  */
118
119
  const _cache = { mtime: null, result: null };
119
120
 
121
+ /** One quoted string literal — its interior in group 2. */
122
+ const STRING_LITERAL_RE = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
123
+
120
124
  /**
121
- * Parse the type-enum values out of a Zod `z.enum([...])` call in source text.
125
+ * Parse the enum values out of a Zod `z.enum([...])` call in source text.
122
126
  *
123
- * @param {string} text
127
+ * `codeText` must be the source with its COMMENTS blanked
128
+ * (`maskSource(text, { keepLiterals: true })`): the values are the quoted
129
+ * literals of the enum body and nothing else. Splitting the raw body on commas
130
+ * rendered comment prose between the literals as enum values — 6 of 16
131
+ * `statusEnum` entries against the real baseline schema, one fused onto
132
+ * `'maintenance'` (#1298) — and every one reached agent prompts as an allowed
133
+ * `status`. Masking via the shared lexer also keeps a `//` inside a literal
134
+ * intact and a `]` inside a comment from ending the body early.
135
+ *
136
+ * @param {string} codeText - comment-masked source
124
137
  * @param {string} exportName - e.g. 'vaultNoteTypeSchema'
125
138
  * @returns {string[]}
126
139
  */
127
- function _extractEnum(text, exportName) {
140
+ function _extractEnum(codeText, exportName) {
128
141
  // Match: export const <name> = z.enum([ ...values... ]);
129
142
  const re = new RegExp(
130
143
  `export\\s+const\\s+${exportName}\\s*=\\s*z\\.enum\\(\\s*\\[([^\\]]+)\\]`,
131
144
  's',
132
145
  );
133
- const match = text.match(re);
146
+ const match = codeText.match(re);
134
147
  if (!match) return [];
135
- return match[1]
136
- .split(',')
137
- .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
138
- .filter(Boolean);
148
+ return [...match[1].matchAll(STRING_LITERAL_RE)].map((m) => m[2]).filter(Boolean);
139
149
  }
140
150
 
141
151
  /**
@@ -145,8 +155,9 @@ function _extractEnum(text, exportName) {
145
155
  * @returns {{ typeEnum: string[], statusEnum: string[], requiredFields: string[], idRegex: string, tagsRegex: string, schemaText: string }}
146
156
  */
147
157
  function _parseSchema(text) {
148
- const typeEnum = _extractEnum(text, 'vaultNoteTypeSchema');
149
- const statusEnum = _extractEnum(text, 'vaultNoteStatusSchema');
158
+ const codeText = maskSource(text, { keepLiterals: true });
159
+ const typeEnum = _extractEnum(codeText, 'vaultNoteTypeSchema');
160
+ const statusEnum = _extractEnum(codeText, 'vaultNoteStatusSchema');
150
161
 
151
162
  // Required fields are id, type, created, updated — stable; extracted
152
163
  // from the schema object declaration (non-optional fields).
@@ -10,6 +10,31 @@ import { runCheck } from './gate-helpers.mjs';
10
10
  const typecheckCmd = process.env.TYPECHECK_CMD;
11
11
  const testCmd = process.env.TEST_CMD;
12
12
 
13
+ /**
14
+ * Per-command wall-clock ceiling, published by `scripts/run-quality-gate.mjs`
15
+ * as `GATE_TIMEOUT_MS` (#1425 A3 / #1432). It carries the ALREADY-RESOLVED
16
+ * value — operator override `SO_GATE_TIMEOUT_MS` > Session Config
17
+ * `gate.timeout-path-b-ms` > 900 000 — so this script only has to read it.
18
+ *
19
+ * Absent or non-numeric (this gate invoked directly, not through the wrapper)
20
+ * → the option is OMITTED, and `runCheck` falls back to `resolveGateTimeoutMs()`
21
+ * exactly as before. An empty object is deliberate: passing `timeoutMs:
22
+ * undefined` would NOT trigger that fallback in every spread order.
23
+ */
24
+ const CHECK_OPTS = (() => {
25
+ const raw = Number((process.env.GATE_TIMEOUT_MS || '').trim());
26
+ const opts = Number.isFinite(raw) && raw > 0 ? { timeoutMs: raw } : {};
27
+ // GATE_LEDGER_ROOT (#1425 A4, W5 fix-pass): the wrapper publishes the ledger
28
+ // root it resolved (--ledger-root > repo root) so the gate-process register
29
+ // is written where the WRAPPER decided, not where this sub-script happens to
30
+ // run. Without it every runCheck() here defaulted to process.cwd(), and a test
31
+ // that spawned this script from the checkout wrote real lines into the live
32
+ // .orchestrator/runtime/gate-processes.jsonl — the reaper's kill population.
33
+ const ledgerRoot = (process.env.GATE_LEDGER_ROOT || "").trim();
34
+ if (ledgerRoot && ledgerRoot.startsWith("/")) opts.repoRoot = ledgerRoot;
35
+ return opts;
36
+ })();
37
+
13
38
  if (!typecheckCmd) {
14
39
  process.stderr.write('TYPECHECK_CMD must be set\n');
15
40
  process.exit(1);
@@ -23,11 +48,11 @@ let tcResult = { status: 'skip', output: '' };
23
48
  let testResult = { status: 'skip', output: '' };
24
49
 
25
50
  if (typecheckCmd !== 'skip') {
26
- tcResult = await runCheck(typecheckCmd);
51
+ tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
27
52
  }
28
53
 
29
54
  if (testCmd !== 'skip') {
30
- testResult = await runCheck(testCmd);
55
+ testResult = await runCheck(testCmd, CHECK_OPTS);
31
56
  }
32
57
 
33
58
  const result = {