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
@@ -42,8 +42,7 @@
42
42
  */
43
43
 
44
44
  import { shouldRunHook } from './_lib/profile-gate.mjs';
45
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
46
- if (!shouldRunHook('config-protection')) process.exit(0);
45
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
47
46
 
48
47
  import { promises as fs } from 'node:fs';
49
48
  import path from 'node:path';
@@ -512,4 +511,13 @@ async function main() {
512
511
  }
513
512
 
514
513
  // Fail-open on ANY uncaught error — a guard bug must never block a legit edit.
515
- main().catch(() => { try { emitAllow(); } catch { process.exit(0); } });
514
+ // Entry guard (#1393): run only when this file IS the script node was invoked
515
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
516
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
517
+ // tear the importing process down. The profile gate sits INSIDE the guard for
518
+ // that second reason: at module top level its `process.exit(0)` exited every
519
+ // process that merely imported this hook.
520
+ if (isMainModule(import.meta.url)) {
521
+ if (!shouldRunHook('config-protection')) process.exit(0);
522
+ main().catch(() => { try { emitAllow(); } catch { process.exit(0); } });
523
+ }
@@ -27,8 +27,7 @@
27
27
  import path from 'node:path';
28
28
 
29
29
  import { shouldRunHook } from './_lib/profile-gate.mjs';
30
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
31
- if (!shouldRunHook('cwd-change-restore')) process.exit(0);
30
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
32
31
 
33
32
  import { getProjectDir } from '../scripts/lib/platform.mjs';
34
33
  import { atomicMutateJson } from './_lib/atomic-json.mjs';
@@ -108,4 +107,13 @@ async function main() {
108
107
  }
109
108
 
110
109
  // Exit 0 always — informational hook must never block Claude.
111
- main().catch(() => {}).finally(() => process.exit(0));
110
+ // Entry guard (#1393): run only when this file IS the script node was invoked
111
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
112
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
113
+ // tear the importing process down. The profile gate sits INSIDE the guard for
114
+ // that second reason: at module top level its `process.exit(0)` exited every
115
+ // process that merely imported this hook.
116
+ if (isMainModule(import.meta.url)) {
117
+ if (!shouldRunHook('cwd-change-restore')) process.exit(0);
118
+ main().catch(() => {}).finally(() => process.exit(0));
119
+ }
@@ -31,9 +31,44 @@
31
31
  */
32
32
 
33
33
  import { shouldRunHook } from './_lib/profile-gate.mjs';
34
- // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
35
- if (!shouldRunHook('enforce-commands')) process.exit(0);
34
+ // Static for the SAME reason profile-gate.mjs is (#993, see the late-binding
35
+ // block below): a leaf predicate with ZERO repo imports (node:fs + node:url
36
+ // only) that decides whether this hook runs at all. Everything carrying a
37
+ // transitive repo graph stays late-bound inside bootstrap().
38
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
36
39
 
40
+ /**
41
+ * sha256(command), auf 16 Hex-Zeichen gekuerzt.
42
+ *
43
+ * WORTGLEICH zu `hashCommand()` in `hooks/pre-bash-destructive-guard.mjs:245`
44
+ * (das seinerseits `loop-guard.mjs` hashArgs() spiegelt). Bewusst dupliziert
45
+ * statt geteilt: ein Hook darf beim Start nicht an einem weiteren Modul
46
+ * haengen, das fehlen kann — die drei Zeilen sind billiger als ein
47
+ * Ladefehler auf dem PreToolUse-Pfad.
48
+ *
49
+ * WARUM ES DEN HELFER HIER BRAUCHT (2026-09-19, EventDrop #1140):
50
+ * Die `foreign_session_ignored`-Nutzlast dieses Hooks trug bis heute das
51
+ * ROHE Kommando. Gemessen in EventDrop.at: 3.816 Zeilen in der getrackten
52
+ * `.orchestrator/metrics/events.jsonl` mit rohem `command`, darin 24
53
+ * distinkte ECHTE Produktions-Share-Codes aus 17 fremden Kundenkonten und
54
+ * ein protokollierter `select access_pin_hash`. Bei 23 dieser Events ist
55
+ * der Share-Code die vollstaendige Capability — `/event/<code>` oeffnet das
56
+ * Album ohne Anmeldung. Das Journal ist getrackt und geht bei jedem Klon mit.
57
+ * Der Geschwisterhook `pre-bash-destructive-guard.mjs` machte es von Anfang
58
+ * an richtig und sagt es im eigenen Kopf: „Payload never includes the raw
59
+ * command — only a truncated sha256 command_hash."
60
+ *
61
+ * Der Hash haelt das Ereignis ZAEHLBAR und GRUPPIERBAR — genau die
62
+ * Eigenschaft, fuer die es laut `docs/scope-collision-guard.md` existiert.
63
+ * Das rohe Kommando wurde von keinem Konsumenten gelesen.
64
+ *
65
+ * @param {string} command
66
+ * @returns {string}
67
+ */
68
+ function hashCommand(command) {
69
+ return crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
70
+ }
71
+ import crypto from 'node:crypto';
37
72
  import path from 'node:path';
38
73
  import { pathToFileURL } from 'node:url';
39
74
 
@@ -298,7 +333,7 @@ async function main() {
298
333
  manifest_session: manifestIds,
299
334
  own_session: [...ownIds],
300
335
  wave: scope.wave,
301
- command,
336
+ command_hash: hashCommand(command),
302
337
  },
303
338
  { repoRoot: projectRoot },
304
339
  );
@@ -430,26 +465,38 @@ function targetInWaveScope(target, allowedPaths, projectRoot) {
430
465
  // guard armed and then tripped over a specific command; that fails CLOSED
431
466
  // via emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
432
467
  // ---------------------------------------------------------------------------
433
- try {
434
- await bootstrap();
435
- } catch (loadError) {
468
+ // Entry guard (#1393): run only when this file IS the script node was invoked
469
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
470
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
471
+ // tear the importing process down. The profile gate sits INSIDE the guard for
472
+ // that second reason: at module top level its `process.exit(0)` exited every
473
+ // process that merely imported this hook. bootstrap() is inside too — loading
474
+ // the guard sources is work a disabled hook and a bare importer must not do.
475
+ if (isMainModule(import.meta.url)) {
476
+ // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
477
+ if (!shouldRunHook('enforce-commands')) process.exit(0);
478
+
436
479
  try {
437
- const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
438
- // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
439
- emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
440
- } catch {
441
- // Last resort: even the banner helper failed to load. Emit unconditionally —
442
- // repeated noise beats a silent disarm.
443
- process.stderr.write(
444
- '🚨 enforce-commands: GUARD INACTIVE — module load failed ' +
445
- `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
446
- 'Blocked Bash commands are NOT being screened. See issue #993.\n'
447
- );
480
+ await bootstrap();
481
+ } catch (loadError) {
482
+ try {
483
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
484
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
485
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
486
+ } catch {
487
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
488
+ // repeated noise beats a silent disarm.
489
+ process.stderr.write(
490
+ '🚨 enforce-commands: GUARD INACTIVE — module load failed ' +
491
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
492
+ 'Blocked Bash commands are NOT being screened. See issue #993.\n'
493
+ );
494
+ }
495
+ process.exit(0); // fail-open, but no longer fail-silent
448
496
  }
449
- process.exit(0); // fail-open, but no longer fail-silent
450
- }
451
497
 
452
- // SECURITY-REQ-01 (F-03): top-level try/catch — never let exit 1 leak.
453
- main().catch((e) => {
454
- emitDeny('Internal hook error — request blocked for safety', `${e?.message || e}`);
455
- });
498
+ // SECURITY-REQ-01 (F-03): top-level try/catch — never let exit 1 leak.
499
+ main().catch((e) => {
500
+ emitDeny('Internal hook error — request blocked for safety', `${e?.message || e}`);
501
+ });
502
+ }
@@ -21,7 +21,10 @@
21
21
  * matches the fully realpath-resolved candidate → allow, BEFORE G6.
22
22
  * Runs before G6 so a deliberate out-of-repo grant (e.g. a vault path)
23
23
  * is reachable at all — G6 would otherwise deny every out-of-repo path
24
- * without ever consulting allowedPaths. See matchesAbsoluteAllowlist.
24
+ * without ever consulting allowedPaths. See matchedAbsoluteGrant.
25
+ * (#1398 cond. 4) The matched grant is GRADED through the shared
26
+ * `gradeScopeEntry` predicate; an `error` verdict emits ONE WARN and the
27
+ * write is still ALLOWED — this gate never denies on a grading verdict.
25
28
  * G5c (#1295) out-of-root carveout for THIS repo's Claude Code auto-memory
26
29
  * directory (`~/.claude/projects/<encoded-repo-path>/memory/`). Harness-
27
30
  * owned, lives outside the working copy, cannot collide with any wave
@@ -68,8 +71,11 @@ import { promises as fs } from 'node:fs';
68
71
  import { pathToFileURL } from 'node:url';
69
72
 
70
73
  import { shouldRunHook } from './_lib/profile-gate.mjs';
71
- // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
72
- if (!shouldRunHook('enforce-scope')) process.exit(0);
74
+ // Static for the SAME reason profile-gate.mjs is (#993, see the late-binding
75
+ // block below): a leaf predicate with ZERO repo imports (node:fs + node:url
76
+ // only) that decides whether this hook runs at all. Everything carrying a
77
+ // transitive repo graph stays late-bound inside bootstrap().
78
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
73
79
 
74
80
  // ---------------------------------------------------------------------------
75
81
  // #993 — late-bound repo dependencies
@@ -109,6 +115,12 @@ let readJson;
109
115
  let classifyEmptyScope;
110
116
  let suggestForEmptyScope;
111
117
  let sessionStartedAtMs;
118
+ // #1398 cond. 4 — THE shared grading predicate for an absolute Gate 5b grant,
119
+ // plus the resolver it takes. The hook WARNS on an `error` verdict and never
120
+ // denies on it; see Gate 5b. Both come from the module already bound below —
121
+ // no new module edge, so `hooks/_lib/hook-import-set.json` is unchanged.
122
+ let gradeScopeEntry;
123
+ let canonicalizeGrantPrefix;
112
124
  // #1123 — "is this manifest even mine?" (G3b). Process-local identity only
113
125
  // (#1194): the repo-global `session.lock` tier is shared by every session in the
114
126
  // checkout and would classify a peer's manifest as ours.
@@ -188,7 +200,13 @@ async function bootstrap() {
188
200
  ({ resolveProjectDir } = modules.platform);
189
201
  ({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
190
202
  ({ readJson } = modules.common);
191
- ({ classifyEmptyScope, suggestForEmptyScope, sessionStartedAtMs } = modules.scopeGate);
203
+ ({
204
+ classifyEmptyScope,
205
+ suggestForEmptyScope,
206
+ sessionStartedAtMs,
207
+ gradeScopeEntry,
208
+ canonicalizeGrantPrefix,
209
+ } = modules.scopeGate);
192
210
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
193
211
  }
194
212
 
@@ -433,7 +451,52 @@ async function main() {
433
451
  // out-of-repo path before allowedPaths is ever consulted. Honour such grants
434
452
  // here — matching ONLY absolute entries against the fully realpath-resolved
435
453
  // candidate, so relative entries can never be used to escape the repo (REQ-09).
436
- if (matchesAbsoluteAllowlist(resolvedPath, allowedPaths)) return emitAllow();
454
+ //
455
+ // GRADING (#1398 acceptance condition 4): this gate ALLOWS exactly what it
456
+ // allowed before — the verdict below is a WARN, never a deny. Until now the
457
+ // asymmetry was silent: `scripts/validate-wave-scope.mjs` graded these grants
458
+ // (system-root denylist, home depth + sensitive-subdirectory rule) and NOTHING
459
+ // called it from code — four prose steps in `skills/wave-executor/` were the
460
+ // only thing between a manifest and this gate, so a manifest that skipped them
461
+ // reached here ungraded and the operator never learned that it had.
462
+ //
463
+ // Now the same predicate runs HERE, on the grant that actually matched, and an
464
+ // `error` verdict surfaces as one operator-visible notice. Deliberately not a
465
+ // deny: this hook runs in every repo on the host, the grant is the
466
+ // coordinator's own artefact, and turning a live allow into a block on a
467
+ // pre-flight rule would break working sessions to enforce a policy whose place
468
+ // is before dispatch. `emitWarn` is an ALLOW that carries a notice (exit 0,
469
+ // `systemMessage` only) — see `scripts/lib/io.mjs`.
470
+ //
471
+ // THE SAME `resolve` THE VALIDATOR PASSES (#1398 cond. 4, closed 2026-09-21).
472
+ // It was omitted here on the assumption that canonicalisation is too expensive
473
+ // for a PreToolUse hot path; measuring it refuted that, and the omission was
474
+ // the last source of a verdict divergence between the two callers of one
475
+ // predicate (`/tmp/x/**`: `warn` here, `error/non-canonical` there — silent on
476
+ // exactly the grant that matches nothing at this gate).
477
+ //
478
+ // MEASURED 2026-09-21, A/B in one process, 200 repetitions, median:
479
+ // - this call site grades ONE grant per Gate 5b hit, never the manifest:
480
+ // +0.067 ms, 15 `realpathSync` — 75× under the 5 ms decision threshold;
481
+ // - a Gate 5b hit requires an out-of-repo write AND an absolute grant that
482
+ // matches it. The live 64-entry manifest of that session carried 0
483
+ // absolute entries and therefore cost 0 syscalls: an ordinary in-repo
484
+ // write never reaches this line at all.
485
+ // `canonicalizeGrantPrefix` memoizes per process and never throws (see its
486
+ // docblock); `gradeScopeEntry` additionally degrades a throwing resolver to
487
+ // the literal spelling, so the worst case is today's verdict.
488
+ const matchedGrant = matchedAbsoluteGrant(resolvedPath, allowedPaths);
489
+ if (matchedGrant !== null) {
490
+ const grade = gradeScopeEntry(matchedGrant, { resolve: canonicalizeGrantPrefix });
491
+ if (grade?.verdict === 'error') {
492
+ return emitWarn(
493
+ `Gate 5b honoured an out-of-repo grant that scripts/validate-wave-scope.mjs would REFUSE ` +
494
+ `before dispatch — allowedPaths ${grade.message}. The write to '${resolvedPath}' is ` +
495
+ `ALLOWED (this gate never denies on a grading verdict); re-validate the manifest.`,
496
+ );
497
+ }
498
+ return emitAllow();
499
+ }
437
500
 
438
501
  // Gate 6: path must be inside the project root
439
502
  if (!isPathInside(resolvedPath, projectRoot)) {
@@ -774,19 +837,54 @@ function isCoordinatorCarveout(normalizedRel, projectRoot, scopePath) {
774
837
  * SECURITY (REQ-09): matches ONLY entries that are themselves absolute, against
775
838
  * the fully realpath-resolved candidate — so RELATIVE entries (`**`, `../**`)
776
839
  * can NEVER be used to escape the repo. When no absolute entry exists the helper
777
- * returns false and the caller falls through to Gate 6 unchanged (inert pre-gate).
840
+ * returns null and the caller falls through to Gate 6 unchanged (inert pre-gate).
778
841
  * An absolute entry matches only its own literal (canonical/realpath) subtree;
779
842
  * the operator is responsible for supplying a canonical absolute path.
780
843
  *
844
+ * MATCHING IS SHAPE-BLIND HERE; GRADING IS SHARED (#1398 cond. 4). This helper
845
+ * still decides nothing about whether a grant SHOULD exist: `/Users/<u>/**` and
846
+ * `/Users/<u>/.ssh/**` match exactly like `/Users/<u>/Projects/vault/**`. What
847
+ * changed is that the CALLER now runs the ONE grading predicate —
848
+ * `gradeScopeEntry` in `scripts/lib/scope-gate.mjs`, the same function
849
+ * `scripts/validate-wave-scope.mjs` turns into an exit-1 refusal — over the grant
850
+ * this helper returns, and WARNS when it grades `error`.
851
+ *
852
+ * ONE PREDICATE, ONE ARGUMENT SHAPE — THE VERDICTS NO LONGER DIFFER (#1398
853
+ * cond. 4, closed 2026-09-21). Both callers now pass the SAME fs-backed
854
+ * `resolve` (`canonicalizeGrantPrefix`, exported from
855
+ * `scripts/lib/scope-gate.mjs`), so only the CONSEQUENCE is asymmetric: the
856
+ * validator refuses an `error` before dispatch, this gate allows it with a
857
+ * notice at write time. The history, because the asymmetry was load-bearing for
858
+ * two fixes, measured 2026-09-20 @ 7e110a2a with the resolver-free call:
859
+ * - `/private/etc/**`, `/private/var/**` — the macOS realpaths of the
860
+ * denylisted `/etc` and `/var`, i.e. the spellings Gate 5b ACTUALLY matches
861
+ * (it matches the realpath-resolved candidate): hook `warn`, validator
862
+ * `error`. The notice fired on `/etc/**`, which reaches nothing here, and
863
+ * stayed silent on the spelling that reaches everything. CLOSED by listing
864
+ * the two aliases literally (`DENIED_ABSOLUTE_ALIAS_ROOTS`), at zero
865
+ * syscalls — they still cost nothing and stay.
866
+ * - `/tmp/x/**` — hook `warn`, validator `error/non-canonical`: the last
867
+ * divergence, 1 of 9 probes, and not closable by any literal list, because
868
+ * proving a literal prefix resolves elsewhere IS the realpath call. CLOSED
869
+ * by passing the resolver here too, once its cost was measured instead of
870
+ * assumed (+0.067 ms per Gate 5b hit; see the call site).
871
+ * The parity of the two verdict tables is pinned by
872
+ * `tests/lib/scope-gate.test.mjs` § "hook / validator grading parity", so a NEW
873
+ * divergence is a red test rather than a comment someone has to notice.
874
+ *
875
+ * Returns the MATCHED PATTERN rather than a boolean precisely so the caller has
876
+ * something to grade: with a bare `true` the grant that opened the gate is
877
+ * unknowable at the call site, and any notice would have to re-derive it.
878
+ *
781
879
  * @param {string} resolvedPath — fully realpath-resolved candidate (absolute)
782
880
  * @param {string[]} allowedPaths — raw allowedPaths array from wave-scope.json
783
- * @returns {boolean}
881
+ * @returns {string|null} the first matching absolute entry, or null
784
882
  */
785
- function matchesAbsoluteAllowlist(resolvedPath, allowedPaths) {
883
+ function matchedAbsoluteGrant(resolvedPath, allowedPaths) {
786
884
  const abs = allowedPaths.filter((p) => typeof p === 'string' && path.isAbsolute(p));
787
- if (abs.length === 0) return false;
885
+ if (abs.length === 0) return null;
788
886
  const normalizedAbs = resolvedPath.split(path.sep).join('/');
789
- return abs.some((pat) => pathMatchesPattern(normalizedAbs, pat.split(path.sep).join('/')));
887
+ return abs.find((pat) => pathMatchesPattern(normalizedAbs, pat.split(path.sep).join('/'))) ?? null;
790
888
  }
791
889
 
792
890
  // ---------------------------------------------------------------------------
@@ -804,29 +902,41 @@ function matchesAbsoluteAllowlist(resolvedPath, allowedPaths) {
804
902
  // guard armed and then tripped over a specific path; that fails CLOSED via
805
903
  // emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
806
904
  // ---------------------------------------------------------------------------
807
- try {
808
- await bootstrap();
809
- } catch (loadError) {
905
+ // Entry guard (#1393): run only when this file IS the script node was invoked
906
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
907
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
908
+ // tear the importing process down. The profile gate sits INSIDE the guard for
909
+ // that second reason: at module top level its `process.exit(0)` exited every
910
+ // process that merely imported this hook. bootstrap() is inside too — loading
911
+ // the guard sources is work a disabled hook and a bare importer must not do.
912
+ if (isMainModule(import.meta.url)) {
913
+ // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
914
+ if (!shouldRunHook('enforce-scope')) process.exit(0);
915
+
810
916
  try {
811
- const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
812
- // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
813
- emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
814
- } catch {
815
- // Last resort: even the banner helper failed to load. Emit unconditionally —
816
- // repeated noise beats a silent disarm.
817
- process.stderr.write(
818
- '🚨 enforce-scope: GUARD INACTIVE — module load failed ' +
819
- `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
820
- 'Edit/Write/MultiEdit scope enforcement is OFF. See issue #993.\n'
821
- );
917
+ await bootstrap();
918
+ } catch (loadError) {
919
+ try {
920
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
921
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
922
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
923
+ } catch {
924
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
925
+ // repeated noise beats a silent disarm.
926
+ process.stderr.write(
927
+ '🚨 enforce-scope: GUARD INACTIVE — module load failed ' +
928
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
929
+ 'Edit/Write/MultiEdit scope enforcement is OFF. See issue #993.\n'
930
+ );
931
+ }
932
+ process.exit(0); // fail-open, but no longer fail-silent
822
933
  }
823
- process.exit(0); // fail-open, but no longer fail-silent
824
- }
825
934
 
826
- // SECURITY-REQ-01 (fail-closed): any unhandled rejection → structured deny, never bare exit 1
827
- main().catch((e) => {
828
- emitDeny(
829
- 'Internal hook error — request blocked for safety',
830
- `${e?.message ?? String(e)}`,
831
- );
832
- });
935
+ // SECURITY-REQ-01 (fail-closed): any unhandled rejection → structured deny, never bare exit 1
936
+ main().catch((e) => {
937
+ emitDeny(
938
+ 'Internal hook error — request blocked for safety',
939
+ `${e?.message ?? String(e)}`,
940
+ );
941
+ });
942
+ }
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.2.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.3.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v5.2.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.3.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -25,8 +25,7 @@
25
25
  */
26
26
 
27
27
  import { shouldRunHook } from './_lib/profile-gate.mjs';
28
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
29
- if (!shouldRunHook('loop-guard')) process.exit(0);
28
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
30
29
 
31
30
  import { promises as fs } from 'node:fs';
32
31
  import os from 'node:os';
@@ -257,4 +256,13 @@ async function main() {
257
256
  }
258
257
 
259
258
  // Exit 0 always — informational hook must never block Claude (#619).
260
- main().catch(() => {}).finally(() => process.exit(0));
259
+ // Entry guard (#1393): run only when this file IS the script node was invoked
260
+ // with — every harness path execs it (`sh run-node.sh <this file>`). A bare
261
+ // `import()` (a probe, a test, a curious agent) must neither run main() nor
262
+ // tear the importing process down. The profile gate sits INSIDE the guard for
263
+ // that second reason: at module top level its `process.exit(0)` exited every
264
+ // process that merely imported this hook.
265
+ if (isMainModule(import.meta.url)) {
266
+ if (!shouldRunHook('loop-guard')) process.exit(0);
267
+ main().catch(() => {}).finally(() => process.exit(0));
268
+ }
@@ -31,8 +31,6 @@ import { promises as fs } from 'node:fs';
31
31
  import path from 'node:path';
32
32
 
33
33
  import { shouldRunHook } from './_lib/profile-gate.mjs';
34
- // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
35
- if (!shouldRunHook('on-session-end')) process.exit(0);
36
34
 
37
35
  import { emitEvent } from '../scripts/lib/events.mjs';
38
36
  import { getProjectDir } from '../scripts/lib/platform.mjs';
@@ -47,6 +45,7 @@ import {
47
45
  OWNER_PROOF_RELPATH,
48
46
  } from '../scripts/lib/session-lock.mjs';
49
47
  import { parseSessionId } from '../scripts/lib/session-id.mjs';
48
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
50
49
  import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
51
50
  import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
52
51
  import { flush } from '../scripts/lib/telemetry/sync.mjs';
@@ -255,21 +254,44 @@ async function readPersistence(projectRoot) {
255
254
  }
256
255
 
257
256
  /**
258
- * Reduce a `flush()` result to the two-field breadcrumb the event carries.
259
- *
260
- * `reason` is normalised to its head token because `flush()` may return
261
- * `build-error: <message>`, and a raw error message is unbounded free text in a
262
- * stream whose whole purpose is aggregation by class.
257
+ * Characters kept from `flush()`'s raw `reason` in the event record.
258
+ *
259
+ * NAMED CEILING (BV-004): `flush()` may return `build-error: <message>`, i.e.
260
+ * unbounded free text, into a stream whose purpose is aggregation. 200 mirrors
261
+ * the sibling bound in `scripts/lib/session-start-probes.mjs`; the banner
262
+ * re-bounds to 120 and strips control bytes on its own side, because the ledger
263
+ * is an untrusted string source for anything that renders it.
264
+ * REVISIT TRIGGER: a legitimate `flush()` reason longer than 200 characters.
265
+ */
266
+ const FLUSH_REASON_MAX_CHARS = 200;
267
+
268
+ /**
269
+ * Reduce a `flush()` result to the three-field breadcrumb the event carries.
270
+ *
271
+ * `reason` is the FULL reason (length-bounded, see FLUSH_REASON_MAX_CHARS) and
272
+ * `reason_class` is its head token — the part before the first `:` — so an
273
+ * aggregation by class stays possible without destroying the detail.
274
+ *
275
+ * #1392, the defect this shape replaces: `reason` used to BE the head token
276
+ * (`String(res?.reason ?? 'unknown').split(':')[0]`). All five sandbox
277
+ * refusals `scripts/lib/telemetry/sync.mjs` produces (`sandbox:temp-root`,
278
+ * `sandbox:probe-failed`, …) were therefore written as bare `sandbox`, while
279
+ * the one reader — `scripts/lib/telemetry-flush-health-banner.mjs` — requires
280
+ * `reason.startsWith('sandbox:')`. The banner could not fire on any record this
281
+ * emitter ever wrote, and a mute instrument looks exactly like a healthy
282
+ * channel (HR-105). The cut is irrecoverable at the reader, so the fix belongs
283
+ * here: persist the full reason, carry the class beside it.
263
284
  *
264
285
  * @param {{sent?: boolean, queued?: boolean, reason?: string}|null|undefined} res
265
- * @returns {{outcome: 'sent'|'queued'|'gated'|'skipped', reason: string}}
286
+ * @returns {{outcome: 'sent'|'queued'|'gated'|'skipped', reason: string, reason_class: string}}
266
287
  */
267
- function classifyFlush(res) {
268
- const reason = String(res?.reason ?? 'unknown').split(':')[0];
269
- if (res?.sent === true) return { outcome: 'sent', reason };
270
- if (res?.queued === true) return { outcome: 'queued', reason };
271
- if (reason === 'gated') return { outcome: 'gated', reason };
272
- return { outcome: 'skipped', reason };
288
+ export function classifyFlush(res) {
289
+ const reason = String(res?.reason ?? 'unknown').slice(0, FLUSH_REASON_MAX_CHARS);
290
+ const reasonClass = reason.split(':')[0];
291
+ if (res?.sent === true) return { outcome: 'sent', reason, reason_class: reasonClass };
292
+ if (res?.queued === true) return { outcome: 'queued', reason, reason_class: reasonClass };
293
+ if (reasonClass === 'gated') return { outcome: 'gated', reason, reason_class: reasonClass };
294
+ return { outcome: 'skipped', reason, reason_class: reasonClass };
273
295
  }
274
296
 
275
297
  /**
@@ -287,8 +309,9 @@ function classifyFlush(res) {
287
309
  * statement) — this function deliberately does not re-implement it, so there is
288
310
  * exactly one place where "may we send?" is decided.
289
311
  *
290
- * Always emits `orchestrator.telemetry.flush` with `{ outcome, reason }` and
291
- * NOTHING else — no payload, no anon_id. Per `.claude/rules/host-resources.md`
312
+ * Always emits `orchestrator.telemetry.flush` with `{ outcome, reason,
313
+ * reason_class }` and NOTHING else — no payload, no anon_id. Per
314
+ * `.claude/rules/host-resources.md`
292
315
  * HR-105, a mechanism whose firing rate nothing records cannot be falsified;
293
316
  * this event is what makes the flush rate measurable next time.
294
317
  *
@@ -305,13 +328,17 @@ async function flushTelemetry(projectRoot) {
305
328
  }))
306
329
  // `persistence: false` means this session leaves no durable local trace;
307
330
  // a telemetry ping is a durable record too, so it honours the same switch.
308
- : { outcome: 'skipped', reason: 'persistence-disabled' };
331
+ // `reason_class` is spelled out on BOTH non-flush() branches so every
332
+ // record of this event carries the same three keys — a consumer grouping
333
+ // by class must not have to treat "absent" as a fourth class.
334
+ : { outcome: 'skipped', reason: 'persistence-disabled', reason_class: 'persistence-disabled' };
309
335
  } catch {
310
- result = { outcome: 'skipped', reason: 'error' };
336
+ result = { outcome: 'skipped', reason: 'error', reason_class: 'error' };
311
337
  }
312
338
 
313
- // `result` is passed through verbatim: it holds EXACTLY {outcome, reason}, so
314
- // no payload field can leak into the event by accident.
339
+ // `result` is passed through verbatim: it holds EXACTLY
340
+ // {outcome, reason, reason_class}, so no payload field can leak into the
341
+ // event by accident.
315
342
  try {
316
343
  await emitEvent('orchestrator.telemetry.flush', result);
317
344
  } catch { /* observability is best-effort */ }
@@ -830,6 +857,14 @@ async function main() {
830
857
  }
831
858
 
832
859
  // Exit 0 always — informational hook must never block session teardown.
833
- main()
834
- .catch(() => {})
835
- .finally(() => process.exit(0));
860
+ // Entry guard (#1298 P7): run only as the node script every harness execs
861
+ // (`sh run-node.sh <this file>`); a bare `import()` must not tear down the
862
+ // live repo's session state.
863
+ if (isMainModule(import.meta.url)) {
864
+ // #1393: the profile gate sits INSIDE the entry guard — at module top level
865
+ // its `process.exit(0)` exited every process that merely imported this hook.
866
+ if (!shouldRunHook('on-session-end')) process.exit(0);
867
+ main()
868
+ .catch(() => {})
869
+ .finally(() => process.exit(0));
870
+ }