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
@@ -22,7 +22,24 @@
22
22
  * `findViolations()` — the whole matcher (claim patterns, negative-context
23
23
  * guards, evidence proximity, normalisation, dedup) lives there so it can
24
24
  * be measured against a claim corpus without spawning this hook. It
25
- * returns DEDUPLICATED `{claim, normalized, occurrences}` records.
25
+ * returns DEDUPLICATED `{claim, normalized, occurrences, kind, detail?}`
26
+ * records in THREE classes: `distributional` (#567/#908 — "4 of 4
27
+ * callers"), `gate-verdict` (w4-1 — "STATUS: done" / "alles grün" with
28
+ * no run receipt anywhere in the report), and `claim-mismatch` (#1385 R1
29
+ * — a claimed test count that NO observed vitest run carries). `kind`
30
+ * reaches both the ledger record and the WARN; its ABSENCE on a record
31
+ * means `distributional`, since no pre-w4-1 record could be anything
32
+ * else.
33
+ * 5b. The `claim-mismatch` class needs the OTHER side of the transcript, so
34
+ * `readTranscriptObservations()` reads the vitest summaries out of the
35
+ * `tool_result` blocks in the same window and passes them in. It closes
36
+ * an INVERSION: `RUN_RECEIPT_RE` is report-wide and matches
37
+ * `\d+\s+passed`, so before R1 an INVENTED number was its own receipt
38
+ * while an honest report with no number was flagged (measured
39
+ * 2026-09-19: `'STATUS: done\nTests pass: 5129 passed / 0 failed.'` → no
40
+ * violation; `'STATUS: done\nAlles grün.'` → two). No observations in
41
+ * the window ⇒ no `claim-mismatch` finding: evidence ABSENCE is
42
+ * `gate-verdict`'s job.
26
43
  * 6. Attribute the claim: `agent` + `agent_source` (`payload`|`meta`|`none`)
27
44
  * + `agent_description`, and — on `none` — the sorted stdin `payload_keys`
28
45
  * the harness DID send, so a gap is diagnosable from the ledger.
@@ -62,8 +79,7 @@
62
79
  */
63
80
 
64
81
  import { shouldRunHook } from './_lib/profile-gate.mjs';
65
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
66
- if (!shouldRunHook('post-subagent-discovery-validator')) process.exit(0);
82
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
67
83
 
68
84
  import { createHash } from 'node:crypto';
69
85
  import { promises as fs } from 'node:fs';
@@ -71,7 +87,11 @@ import { tmpdir } from 'node:os';
71
87
  import path from 'node:path';
72
88
 
73
89
  import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
74
- import { findViolations, readTranscriptTail } from './_lib/subagent-transcript.mjs';
90
+ import {
91
+ findViolations,
92
+ readTranscriptObservations,
93
+ readTranscriptTail,
94
+ } from './_lib/subagent-transcript.mjs';
75
95
  import { appendJsonl } from '../scripts/lib/common.mjs';
76
96
  import { eventsFilePath } from '../scripts/lib/events.mjs';
77
97
  import { getProjectDir } from '../scripts/lib/platform.mjs';
@@ -393,7 +413,11 @@ async function main() {
393
413
  if (sidecar === null) return;
394
414
 
395
415
  const text = await readTranscriptTail(sidecar.transcript);
396
- const { violations, undatedVerified } = findViolations(text);
416
+ // The `tool_result` side of the SAME window (#1385 R1) — what the agent
417
+ // actually RAN, against which its claimed test counts are compared. An
418
+ // empty list disables the `claim-mismatch` class; it never creates one.
419
+ const observations = await readTranscriptObservations(sidecar.transcript);
420
+ const { violations, undatedVerified } = findViolations(text, { observations });
397
421
  if (violations.length === 0) return;
398
422
 
399
423
  const attribution = await resolveAgentAttribution(input, sidecar.meta);
@@ -443,7 +467,15 @@ async function main() {
443
467
  }
444
468
  if (seen) continue;
445
469
  }
446
- await appendJsonl(filePath, {
470
+ // AWAITED **and** CAUGHT. A telemetry write added to a guard silently
471
+ // disarms it unless both hold: without the await the process can exit
472
+ // before the append lands, and without the catch one failed write aborts
473
+ // the remaining violations AND the stderr WARN below — the coordinator's
474
+ // only copy of the finding. This hook never denies, so the failure is a
475
+ // lost finding rather than a lost block; it is still a loss, and a full
476
+ // disk must not be able to silence the scanner.
477
+ try {
478
+ await appendJsonl(filePath, {
447
479
  event: 'discovery_validator_violation',
448
480
  timestamp: new Date().toISOString(),
449
481
  agent,
@@ -457,8 +489,25 @@ async function main() {
457
489
  ...(sessionId !== null ? { session_id: sessionId } : {}),
458
490
  claim_text: violation.claim,
459
491
  occurrences: violation.occurrences,
460
- });
461
- written++;
492
+ // Claim CLASS (w4-1, #1385 R1): `distributional` (the #567/#908
493
+ // patterns), `gate-verdict` (a done/green assertion with no run
494
+ // receipt), or `claim-mismatch` (a test count no observed run carries).
495
+ // A new field rather than an overloaded old one — a ledger reader
496
+ // triaging 3,000 records must be able to separate them without
497
+ // re-parsing `claim_text`, and every pre-w4-1 record is
498
+ // `distributional` by construction (the field's absence means that).
499
+ kind: violation.kind ?? 'distributional',
500
+ // `claim-mismatch` only: `{mismatch, claimed, observed, observed_n}`.
501
+ // Numbers, never the command text that produced them (precedent
502
+ // `8f15f77b` — `command_hash` instead of the raw command); `observed`
503
+ // is capped at the last 3 runs by findViolations().
504
+ ...(violation.detail ?? {}),
505
+ });
506
+ written++;
507
+ } catch {
508
+ // Write failed — keep going. The WARN below still reaches the
509
+ // coordinator, which is the channel PSA-006 actually depends on.
510
+ }
462
511
  }
463
512
 
464
513
  // Advisory only (#908 item 4) — never promoted to a violation in v1.
@@ -473,10 +522,39 @@ async function main() {
473
522
  ? ` ${violations.length - written} already recorded earlier in this session.`
474
523
  : '';
475
524
 
525
+ // THREE claim CLASSES, counted separately in the WARN (w4-1, #1385 R1):
526
+ // they fail different rules and want different corrections — a
527
+ // distributional claim needs its grep transcript, a gate verdict needs a
528
+ // run receipt, and a count mismatch needs the run repeated or the number
529
+ // corrected. One aggregate count would leave the coordinator guessing which.
530
+ const gateCount = violations.filter((v) => v.kind === 'gate-verdict').length;
531
+ const mismatchCount = violations.filter((v) => v.kind === 'claim-mismatch').length;
532
+ const distCount = violations.length - gateCount - mismatchCount;
533
+ const classNote = gateCount === 0
534
+ ? ''
535
+ : ` ${gateCount} of them ${gateCount === 1 ? 'is a' : 'are'} DONE/GATE verdict(s) with no run ` +
536
+ `receipt (no test count, no exit code) anywhere in the report; ` +
537
+ `${distCount} repo-state/distributional.`;
538
+
539
+ // The correction this class asks for is named, because it is NOT the one
540
+ // the other two ask for: no amount of added grep evidence fixes a number
541
+ // that contradicts every run in the window.
542
+ const mismatchNote = mismatchCount === 0
543
+ ? ''
544
+ : ` ${mismatchCount} ${mismatchCount === 1 ? 'is a' : 'are'} COUNT MISMATCH: ` +
545
+ violations
546
+ .filter((v) => v.kind === 'claim-mismatch')
547
+ .map((v) => {
548
+ const observed = (v.detail?.observed ?? []).map((o) => `${o.passed} passed`).join(', ');
549
+ return `claimed ${v.detail?.claimed?.passed} passed, observed ${observed || 'none'}`;
550
+ })
551
+ .join('; ') +
552
+ ` — re-run the suite or correct the number.`;
553
+
476
554
  const warnText =
477
- `⚠ PSA-006: ${violations.length} distinct repo-state/distributional claim(s) from agent ` +
478
- `"${agent}" (source: ${attribution.source}) lack an adjacent measurement transcript ` +
479
- `(grep/rg/find/git/wc/jq/ls/node/npm) (non-blocking).` +
555
+ `⚠ PSA-006: ${violations.length} distinct claim(s) from agent ` +
556
+ `"${agent}" (source: ${attribution.source}) lack measurement evidence ` +
557
+ `(grep/rg/find/git/wc/jq/ls/node/npm) (non-blocking).${classNote}${mismatchNote}` +
480
558
  `${suppressedNote}${undatedNote} ` +
481
559
  `See .claude/rules/parallel-sessions.md § PSA-006.`;
482
560
  process.stderr.write(warnText + '\n');
@@ -526,5 +604,12 @@ async function main() {
526
604
  }));
527
605
  }
528
606
 
529
- // Exit 0 always informational hook must never block Claude (#567 v1).
530
- main().catch(() => {}).finally(() => process.exit(0));
607
+ // Entry guard (#1393): run only as the node script the harness execs — a bare
608
+ // `import()` must run no handler and must not exit the importing process.
609
+ if (isMainModule(import.meta.url)) {
610
+ // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
611
+ if (!shouldRunHook('post-subagent-discovery-validator')) process.exit(0);
612
+
613
+ // Exit 0 always — informational hook must never block Claude (#567 v1).
614
+ main().catch(() => {}).finally(() => process.exit(0));
615
+ }
@@ -66,18 +66,21 @@
66
66
  * hooks.json wiring is managed separately (W3-C4 scope).
67
67
  */
68
68
 
69
- import { execFileSync } from 'node:child_process';
69
+ import { execFileSync, spawn } from 'node:child_process';
70
+ import { readFileSync } from 'node:fs';
70
71
  import { readFile } from 'node:fs/promises';
71
72
  import path from 'node:path';
73
+ import { performance } from 'node:perf_hooks';
74
+ import { fileURLToPath } from 'node:url';
72
75
 
73
76
  import { shouldRunHook } from './_lib/profile-gate.mjs';
74
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
75
- if (!shouldRunHook('post-tool-batch-wave-signal')) process.exit(0);
77
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
76
78
 
77
79
  import { getProjectDir } from '../scripts/lib/platform.mjs';
78
80
  import { emitEvent } from '../scripts/lib/events.mjs';
79
81
  import { findScopeFile } from '../scripts/lib/scope-gate.mjs';
80
82
  import { atomicMutateJson } from './_lib/atomic-json.mjs';
83
+ import { _parseReaper } from '../scripts/lib/config/reaper.mjs';
81
84
 
82
85
  // ---------------------------------------------------------------------------
83
86
  // Helpers
@@ -205,7 +208,7 @@ async function ownsSessionFile(input, sessionFile) {
205
208
  * previous hard-coded `.claude/` path made this hook's whole wave-lifecycle
206
209
  * fallback structurally dead on Codex CLI, Cursor and pi: the manifest exists,
207
210
  * it just is not under `.claude/`, so every batch read 0 and no
208
- * `orchestrator.wave.started`/`completed` event was ever emitted there.
211
+ * wave-lifecycle event was ever emitted there.
209
212
  *
210
213
  * @param {string} projectDir
211
214
  * @returns {Promise<number>}
@@ -305,6 +308,145 @@ function countFilesChangedSince(projectDir, sha) {
305
308
  }
306
309
  }
307
310
 
311
+ // ---------------------------------------------------------------------------
312
+ // orphan-reaper trigger (#1432 B4)
313
+ // ---------------------------------------------------------------------------
314
+ //
315
+ // DUPLICATED VERBATIM between the two trigger hooks —
316
+ // hooks/post-tool-batch-wave-signal.mjs <-> hooks/on-stop.mjs. Deliberate, with
317
+ // a named revisit trigger (BV-004): they are the only two trigger points the
318
+ // PRD declares, and a shared `hooks/_lib/reaper-trigger.mjs` would be a third
319
+ // file in the hook import graph. SIZE, measured 2026-09-22 by diffing the two
320
+ // blocks: 138 identical lines — this note said "~40 lines of glue" until then,
321
+ // which is what made the duplicate look cheaper than it is. Extract it the
322
+ // moment a THIRD hook needs the trigger, or the moment the copies must differ.
323
+
324
+ /**
325
+ * Filesystem path of the scan CLI, spawned as a PLAIN argv call.
326
+ *
327
+ * It used to be a `file://` URL handed to `node --input-type=module -e
328
+ * <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
329
+ * has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
330
+ * contract lives in that module instead of as source text duplicated here —
331
+ * and nothing this hook builds is a program any more: every variable part is
332
+ * an argv value, which cannot become code whatever the checkout path contains.
333
+ *
334
+ * `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
335
+ * percent-encoded path for any checkout directory containing a space.
336
+ */
337
+ const ORPHAN_REAPER_SCRIPT = fileURLToPath(
338
+ new URL('../scripts/lib/orphan-reaper.mjs', import.meta.url),
339
+ );
340
+
341
+ /**
342
+ * Read the `reaper:` block from the project's Session Config host file.
343
+ * Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
344
+ * path must not import the full config orchestrator. A missing or unreadable
345
+ * file yields the parser defaults, i.e. DISABLED.
346
+ *
347
+ * @param {string} projectDir
348
+ * @returns {ReturnType<typeof _parseReaper>}
349
+ */
350
+ function loadReaperConfig(projectDir) {
351
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
352
+ try {
353
+ return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
354
+ } catch {
355
+ // missing or unreadable — try the next candidate
356
+ }
357
+ }
358
+ return _parseReaper('');
359
+ }
360
+
361
+ /**
362
+ * Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
363
+ *
364
+ * The hook itself does exactly two pieces of I/O — one config read and one
365
+ * `stat` of the throttle marker — and then hands the work to a DETACHED,
366
+ * unref'd child. The scan is never run inline: one `ps` round-trip out of Node
367
+ * was measured at ~47 ms over 287 KB of output, which alone would blow the
368
+ * 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
369
+ *
370
+ * The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
371
+ * the throttle window — otherwise a broken spawn would be retried on every
372
+ * single tool batch.
373
+ *
374
+ * `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
375
+ * with `performance.now()` and reported as ONE stderr WARN line when exceeded.
376
+ * A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
377
+ * per-fire telemetry record is exactly the always-on signal
378
+ * `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
379
+ * measurement covers preparation only — the scan itself runs detached, which is
380
+ * the whole reason the budget can be held.
381
+ *
382
+ * Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
383
+ *
384
+ * @param {object} [opts]
385
+ * @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
386
+ * @param {number} [opts.now] Injected clock (ms).
387
+ * @param {Function} [opts.spawnFn] Injected `spawn` (tests).
388
+ * @param {Function} [opts.statFn] Injected `statSync` (tests).
389
+ * @param {Function} [opts.writeFn] Injected marker writer (tests).
390
+ * @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
391
+ * budget (tests); defaults to `performance.now`.
392
+ * @returns {Promise<{spawned: boolean, reason: string}>}
393
+ */
394
+ export async function maybeTriggerOrphanScan({
395
+ projectDir,
396
+ now,
397
+ spawnFn = spawn,
398
+ statFn,
399
+ writeFn,
400
+ clockFn = () => performance.now(),
401
+ } = {}) {
402
+ const startedAt = clockFn();
403
+ /** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
404
+ const checkLatency = (budgetMs) => {
405
+ const elapsed = clockFn() - startedAt;
406
+ if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
407
+ process.stderr.write(
408
+ `orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
409
+ + `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
410
+ );
411
+ }
412
+ };
413
+ try {
414
+ const root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
415
+ const cfg = loadReaperConfig(root);
416
+ // Cheapest gate first: disabled means no stat, no spawn, no module load.
417
+ if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
418
+
419
+ const reaper = await import('../scripts/lib/orphan-reaper.mjs');
420
+ const markerPath = reaper.scanMarkerPath(root);
421
+ const nowMs = typeof now === 'number' ? now : Date.now();
422
+
423
+ if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
424
+ checkLatency(cfg['max-hook-latency-ms']);
425
+ return { spawned: false, reason: 'throttled' };
426
+ }
427
+ reaper.touchScanMarker(markerPath, { writeFn });
428
+
429
+ const child = spawnFn(
430
+ process.execPath,
431
+ [
432
+ ORPHAN_REAPER_SCRIPT,
433
+ '--repo-root', root,
434
+ '--mode', cfg.mode,
435
+ '--min-age-seconds', String(cfg['min-age-seconds']),
436
+ '--kill-grace-ms', String(cfg['kill-grace-ms']),
437
+ '--verify-wait-ms', String(cfg['verify-wait-ms']),
438
+ '--false-alarm-window', String(cfg['false-alarm-window']),
439
+ ],
440
+ { detached: true, stdio: 'ignore' },
441
+ );
442
+ if (child && typeof child.unref === 'function') child.unref();
443
+ checkLatency(cfg['max-hook-latency-ms']);
444
+ return { spawned: true, reason: 'spawned' };
445
+ } catch {
446
+ return { spawned: false, reason: 'error' };
447
+ }
448
+ }
449
+
308
450
  // ---------------------------------------------------------------------------
309
451
  // Main
310
452
  // ---------------------------------------------------------------------------
@@ -378,9 +520,13 @@ async function main() {
378
520
  }
379
521
  } catch { /* best effort — hook must remain non-blocking */ }
380
522
 
381
- // Emit wave-lifecycle events via the canonical stream when the orchestrator
382
- // populates wave_signal ('wave-start' | 'wave-complete'). Mechanical seam —
383
- // see docs/events-schema.md. Best-effort; never blocks the hook.
523
+ // Emit the wave-completion event via the canonical stream when the
524
+ // orchestrator populates wave_signal 'wave-complete'. Mechanical seam — see
525
+ // docs/events-schema.md. Best-effort; never blocks the hook. A 'wave-start'
526
+ // signal is still ACCEPTED — it keeps suppressing the fallback below, as
527
+ // before — but emits nothing: the `started` sibling event was removed
528
+ // 2026-09-19, having no reader once the convergence monitor stopped
529
+ // admitting it.
384
530
  //
385
531
  // Live path today: the `wave_signal === null` branch below provides the
386
532
  // mechanical wave-lifecycle fallback (#612). It fires live by diffing
@@ -388,19 +534,14 @@ async function main() {
388
534
  // payload injection required. This explicit-signal branch remains as the
389
535
  // preferred path for whenever the harness DOES inject `wave_signal` into the
390
536
  // batch payload (it takes precedence over the fallback when present).
391
- if (waveSignal === 'wave-start' || waveSignal === 'wave-complete') {
537
+ if (waveSignal === 'wave-complete') {
392
538
  try {
393
- await emitEvent(
394
- waveSignal === 'wave-start'
395
- ? 'orchestrator.wave.started'
396
- : 'orchestrator.wave.completed',
397
- {
398
- ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
399
- ...(nextWaveRole !== null ? { next_wave_role: nextWaveRole } : {}),
400
- ...(batchId !== null ? { batch_id: batchId } : {}),
401
- ...(batchSize !== null ? { batch_size: batchSize } : {}),
402
- },
403
- );
539
+ await emitEvent('orchestrator.wave.completed', {
540
+ ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
541
+ ...(nextWaveRole !== null ? { next_wave_role: nextWaveRole } : {}),
542
+ ...(batchId !== null ? { batch_id: batchId } : {}),
543
+ ...(batchSize !== null ? { batch_size: batchSize } : {}),
544
+ });
404
545
  // #1193 review F2 — this branch is a SECOND emitter of
405
546
  // `wave.completed`, so it must feed the same high-water mark the other
406
547
  // two read; otherwise SessionEnd (and the fallback below) would close the
@@ -413,8 +554,7 @@ async function main() {
413
554
  // and walked the marker BACKWARDS to 4. Both keys go through maxWave().
414
555
  // W4c Q1-MED — and neither is written into a PEER's record.
415
556
  if (
416
- waveSignal === 'wave-complete'
417
- && typeof waveNumber === 'number'
557
+ typeof waveNumber === 'number'
418
558
  && waveNumber > 0
419
559
  && await ownsSessionFile(input, sessionFile)
420
560
  ) {
@@ -498,24 +638,31 @@ async function main() {
498
638
  ...(batchSize !== null ? { batch_size: batchSize } : {}),
499
639
  });
500
640
  }
501
- // Open the new wave.
502
- await emitEvent('orchestrator.wave.started', {
503
- wave_number: wave,
504
- ...(nextWaveRole !== null ? { next_wave_role: nextWaveRole } : {}),
505
- ...(batchId !== null ? { batch_id: batchId } : {}),
506
- ...(batchSize !== null ? { batch_size: batchSize } : {}),
507
- });
508
- // Persist the high-water marks so the next batch does not re-emit,
509
- // and so SessionEnd can tell whether the prior wave was already
510
- // closed here (#1193). `last_wave_completed` is only advanced when a
511
- // `completed` was actually emitted above (lastWave > 0).
641
+ // Open the new wave. There is no event for this (the `started`
642
+ // sibling was removed 2026-09-19); opening IS the high-water marks
643
+ // persisted below, so the next batch does not re-close the prior
644
+ // wave, and SessionEnd can tell whether it was already closed here
645
+ // (#1193). `last_wave_completed` is only advanced when a `completed`
646
+ // was actually emitted above (lastWave > 0).
512
647
  // W4c Q1-MED — never stamp these into a PEER session's record. The
513
- // events above are still emitted (they carry THIS session's
648
+ // event above is still emitted (it carries THIS session's
514
649
  // attribution via emitEvent); only the shared-file claim is withheld.
515
650
  // NAMED CEILING: for a non-owning session the marks therefore never
516
- // advance, so this branch re-emits started{wave} once per batch until
517
- // that session's own SessionStart takes over the file. Revisit if the
518
- // ledger shows repeated started{N} for one wave in a shared checkout.
651
+ // advance, so each of its batches re-emits completed{lastWave} while
652
+ // the file's `last_wave` is ahead of its `last_wave_completed`, until
653
+ // that session's own SessionStart takes over the file. The revisit
654
+ // trigger (repeated emissions for one wave) HAS FIRED — measured
655
+ // 2026-09-19 @ 8f6ac022 over this repo's
656
+ // `.orchestrator/metrics/events.jsonl{.1,}`, timestamps >= 2026-09-12,
657
+ // grouped by (session_id, wave_number) with jq:
658
+ // started — 1936 emissions / 48 groups, 19 duplicated, max 274
659
+ // completed — 650 emissions / 28 groups, 2 duplicated, max 579
660
+ // The `started` half is GONE (#1202 §11): its only reader,
661
+ // `scripts/lib/convergence-monitor.mjs`, no longer admits it — it
662
+ // carried no measurement and, sharing a tail tick with completed{N},
663
+ // masked the (N-1, N) shrinking_diff pair. The `completed` half is
664
+ // OPEN: that event has readers, so the fix is a mark a non-owning
665
+ // session can keep for itself, not a removal.
519
666
  if (await ownsSessionFile(input, sessionFile)) {
520
667
  // #980 — the OPEN half: stamp the sha this new wave starts from.
521
668
  // Written as null (not omitted) when git is unreadable, so the
@@ -559,7 +706,22 @@ async function main() {
559
706
  },
560
707
  }));
561
708
  }
709
+
710
+ // Orphan-reaper trigger (#1432 B4). LAST, after this hook's own contract is
711
+ // fulfilled — the stdout envelope above must never wait on the watchdog.
712
+ // Awaited (not fired and forgotten) because the entry guard calls
713
+ // `process.exit(0)` in a `finally`: an un-awaited spawn would race the exit.
714
+ // The await is bounded by design — one config read, one stat, one detached
715
+ // spawn, all measured under `reaper.max-hook-latency-ms` (50 ms).
716
+ await maybeTriggerOrphanScan();
562
717
  }
563
718
 
564
- // Exit 0 always informational hook must never block Claude.
565
- main().catch(() => {}).finally(() => process.exit(0));
719
+ // Entry guard (#1393): run only as the node script the harness execs — a bare
720
+ // `import()` must run no handler and must not exit the importing process.
721
+ if (isMainModule(import.meta.url)) {
722
+ // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
723
+ if (!shouldRunHook('post-tool-batch-wave-signal')) process.exit(0);
724
+
725
+ // Exit 0 always — informational hook must never block Claude.
726
+ main().catch(() => {}).finally(() => process.exit(0));
727
+ }
@@ -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('post-tool-failure-corrective-context')) 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';
@@ -206,7 +205,7 @@ async function main() {
206
205
  // Strip control chars (newlines, ANSI escapes) before forwarding to additionalContext —
207
206
  // SEC-016 (Log Injection): tool errors may contain attacker-controlled bytes.
208
207
  const safeSummary = errorSummary
209
- ? errorSummary.replace(/[\r\n]/g, ' ').split('').join(' ').slice(0, 120)
208
+ ? errorSummary.replace(/[\r\n]/g, ' ').split('\x1b').join(' ').slice(0, 120)
210
209
  : '';
211
210
  const summaryLabel = safeSummary ? ` Error: ${safeSummary}` : '';
212
211
  const additionalContext =
@@ -221,5 +220,12 @@ async function main() {
221
220
  }));
222
221
  }
223
222
 
224
- // Exit 0 always informational hook must never block Claude.
225
- main().catch(() => {}).finally(() => process.exit(0));
223
+ // Entry guard (#1393): run only as the node script the harness execs — a bare
224
+ // `import()` must run no handler and must not exit the importing process.
225
+ if (isMainModule(import.meta.url)) {
226
+ // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
227
+ if (!shouldRunHook('post-tool-failure-corrective-context')) process.exit(0);
228
+
229
+ // Exit 0 always — informational hook must never block Claude.
230
+ main().catch(() => {}).finally(() => process.exit(0));
231
+ }
@@ -24,8 +24,7 @@
24
24
  */
25
25
 
26
26
  import { shouldRunHook } from './_lib/profile-gate.mjs';
27
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
28
- if (!shouldRunHook('frontend-slop-hook')) process.exit(0);
27
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
29
28
 
30
29
  import path from 'node:path';
31
30
 
@@ -180,5 +179,12 @@ async function main() {
180
179
  }));
181
180
  }
182
181
 
183
- // Exit 0 always informational hook must never block Claude (#684).
184
- main().catch(() => {}).finally(() => process.exit(0));
182
+ // Entry guard (#1393): run only as the node script the harness execs — a bare
183
+ // `import()` must run no handler and must not exit the importing process.
184
+ if (isMainModule(import.meta.url)) {
185
+ // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
186
+ if (!shouldRunHook('frontend-slop-hook')) process.exit(0);
187
+
188
+ // Exit 0 always — informational hook must never block Claude (#684).
189
+ main().catch(() => {}).finally(() => process.exit(0));
190
+ }
@@ -659,6 +659,20 @@ export function decide(input, lib) {
659
659
  }
660
660
  const tail = omitted > 0 ? `\n(+${omitted} weitere Befund(e) nicht gezeigt)` : '';
661
661
 
662
+ // Die Optionsgrenze wird aus der Schwelle GERENDERT, nie danebengeschrieben.
663
+ // Ausgeschriebene Zahlwörter ("zwei bis vier") standen hier bis 2026-09-18
664
+ // und waren für jeden Zahlen-grep unsichtbar: verschiebt sich
665
+ // `THRESHOLDS.K6.optionsMax`, blockt der Hook nach der neuen Zahl und nennt
666
+ // dem Operator die alte. Fehlt die Schwelle (defektes Schema-Modul), entfällt
667
+ // der Satz — eine Lücke ist besser als eine erfundene Zahl.
668
+ const k6Limits = lib?.schema?.THRESHOLDS?.K6;
669
+ const limitSentence =
670
+ Number.isInteger(k6Limits?.optionsMin) && Number.isInteger(k6Limits?.optionsMax)
671
+ ? `Das ist keine Stilfrage: das Tool selbst nimmt nur ${k6Limits.optionsMin} bis ` +
672
+ `${k6Limits.optionsMax} Optionen je Frage an, und mehr als ${k6Limits.optionsMax} kann ` +
673
+ 'niemand gegeneinander abwägen.\n\n'
674
+ : '';
675
+
662
676
  // The FIRST LINE names the concrete break, because `emitDeny` derives the
663
677
  // operator-visible headline from it. A general preamble first would put a
664
678
  // sentence that is identical for every deny in front of the human, and the
@@ -666,8 +680,7 @@ export function decide(input, lib) {
666
680
  const reason =
667
681
  `AskUserQuestion blockiert: harte Grenze ${groups.map(([id]) => id).join(' + ')} gerissen — ` +
668
682
  'so erreicht die Frage den Operator nicht.\n\n' +
669
- 'Das ist keine Stilfrage: das Tool selbst nimmt nur zwei bis vier Optionen je Frage an, ' +
670
- 'und mehr als vier kann niemand gegeneinander abwägen.\n\n' +
683
+ limitSentence +
671
684
  `${sections.join('\n\n')}${tail}`;
672
685
 
673
686
  const suggestion =