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
@@ -36,8 +36,6 @@ import { randomUUID } from 'node:crypto';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { shouldRunHook } from './_lib/profile-gate.mjs';
39
- // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
40
- if (!shouldRunHook('on-session-start')) process.exit(0);
41
39
 
42
40
  import { emitEvent, eventsFilePath } from '../scripts/lib/events.mjs';
43
41
  import { maybeRotate } from '../scripts/lib/events-rotation.mjs';
@@ -54,6 +52,7 @@ import { detectColdStart, consumeMarker } from '../scripts/lib/cold-start-detect
54
52
  import { parseSessionId } from '../scripts/lib/session-id.mjs';
55
53
  import { readTelemetryState, resolveConsent, isCiEnv } from '../scripts/lib/telemetry/consent.mjs';
56
54
  import { loadOwnerConfig } from '../scripts/lib/owner-yaml.mjs';
55
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
57
56
  // SSOT for the "is this a re-entry into the same logical session?" question
58
57
  // (#1091). Defined in the lock-bootstrap leaf module, which this hook already
59
58
  // loads, so the preservation branch below and the lock force-refresh gate can
@@ -453,8 +452,10 @@ async function resolveSessionId(input, projectRoot) {
453
452
  // observe. A naive full overwrite of current-session.json drops the
454
453
  // `last_wave` / `last_batch` markers written mid-session by
455
454
  // post-tool-batch-wave-signal.mjs, which makes the next PostToolBatch
456
- // re-read last_wave as absent→0 and re-emit a duplicate
457
- // orchestrator.wave.started{N} with no intervening wave.completed.
455
+ // re-read last_wave as absent→0 and RE-OPEN wave N mid-wave, re-stamping
456
+ // wave_start_sha and so moving the #980 files_changed start point. (Until
457
+ // 2026-09-19 it also re-emitted a duplicate wave.started{N}; that event
458
+ // has since been removed.)
458
459
  //
459
460
  // Three cases, exactly one of which preserves nothing:
460
461
  // 'raw-id' — same-logical-session source AND the recorded raw
@@ -764,8 +765,12 @@ async function main() {
764
765
  // v3.1.0 env-aware banner (opt-out via enable-host-banner: false in Session Config).
765
766
  // The ask-via-tool nudge rides the same opt-out flag — both are coordinator
766
767
  // reminders shown at session start; users who silence one expect silence.
768
+ // Hoisted because the cold-start DISPLAY below rides the same opt-out (#1133)
769
+ // and `bannerData` is not a usable proxy for it: `emitHostBanner()` returns
770
+ // null on its own error paths, which would silently re-open the nudge.
771
+ const hostBannerEnabled = await isHostBannerEnabled(projectRoot);
767
772
  let bannerData = null;
768
- if (await isHostBannerEnabled(projectRoot)) {
773
+ if (hostBannerEnabled) {
769
774
  bannerData = await emitHostBanner(projectRoot);
770
775
  // Always-on nudge: a user decision has three legitimate forms and AUQ-001
771
776
  // routes between them in order — operator verb first (nothing is blocked
@@ -795,7 +800,14 @@ async function main() {
795
800
  enabled: coldStartCfg.enabled !== false,
796
801
  });
797
802
  if (decision.shouldEmit) {
798
- pushBanner(decision.bannerLines.join('\n'));
803
+ // #1133 — the DISPLAY rides the enable-host-banner opt-out like every
804
+ // other start-of-session emitter ("users who silence one expect
805
+ // silence"). The one-shot marker consumption below does NOT: the
806
+ // opt-out silences the display, never the measurement or the state
807
+ // transition. Consuming it unconditionally is what keeps the nudge
808
+ // one-shot for an operator who has banners off — otherwise it re-arms
809
+ // on every start forever.
810
+ if (hostBannerEnabled) pushBanner(decision.bannerLines.join('\n'));
799
811
  if (decision.markerPath) {
800
812
  await consumeMarker(decision.markerPath).catch(() => false);
801
813
  }
@@ -1219,6 +1231,14 @@ async function main() {
1219
1231
  // Size-based rotation of events.jsonl (#251). Session-start is the single
1220
1232
  // rotation trigger — per-append overhead is rejected design. Any failure
1221
1233
  // is swallowed: rotation must NEVER block the hook.
1234
+ //
1235
+ // #1401 — the DURABLE record of a rotation is no longer this block. Until
1236
+ // 2026-09-19 the only evidence a rotation had happened was the `console.error`
1237
+ // below, and this hook's stderr is discarded by the harness: a rotation and a
1238
+ // DELETED archive were byte-identical from outside. `maybeRotate()` now writes
1239
+ // an `orchestrator.events.rotated` record as the first line of the new active
1240
+ // file, so the ledger carries its own break whatever happens to this stderr
1241
+ // line. The line stays as an operator convenience only — never as the record.
1222
1242
  try {
1223
1243
  let rotCfg = { enabled: true, 'max-size-mb': 10, 'max-backups': 5 };
1224
1244
  try {
@@ -1234,7 +1254,13 @@ async function main() {
1234
1254
  enabled: rotCfg.enabled !== false,
1235
1255
  });
1236
1256
  if (result.rotated) {
1237
- console.error(`events-rotation: archived ${result.archivedAs} (${result.sizeBefore} bytes)`);
1257
+ const pruned = result.pruned?.length ? `, pruned ${result.pruned.length}` : '';
1258
+ const ledger = result.recordWritten ? '' : ' [WARN: rotation record NOT written]';
1259
+ console.error(
1260
+ `events-rotation: archived ${result.archivedAs} ` +
1261
+ `(${result.sizeBefore} bytes, ${result.lines ?? '?'} lines, ` +
1262
+ `${result.firstTs ?? 'unknown'} → ${result.lastTs ?? 'unknown'}${pruned})${ledger}`,
1263
+ );
1238
1264
  }
1239
1265
  } catch (err) {
1240
1266
  console.error(`events-rotation: skipped (${err?.message ?? err})`);
@@ -1245,7 +1271,18 @@ async function main() {
1245
1271
  // flushBanner() runs here too so a throw partway through main() still surfaces
1246
1272
  // whatever was already collected; it is idempotent, so the normal path (which
1247
1273
  // flushes at the end of main) does not double-emit.
1248
- main().catch(() => {}).finally(() => {
1249
- flushBanner();
1250
- process.exit(0);
1251
- });
1274
+ //
1275
+ // Entry guard (#1298 P7): every harness execs this file as the node script
1276
+ // (`sh run-node.sh <this file>` → `exec node "$@"`), so argv[1] IS this module
1277
+ // on every real path. A bare `import()` — a probe, a test, a curious agent —
1278
+ // must not run main() against the live repo: incident W4-FX1 overwrote
1279
+ // `.orchestrator/current-session.json` exactly that way.
1280
+ if (isMainModule(import.meta.url)) {
1281
+ // #1393: the profile gate sits INSIDE the entry guard — at module top level
1282
+ // its `process.exit(0)` exited every process that merely imported this hook.
1283
+ if (!shouldRunHook('on-session-start')) process.exit(0);
1284
+ main().catch(() => {}).finally(() => {
1285
+ flushBanner();
1286
+ process.exit(0);
1287
+ });
1288
+ }
package/hooks/on-stop.mjs CHANGED
@@ -30,28 +30,28 @@
30
30
  */
31
31
 
32
32
  import path from 'node:path';
33
+ import { spawn } from 'node:child_process';
34
+ import { performance } from 'node:perf_hooks';
33
35
  import {
34
36
  promises as fs,
35
- closeSync,
36
37
  existsSync,
37
- fstatSync,
38
- openSync,
39
38
  readFileSync,
40
- readSync,
41
39
  statSync,
42
40
  writeFileSync,
43
41
  } from 'node:fs';
42
+ import { fileURLToPath } from 'node:url';
44
43
 
45
44
  import { shouldRunHook } from './_lib/profile-gate.mjs';
45
+ import { readTailWindow } from '../scripts/lib/tail-window.mjs';
46
46
  import { AGENT_ID_RE, resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
47
- // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
48
- if (!shouldRunHook('on-stop')) process.exit(0);
49
47
 
50
48
  import { emitEvent } from '../scripts/lib/events.mjs';
51
49
  import { detectPlatform, getProjectDir } from '../scripts/lib/platform.mjs';
52
50
  import { parseSessionId } from '../scripts/lib/session-id.mjs';
51
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
53
52
  import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
54
53
  import { readLock, updateHeartbeat } from '../scripts/lib/session-lock.mjs';
54
+ import { _parseReaper } from '../scripts/lib/config/reaper.mjs';
55
55
 
56
56
  // ---------------------------------------------------------------------------
57
57
  // stdin reading (inline — no io.mjs because Stop hooks exit 0 always, never deny)
@@ -743,17 +743,11 @@ function firstNonEmptyString(input, keys) {
743
743
  * @returns {string|null}
744
744
  */
745
745
  function readStatusFromTranscriptTail(transcriptPath) {
746
- let fd = null;
747
746
  try {
748
- fd = openSync(transcriptPath, 'r');
749
- const size = fstatSync(fd).size;
750
- const length = Math.min(size, STATUS_TAIL_BYTES);
751
- const start = size - length;
752
- const buf = Buffer.alloc(length);
753
- const read = readSync(fd, buf, 0, length, start);
754
- let lines = buf.subarray(0, read).toString('utf8').split('\n');
747
+ const window = readTailWindow(transcriptPath, STATUS_TAIL_BYTES);
748
+ let lines = window.text.split('\n');
755
749
  // Drop the leading partial line when the window did not start at byte 0.
756
- if (start > 0) lines = lines.slice(1);
750
+ if (window.cut) lines = lines.slice(1);
757
751
 
758
752
  for (let i = lines.length - 1; i >= 0; i -= 1) {
759
753
  const line = lines[i].trim();
@@ -774,10 +768,6 @@ function readStatusFromTranscriptTail(transcriptPath) {
774
768
  return null;
775
769
  } catch {
776
770
  return null;
777
- } finally {
778
- if (fd !== null) {
779
- try { closeSync(fd); } catch { /* best-effort */ }
780
- }
781
771
  }
782
772
  }
783
773
 
@@ -785,10 +775,158 @@ function readStatusFromTranscriptTail(transcriptPath) {
785
775
  // main
786
776
  // ---------------------------------------------------------------------------
787
777
 
778
+ // ---------------------------------------------------------------------------
779
+ // orphan-reaper trigger (#1432 B4)
780
+ // ---------------------------------------------------------------------------
781
+ //
782
+ // DUPLICATED VERBATIM between the two trigger hooks —
783
+ // hooks/post-tool-batch-wave-signal.mjs <-> hooks/on-stop.mjs. Deliberate, with
784
+ // a named revisit trigger (BV-004): they are the only two trigger points the
785
+ // PRD declares, and a shared `hooks/_lib/reaper-trigger.mjs` would be a third
786
+ // file in the hook import graph. SIZE, measured 2026-09-22 by diffing the two
787
+ // blocks: 138 identical lines — this note said "~40 lines of glue" until then,
788
+ // which is what made the duplicate look cheaper than it is. Extract it the
789
+ // moment a THIRD hook needs the trigger, or the moment the copies must differ.
790
+
791
+ /**
792
+ * Filesystem path of the scan CLI, spawned as a PLAIN argv call.
793
+ *
794
+ * It used to be a `file://` URL handed to `node --input-type=module -e
795
+ * <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
796
+ * has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
797
+ * contract lives in that module instead of as source text duplicated here —
798
+ * and nothing this hook builds is a program any more: every variable part is
799
+ * an argv value, which cannot become code whatever the checkout path contains.
800
+ *
801
+ * `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
802
+ * percent-encoded path for any checkout directory containing a space.
803
+ */
804
+ const ORPHAN_REAPER_SCRIPT = fileURLToPath(
805
+ new URL('../scripts/lib/orphan-reaper.mjs', import.meta.url),
806
+ );
807
+
808
+ /**
809
+ * Read the `reaper:` block from the project's Session Config host file.
810
+ * Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
811
+ * path must not import the full config orchestrator. A missing or unreadable
812
+ * file yields the parser defaults, i.e. DISABLED.
813
+ *
814
+ * @param {string} projectDir
815
+ * @returns {ReturnType<typeof _parseReaper>}
816
+ */
817
+ function loadReaperConfig(projectDir) {
818
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
819
+ try {
820
+ return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
821
+ } catch {
822
+ // missing or unreadable — try the next candidate
823
+ }
824
+ }
825
+ return _parseReaper('');
826
+ }
827
+
828
+ /**
829
+ * Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
830
+ *
831
+ * The hook itself does exactly two pieces of I/O — one config read and one
832
+ * `stat` of the throttle marker — and then hands the work to a DETACHED,
833
+ * unref'd child. The scan is never run inline: one `ps` round-trip out of Node
834
+ * was measured at ~47 ms over 287 KB of output, which alone would blow the
835
+ * 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
836
+ *
837
+ * The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
838
+ * the throttle window — otherwise a broken spawn would be retried on every
839
+ * single tool batch.
840
+ *
841
+ * `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
842
+ * with `performance.now()` and reported as ONE stderr WARN line when exceeded.
843
+ * A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
844
+ * per-fire telemetry record is exactly the always-on signal
845
+ * `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
846
+ * measurement covers preparation only — the scan itself runs detached, which is
847
+ * the whole reason the budget can be held.
848
+ *
849
+ * Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
850
+ *
851
+ * @param {object} [opts]
852
+ * @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
853
+ * @param {number} [opts.now] Injected clock (ms).
854
+ * @param {Function} [opts.spawnFn] Injected `spawn` (tests).
855
+ * @param {Function} [opts.statFn] Injected `statSync` (tests).
856
+ * @param {Function} [opts.writeFn] Injected marker writer (tests).
857
+ * @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
858
+ * budget (tests); defaults to `performance.now`.
859
+ * @returns {Promise<{spawned: boolean, reason: string}>}
860
+ */
861
+ export async function maybeTriggerOrphanScan({
862
+ projectDir,
863
+ now,
864
+ spawnFn = spawn,
865
+ statFn,
866
+ writeFn,
867
+ clockFn = () => performance.now(),
868
+ } = {}) {
869
+ const startedAt = clockFn();
870
+ /** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
871
+ const checkLatency = (budgetMs) => {
872
+ const elapsed = clockFn() - startedAt;
873
+ if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
874
+ process.stderr.write(
875
+ `orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
876
+ + `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
877
+ );
878
+ }
879
+ };
880
+ try {
881
+ const root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
882
+ const cfg = loadReaperConfig(root);
883
+ // Cheapest gate first: disabled means no stat, no spawn, no module load.
884
+ if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
885
+
886
+ const reaper = await import('../scripts/lib/orphan-reaper.mjs');
887
+ const markerPath = reaper.scanMarkerPath(root);
888
+ const nowMs = typeof now === 'number' ? now : Date.now();
889
+
890
+ if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
891
+ checkLatency(cfg['max-hook-latency-ms']);
892
+ return { spawned: false, reason: 'throttled' };
893
+ }
894
+ reaper.touchScanMarker(markerPath, { writeFn });
895
+
896
+ const child = spawnFn(
897
+ process.execPath,
898
+ [
899
+ ORPHAN_REAPER_SCRIPT,
900
+ '--repo-root', root,
901
+ '--mode', cfg.mode,
902
+ '--min-age-seconds', String(cfg['min-age-seconds']),
903
+ '--kill-grace-ms', String(cfg['kill-grace-ms']),
904
+ '--verify-wait-ms', String(cfg['verify-wait-ms']),
905
+ '--false-alarm-window', String(cfg['false-alarm-window']),
906
+ ],
907
+ { detached: true, stdio: 'ignore' },
908
+ );
909
+ if (child && typeof child.unref === 'function') child.unref();
910
+ checkLatency(cfg['max-hook-latency-ms']);
911
+ return { spawned: true, reason: 'spawned' };
912
+ } catch {
913
+ return { spawned: false, reason: 'error' };
914
+ }
915
+ }
916
+
788
917
  async function main() {
789
918
  const input = await readStdinJson();
790
919
  const eventType = discriminate(input);
791
920
 
921
+ // Orphan-reaper trigger (#1432 B4) — SubagentStop is the PRD's second trigger
922
+ // point: it lands exactly when gate children have just finished and their PIDs
923
+ // are still known. Placed BEFORE the branch dispatch (unlike the PostToolBatch
924
+ // hook, which triggers last) because both branches below may throw — a
925
+ // `handleStop` failure must not silently disarm the watchdog. The cost is one
926
+ // config read plus one stat, bounded by `reaper.max-hook-latency-ms` (50 ms),
927
+ // and the scan itself runs in a detached child.
928
+ await maybeTriggerOrphanScan();
929
+
792
930
  if (eventType === 'subagent_stop') {
793
931
  const additionalContext = await handleSubagentStop(input);
794
932
  // v2.1.163+: emit hookSpecificOutput for SubagentStop path.
@@ -860,6 +998,14 @@ function buildTerminalSequenceJson(platform) {
860
998
  }
861
999
 
862
1000
  // Exit 0 always — informational hook must never block Claude.
863
- main()
864
- .catch(() => {})
865
- .finally(() => process.exit(0));
1001
+ // Entry guard (#1298 P7): run only as the node script every harness execs
1002
+ // (`sh run-node.sh <this file>`); a bare `import()` must not run the Stop
1003
+ // handler against the live repo.
1004
+ if (isMainModule(import.meta.url)) {
1005
+ // #1393: the profile gate sits INSIDE the entry guard — at module top level
1006
+ // its `process.exit(0)` exited every process that merely imported this hook.
1007
+ if (!shouldRunHook('on-stop')) process.exit(0);
1008
+ main()
1009
+ .catch(() => {})
1010
+ .finally(() => process.exit(0));
1011
+ }
@@ -34,8 +34,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
34
34
  import path from 'node:path';
35
35
 
36
36
  import { shouldRunHook } from './_lib/profile-gate.mjs';
37
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
38
- if (!shouldRunHook('operator-steer')) process.exit(0);
37
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
39
38
 
40
39
  import { getProjectDir } from '../scripts/lib/platform.mjs';
41
40
 
@@ -61,4 +60,13 @@ async function main() {
61
60
  }
62
61
 
63
62
  // Exit 0 always — informational hook must never block Claude.
64
- main().catch(() => {}).finally(() => process.exit(0));
63
+ // Entry guard (#1393): run only when this file IS the script node was invoked
64
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
65
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
66
+ // tear the importing process down. The profile gate sits INSIDE the guard for
67
+ // that second reason: at module top level its `process.exit(0)` exited every
68
+ // process that merely imported this hook.
69
+ if (isMainModule(import.meta.url)) {
70
+ if (!shouldRunHook('operator-steer')) process.exit(0);
71
+ main().catch(() => {}).finally(() => process.exit(0));
72
+ }
@@ -68,12 +68,13 @@ import {
68
68
  } from '../scripts/lib/issue-budget.mjs';
69
69
 
70
70
  import { shouldRunHook } from './_lib/profile-gate.mjs';
71
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
71
72
  import { existsSync } from 'node:fs';
72
73
  import path from 'node:path';
73
74
 
74
75
  // Opt-out per session via SO_DISABLED_HOOKS=post-bash-issue-budget-refund; the
75
- // "minimal"/"off" profiles disable it like every other non-core hook.
76
- if (!shouldRunHook('post-bash-issue-budget-refund')) process.exit(0);
76
+ // "minimal"/"off" profiles disable it like every other non-core hook — the gate
77
+ // itself runs inside the entry guard at the bottom of this file (#1393).
77
78
 
78
79
  // ---------------------------------------------------------------------------
79
80
  // Helpers
@@ -367,9 +368,18 @@ async function main() {
367
368
  }
368
369
 
369
370
  // Top-level error handler — fail open, same posture as the sibling hooks.
370
- main().catch((e) => {
371
- process.stderr.write(
372
- `⚠ post-bash-issue-budget-refund: internal error — ${e?.message || e}\n`,
373
- );
374
- process.exit(0);
375
- });
371
+ // Entry guard (#1393): run only when this file IS the script node was invoked
372
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
373
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
374
+ // tear the importing process down. The profile gate sits INSIDE the guard for
375
+ // that second reason: at module top level its `process.exit(0)` exited every
376
+ // process that merely imported this hook.
377
+ if (isMainModule(import.meta.url)) {
378
+ if (!shouldRunHook('post-bash-issue-budget-refund')) process.exit(0);
379
+ main().catch((e) => {
380
+ process.stderr.write(
381
+ `⚠ post-bash-issue-budget-refund: internal error — ${e?.message || e}\n`,
382
+ );
383
+ process.exit(0);
384
+ });
385
+ }
@@ -143,8 +143,6 @@
143
143
  */
144
144
 
145
145
  import { shouldRunHook } from './_lib/profile-gate.mjs';
146
- // Exit 0 immediately (silent no-op) when disabled via profile/env (#211).
147
- if (!shouldRunHook('post-bash-write-verify')) process.exit(0);
148
146
 
149
147
  import path from 'node:path';
150
148
  import os from 'node:os';
@@ -1136,6 +1134,9 @@ function invokedAsScript() {
1136
1134
  }
1137
1135
  const isMain = invokedAsScript();
1138
1136
  if (isMain) {
1137
+ // #1393: the profile gate sits INSIDE the entry guard — at module top level
1138
+ // its `process.exit(0)` exited every process that merely imported this hook.
1139
+ if (!shouldRunHook('post-bash-write-verify')) process.exit(0);
1139
1140
  // Advisory hook: never block, never surface an error to the tool call.
1140
1141
  main().catch(() => {}).finally(() => process.exit(0));
1141
1142
  }
@@ -37,8 +37,7 @@
37
37
  */
38
38
 
39
39
  import { shouldRunHook } from './_lib/profile-gate.mjs';
40
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
41
- if (!shouldRunHook('post-edit-import-probe')) process.exit(0);
40
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
42
41
 
43
42
  import path from 'node:path';
44
43
  import { readFileSync, existsSync } from 'node:fs';
@@ -335,10 +334,19 @@ async function main() {
335
334
  // Exit 0 always — a hook that guards against hook breakage must never block.
336
335
  // The catch stays silent towards the TOOL CALL but not towards the operator:
337
336
  // a probe whose own breakage is invisible is a probe nobody can trust.
338
- main()
339
- .catch((err) => {
340
- try {
341
- process.stderr.write(`post-edit-import-probe: ${err?.message ?? String(err)}\n`);
342
- } catch { /* stderr gone — nothing left to report to */ }
343
- })
344
- .finally(() => process.exit(0));
337
+ // Entry guard (#1393): run only when this file IS the script node was invoked
338
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
339
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
340
+ // tear the importing process down. The profile gate sits INSIDE the guard for
341
+ // that second reason: at module top level its `process.exit(0)` exited every
342
+ // process that merely imported this hook.
343
+ if (isMainModule(import.meta.url)) {
344
+ if (!shouldRunHook('post-edit-import-probe')) process.exit(0);
345
+ main()
346
+ .catch((err) => {
347
+ try {
348
+ process.stderr.write(`post-edit-import-probe: ${err?.message ?? String(err)}\n`);
349
+ } catch { /* stderr gone — nothing left to report to */ }
350
+ })
351
+ .finally(() => process.exit(0));
352
+ }
@@ -32,8 +32,7 @@ import { spawnSync } from 'node:child_process';
32
32
  import { performance } from 'node:perf_hooks';
33
33
 
34
34
  import { shouldRunHook } from './_lib/profile-gate.mjs';
35
- // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
36
- if (!shouldRunHook('post-edit-validate')) process.exit(0);
35
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
37
36
 
38
37
  import { readStdin } from '../scripts/lib/io.mjs';
39
38
  import { resolveProjectDir } from '../scripts/lib/platform.mjs';
@@ -220,6 +219,15 @@ async function main() {
220
219
  }
221
220
 
222
221
  // REQ-01: top-level catch — PostToolUse must never block; always exit 0
223
- main().catch(() => {
224
- process.exit(0);
225
- });
222
+ // Entry guard (#1393): run only when this file IS the script node was invoked
223
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
224
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
225
+ // tear the importing process down. The profile gate sits INSIDE the guard for
226
+ // that second reason: at module top level its `process.exit(0)` exited every
227
+ // process that merely imported this hook.
228
+ if (isMainModule(import.meta.url)) {
229
+ if (!shouldRunHook('post-edit-validate')) process.exit(0);
230
+ main().catch(() => {
231
+ process.exit(0);
232
+ });
233
+ }