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
@@ -1812,6 +1812,32 @@ export function redirectRuleMatches(rule, command, opts = {}) {
1812
1812
  // an existing repo-relative rule, and a repo-relative entry still cannot
1813
1813
  // escape the root. An unexpandable `~` (no usable `home`) matches NOTHING —
1814
1814
  // fail-closed for the new class, since an unexpanded `~` names no file.
1815
+ const compiled = compileTargetDenylist(denylist, home);
1816
+ if (compiled.relRegexes.length === 0 && compiled.absRegexes.length === 0) return false;
1817
+
1818
+ for (const entry of extractRedirectTargets(command)) {
1819
+ if (entry.unresolved) continue;
1820
+ if (!modes.has(entry.mode)) continue;
1821
+ if (targetMatchesDenylist(entry.target, compiled, repoRoot, home)) return true;
1822
+ }
1823
+ return false;
1824
+ }
1825
+
1826
+ /**
1827
+ * Compile a rule's `target-denylist` into the two glob classes described in
1828
+ * `redirectRuleMatches` (#1362): repo-relative entries and home-anchored (`~/…`)
1829
+ * entries. Extracted so a SECOND rule class can reuse the exact same compilation
1830
+ * instead of growing a third copy of the glob logic (#1401) — the file already
1831
+ * carries that warning at `findMatchedRedirectEntry` in the consuming hook.
1832
+ *
1833
+ * An unexpandable `~` (no usable `home`) contributes NOTHING — fail-closed for
1834
+ * the home-anchored class, since an unexpanded `~` names no file.
1835
+ *
1836
+ * @param {string[]} denylist
1837
+ * @param {string|undefined} home
1838
+ * @returns {{ relRegexes: RegExp[], absRegexes: RegExp[] }}
1839
+ */
1840
+ function compileTargetDenylist(denylist, home) {
1815
1841
  const relRegexes = [];
1816
1842
  const absRegexes = [];
1817
1843
  for (const pattern of denylist) {
@@ -1824,38 +1850,48 @@ export function redirectRuleMatches(rule, command, opts = {}) {
1824
1850
  relRegexes.push(redirectGlobToRegExp(text));
1825
1851
  }
1826
1852
  }
1827
- if (relRegexes.length === 0 && absRegexes.length === 0) return false;
1828
-
1829
- for (const entry of extractRedirectTargets(command)) {
1830
- if (entry.unresolved) continue;
1831
- if (!modes.has(entry.mode)) continue;
1853
+ return { relRegexes, absRegexes };
1854
+ }
1832
1855
 
1833
- if (absRegexes.length > 0) {
1834
- // A RELATIVE target is repo-root-relative here, exactly as the repo-relative
1835
- // class already treats it (#994 R1) — otherwise `> ../../.claude/projects/
1836
- // <enc>/memory/MEMORY.md` from the repo root names the very file the `~`
1837
- // spelling blocks, and only the spelling would be denied (measured
1838
- // 2026-09-13: relative `>`/`>>` ALLOW vs tilde/absolute BLOCK on one and
1839
- // the same resolved path). This resolves TARGETS, never ENTRIES: a
1840
- // repo-relative denylist entry keeps going through
1841
- // `repoRelativeRedirectTarget` below and still cannot reach out of the root.
1842
- const expandedTarget = expandLeadingHome(entry.target, home);
1843
- const absTarget = path.isAbsolute(expandedTarget)
1844
- ? expandedTarget
1845
- : repoRoot && path.isAbsolute(repoRoot)
1846
- ? path.resolve(repoRoot, expandedTarget)
1847
- : null;
1848
- if (absTarget !== null) {
1849
- const folded = foldAbsoluteTarget(absTarget);
1850
- if (absRegexes.some((re) => re.test(folded))) return true;
1851
- }
1856
+ /**
1857
+ * Test ONE resolved target text against a compiled denylist.
1858
+ *
1859
+ * A RELATIVE target is repo-root-relative in BOTH classes, exactly as the
1860
+ * repo-relative class already treats it (#994 R1) otherwise
1861
+ * `../../.claude/projects/<enc>/memory/MEMORY.md` from the repo root names the
1862
+ * very file the `~` spelling blocks, and only the spelling would be denied
1863
+ * (measured 2026-09-13: relative `>`/`>>` ALLOW vs tilde/absolute BLOCK on one
1864
+ * and the same resolved path). This resolves TARGETS, never ENTRIES: a
1865
+ * repo-relative denylist entry still goes through `repoRelativeRedirectTarget`
1866
+ * and still cannot reach out of the root.
1867
+ *
1868
+ * @param {string} rawTarget — resolved target text (quotes already stripped)
1869
+ * @param {{ relRegexes: RegExp[], absRegexes: RegExp[] }} compiled
1870
+ * @param {string|null} repoRoot
1871
+ * @param {string|undefined} home
1872
+ * @returns {boolean}
1873
+ */
1874
+ function targetMatchesDenylist(rawTarget, compiled, repoRoot, home) {
1875
+ const { relRegexes, absRegexes } = compiled;
1876
+
1877
+ if (absRegexes.length > 0) {
1878
+ const expandedTarget = expandLeadingHome(rawTarget, home);
1879
+ const absTarget = path.isAbsolute(expandedTarget)
1880
+ ? expandedTarget
1881
+ : repoRoot && path.isAbsolute(repoRoot)
1882
+ ? path.resolve(repoRoot, expandedTarget)
1883
+ : null;
1884
+ if (absTarget !== null) {
1885
+ const folded = foldAbsoluteTarget(absTarget);
1886
+ if (absRegexes.some((re) => re.test(folded))) return true;
1852
1887
  }
1888
+ }
1853
1889
 
1854
- if (relRegexes.length > 0) {
1855
- const target = repoRelativeRedirectTarget(entry.target, repoRoot, home);
1856
- if (target !== null && relRegexes.some((re) => re.test(target))) return true;
1857
- }
1890
+ if (relRegexes.length > 0) {
1891
+ const target = repoRelativeRedirectTarget(rawTarget, repoRoot, home);
1892
+ if (target !== null && relRegexes.some((re) => re.test(target))) return true;
1858
1893
  }
1894
+
1859
1895
  return false;
1860
1896
  }
1861
1897
 
@@ -1875,6 +1911,217 @@ function foldAbsoluteTarget(abs) {
1875
1911
  );
1876
1912
  }
1877
1913
 
1914
+ /**
1915
+ * Verbs that REMOVE or RELOCATE a file named as a bare operand (#1401).
1916
+ *
1917
+ * `mv` is here for BOTH of its operand roles: the SOURCE disappears from its
1918
+ * path, and the DESTINATION is overwritten when it already exists — either one
1919
+ * loses the file that was there. So every operand of these verbs is a candidate,
1920
+ * with no source/destination distinction to get wrong.
1921
+ *
1922
+ * NAMED CEILING (BV-004) — measured 2026-09-19 against `splitChainSegments` +
1923
+ * `resolveSegmentVerb`. COVERED, because the verb resolves through the existing
1924
+ * machinery: bare `rm`/`mv`/`unlink`; `env X=1 rm …`, `sudo rm …`, `timeout 5 rm
1925
+ * …`, `nohup`/`nice`/`stdbuf`/`command`/`exec` (WRAPPER_UNWRAP); every chain
1926
+ * position (`;`, `&&`, `||`, `|`, `&`, newline) and compound-statement head
1927
+ * (`do rm …`, `{ rm …; }` — COMMAND_POSITION_KEYWORDS); and the literal payload
1928
+ * of `bash -c '…'` / `env -S '…'` / `eval '…'` via the shared payload recursion.
1929
+ *
1930
+ * NOT covered, deliberately, because the deleted path is not lexically an
1931
+ * operand of a resolvable verb:
1932
+ * - `find … -delete` / `find … -exec rm {} \;` — the verb is `find`; its path
1933
+ * operand is a SEARCH ROOT, and treating a search root as a delete target
1934
+ * would also flag a pure `find … -print` over the same directory.
1935
+ * - `… | xargs rm` — the targets arrive on stdin and do not exist as command
1936
+ * text at hook time. `xargs` is deliberately NOT in WRAPPER_UNWRAP (see
1937
+ * there: unwrapping it could only LOOSEN the guard).
1938
+ * - `git rm <path>` — the verb is `git`; a git-subverb grammar is a separate
1939
+ * table. PSA-007 already bans `git rm` for dispatched subagents.
1940
+ * - in-process deletion (`node -e 'fs.unlinkSync(…)'`) — not redirect- or
1941
+ * operand-shaped; same class boundary the `redirect-harness-memory`
1942
+ * rationale draws for `tee -a` and `fs.appendFileSync`.
1943
+ * REVISIT TRIGGER: any of those four shapes showing up in a real deletion
1944
+ * incident or a transcript census — extend this set or add the sibling grammar,
1945
+ * never special-case one command.
1946
+ */
1947
+ const PATH_DESTRUCTIVE_VERBS = new Set(['rm', 'mv', 'unlink']);
1948
+
1949
+ /**
1950
+ * Recursive collector behind {@link extractDeleteTargets}. Mirrors
1951
+ * collectRedirectTargets' structure — same segment walk, same dual-reading
1952
+ * union, same depth/budget caps and the same fail-VISIBLE cut-off markers.
1953
+ *
1954
+ * @param {Array<Array<{ text: string, quoted: boolean, redirect?: object }>>} segments
1955
+ * @param {Array<object>} out — accumulator
1956
+ * @param {number} depth
1957
+ * @param {{ remaining: number }} budget
1958
+ */
1959
+ function collectDeleteTargets(segments, out, depth, budget) {
1960
+ for (const segment of segments) {
1961
+ const resolved = resolveSegmentVerb(segment);
1962
+
1963
+ // BOTH readings of an ambiguous unknown WRAPPER flag contribute (#1000),
1964
+ // unioned and never replaced: `env -Q x rm .orchestrator/metrics/events.jsonl`
1965
+ // resolves to the non-destructive verb `x` in parse A and to `rm` in parse B,
1966
+ // while `sudo -n rm <file>` is the mirror case (parse B swallows `rm` as
1967
+ // `-n`'s operand). Picking either reading alone drops one of the two.
1968
+ const readings = resolved.alt ? [resolved, resolved.alt] : [resolved];
1969
+ const seen = new Set();
1970
+
1971
+ for (const reading of readings) {
1972
+ if (!reading.verb || reading.index < 0) continue;
1973
+ if (!PATH_DESTRUCTIVE_VERBS.has(reading.verb)) continue;
1974
+ // A verb that only exists inside a quoted token is inert literal text for
1975
+ // this segment — same rule commandHasRecursiveForceRm applies. An
1976
+ // interpreter's quoted payload is reached through the recursion below
1977
+ // instead, where it is re-tokenized as the command it really is.
1978
+ if (segment[reading.index].quoted) continue;
1979
+
1980
+ let seenDashDash = false;
1981
+ for (let i = reading.index + 1; i < segment.length; i++) {
1982
+ const tok = segment[i];
1983
+ // A redirect operand is a filename the SHELL consumes, not an operand of
1984
+ // the verb — `rm -f x > out.log` deletes `x`, it does not delete
1985
+ // `out.log` (the redirect surface judges that one).
1986
+ if (tok.redirect) { i = redirectSpanEnd(segment, i); continue; }
1987
+ if (!seenDashDash && tok.text === '--') { seenDashDash = true; continue; }
1988
+ // FLAG SKIPPING, and why this is the UNION of both flag readings rather
1989
+ // than a pick (#1000 applied to the VERB's own options): an option table
1990
+ // cannot enumerate every flag `rm`/`mv` may grow, and the two possible
1991
+ // readings of an unknown one are "boolean" (the next word is an operand)
1992
+ // and "value-taking" (the next word is the flag's value). The
1993
+ // value-taking reading is a strict SUBSET of the boolean one here — it
1994
+ // can only HIDE a word, never reveal one — so collecting under the
1995
+ // boolean reading yields exactly the union of both. That is also the
1996
+ // fail-closed direction: `mv -t .orchestrator/metrics/ x` reports the
1997
+ // `-t` operand as a candidate (it IS a destination that can overwrite),
1998
+ // where a value-taking reading would have swallowed it silently.
1999
+ if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
2000
+
2001
+ const key = `${reading.verb}\u0000${tok.text}`;
2002
+ if (seen.has(key)) continue;
2003
+ seen.add(key);
2004
+
2005
+ if (/[$`]/.test(tok.text)) {
2006
+ // Variable indirection (`rm -f "$LEDGER"`) or command substitution:
2007
+ // fail-visible, never guess (#983 rule, same direction).
2008
+ out.push({
2009
+ target: null, verb: reading.verb, unresolved: true, reason: 'variable/substitution',
2010
+ });
2011
+ continue;
2012
+ }
2013
+ out.push({ target: tok.text, verb: reading.verb });
2014
+ }
2015
+ }
2016
+
2017
+ // Literal `-c` / `env -S` / `eval` payload recursion — the same deduped
2018
+ // union helper and the same shared budget the match and redirect surfaces
2019
+ // use, so this traversal cannot be starved any differently than those.
2020
+ const payloads = dedupedSegmentPayloads(segment, resolved);
2021
+ if (payloads.length === 0) continue;
2022
+
2023
+ // A cap that drops payloads SILENTLY is a bypass, not a cap (#988 T2) —
2024
+ // emit a marker so the consuming hook can surface it.
2025
+ if (depth >= MAX_PAYLOAD_DEPTH) {
2026
+ out.push({ target: null, verb: null, unresolved: true, reason: 'depth-exceeded' });
2027
+ continue;
2028
+ }
2029
+ for (const payload of payloads) {
2030
+ if (budget.remaining <= 0) {
2031
+ out.push({ target: null, verb: null, unresolved: true, reason: 'budget-exhausted' });
2032
+ break;
2033
+ }
2034
+ budget.remaining -= 1;
2035
+ const subTokens = tokenizeCommand(
2036
+ normalizeShellWhitespaceExpansions(payload, { expandSingleQuoted: true }),
2037
+ );
2038
+ collectDeleteTargets(splitSegments(subTokens), out, depth + 1, budget);
2039
+ }
2040
+ }
2041
+ }
2042
+
2043
+ /**
2044
+ * Extract every path a command names as an operand of a file-destroying verb
2045
+ * (`rm`, `mv`, `unlink` — see {@link PATH_DESTRUCTIVE_VERBS} for the covered and
2046
+ * uncovered shapes), across all chain segments and literal shell payloads (#1401).
2047
+ *
2048
+ * This is the DELETE/RENAME sibling of `extractRedirectTargets`: that one answers
2049
+ * "which file does this command WRITE", this one "which file does it REMOVE or
2050
+ * MOVE". They are separate surfaces because a redirect target and a verb operand
2051
+ * are different grammar positions — `rm -f x > out.log` names one of each, and
2052
+ * the guard's 53,896-line telemetry loss (#1401) happened because only the write
2053
+ * surface was ever consulted.
2054
+ *
2055
+ * Entry shapes:
2056
+ * - `{ target: string, verb: 'rm'|'mv'|'unlink' }` — resolved operand, quotes
2057
+ * stripped by the tokenizer.
2058
+ * - `{ target: null, verb, unresolved: true, reason: 'variable/substitution' }`
2059
+ * — the operand carries a variable or a command substitution. Reported
2060
+ * fail-visible; never a match candidate (#641 FP class).
2061
+ * - `{ target: null, verb: null, unresolved: true,
2062
+ * reason: 'budget-exhausted'|'depth-exceeded' }` — a payload subtree was NOT
2063
+ * traversed because a recursion cap cut it off (#988 T2).
2064
+ *
2065
+ * @param {string} command
2066
+ * @returns {Array<{ target: string|null, verb: string|null, unresolved?: boolean, reason?: string }>}
2067
+ */
2068
+ export function extractDeleteTargets(command) {
2069
+ if (typeof command !== 'string' || command.length === 0) return [];
2070
+ const out = [];
2071
+ const segments = splitSegments(tokenizeCommand(normalizeShellWhitespaceExpansions(command)));
2072
+ collectDeleteTargets(segments, out, 0, { remaining: MAX_PAYLOAD_EVALUATIONS });
2073
+ return out;
2074
+ }
2075
+
2076
+ /**
2077
+ * Return the FIRST collected delete/rename target a `path-delete` policy rule's
2078
+ * `target-denylist` matches, or `null` when none does (#1401).
2079
+ *
2080
+ * Returns the ENTRY, not a boolean, so the consuming hook can name the verb and
2081
+ * the target in its deny reason without re-probing through a boolean matcher —
2082
+ * the workaround `findMatchedRedirectEntry` needs, and which its own docblock
2083
+ * flags as one copy too many.
2084
+ *
2085
+ * Takes pre-collected `entries` rather than the raw command so the hook
2086
+ * tokenizes ONCE (it needs the same array for the unresolved-marker notices).
2087
+ *
2088
+ * PER-TARGET, never per-command: every entry is judged on its own, so a sibling
2089
+ * operand that is NOT on the denylist can never lift the verdict for one that
2090
+ * is. `rm -f .orchestrator/tmp/ok .orchestrator/metrics/events.jsonl` matches on
2091
+ * the second operand — the exemption is the ABSENCE of a denylist entry for
2092
+ * `.orchestrator/tmp/**`, never a whole-command escape clause. That shape is the
2093
+ * #1106 hole (`guard-design.md` § "Widening a matcher without narrowing its
2094
+ * bypass"), where an appended exempt statement lifted the verdict for the whole
2095
+ * command; it is structurally unreachable here.
2096
+ *
2097
+ * Denylist semantics (globs, the two classes, `~` handling, case-folding,
2098
+ * repo-root relativization) are IDENTICAL to `redirectRuleMatches` — both call
2099
+ * `compileTargetDenylist` / `targetMatchesDenylist`, so the two rule classes can
2100
+ * never drift apart on what a pattern means.
2101
+ *
2102
+ * @param {{ 'target-denylist'?: string[] }} rule
2103
+ * @param {Array<{ target: string|null, verb: string|null, unresolved?: boolean }>} entries
2104
+ * @param {{ repoRoot?: string|null, home?: string|undefined }} [opts]
2105
+ * @returns {{ target: string, verb: string }|null}
2106
+ */
2107
+ export function firstDeniedDeleteTarget(rule, entries, opts = {}) {
2108
+ const { repoRoot = null, home = process.env.HOME } = opts;
2109
+ if (!rule || !Array.isArray(entries) || entries.length === 0) return null;
2110
+ const denylist = Array.isArray(rule['target-denylist']) ? rule['target-denylist'] : [];
2111
+ if (denylist.length === 0) return null;
2112
+
2113
+ const compiled = compileTargetDenylist(denylist, home);
2114
+ if (compiled.relRegexes.length === 0 && compiled.absRegexes.length === 0) return null;
2115
+
2116
+ for (const entry of entries) {
2117
+ if (entry.unresolved || typeof entry.target !== 'string') continue;
2118
+ if (targetMatchesDenylist(entry.target, compiled, repoRoot, home)) {
2119
+ return { target: entry.target, verb: entry.verb };
2120
+ }
2121
+ }
2122
+ return null;
2123
+ }
2124
+
1878
2125
  /**
1879
2126
  * Test whether a command string contains a blocked pattern with shell-aware
1880
2127
  * boundaries AND a quoted-payload guard (#641).
@@ -14,6 +14,15 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
14
14
 
15
15
  const ALLOWED_MODELS = new Set(['haiku', 'sonnet', 'opus']);
16
16
 
17
+ /**
18
+ * Default `dialectic.budget-tokens` — the INPUT ceiling of one derivation call
19
+ * (SSOT; `scripts/dialectic-deriver.mjs` `DEFAULT_BUDGET.input` imports it).
20
+ * It is an upper bound the pre-dispatch estimate aborts on, not a spend: card
21
+ * bodies + steering alone estimated ~12k tokens here and ~30k in a consumer
22
+ * repo, so the former default of 8000 aborted real runs (#1380).
23
+ */
24
+ export const DEFAULT_BUDGET_TOKENS = 32000;
25
+
17
26
  /**
18
27
  * Parse the top-level `dialectic:` YAML block from markdown content.
19
28
  * Independent of the `## Session Config` section boundary.
@@ -21,7 +30,7 @@ const ALLOWED_MODELS = new Set(['haiku', 'sonnet', 'opus']);
21
30
  * Defaults:
22
31
  * cadence: 5 (integer ≥ 0 — session count between auto-runs; 0 = disabled)
23
32
  * model: 'haiku'
24
- * budget-tokens: 8000 (integer ≥ 0)
33
+ * budget-tokens: DEFAULT_BUDGET_TOKENS (32000, integer ≥ 0)
25
34
  *
26
35
  * Throws if `model` is present but not one of haiku|sonnet|opus.
27
36
  *
@@ -29,7 +38,7 @@ const ALLOWED_MODELS = new Set(['haiku', 'sonnet', 'opus']);
29
38
  * @returns {{ cadence: number, model: string, "budget-tokens": number }}
30
39
  */
31
40
  export function _parseDialectic(content) {
32
- const defaults = { cadence: 5, model: 'haiku', 'budget-tokens': 8000 };
41
+ const defaults = { cadence: 5, model: 'haiku', 'budget-tokens': DEFAULT_BUDGET_TOKENS };
33
42
 
34
43
  const lines = preprocessBlockLines(content);
35
44
  let inBlock = false;
@@ -49,7 +58,7 @@ export function _parseDialectic(content) {
49
58
 
50
59
  let cadence = 5;
51
60
  let model = 'haiku';
52
- let budgetTokens = 8000;
61
+ let budgetTokens = DEFAULT_BUDGET_TOKENS;
53
62
 
54
63
  for (const rawLine of blockLines) {
55
64
  const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
@@ -0,0 +1,74 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+ import { preprocessBlockLines } from './block-preprocess.mjs';
3
+
4
+ /**
5
+ * gate.mjs — Parser for the top-level `gate:` YAML block
6
+ * (PRD `docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md` §4 A3,
7
+ * Epic #1425 / issue #1432).
8
+ *
9
+ * Holds the wall-clock ceiling for GATE PATH B — the
10
+ * `scripts/run-quality-gate.mjs` → `scripts/lib/gates/gate-*.mjs` route, which
11
+ * had NO timeout at all before #1425 A3. Path A (`scripts/lib/quality-gate.mjs`)
12
+ * keeps its own `SO_GATE_TIMEOUT_MS` operator override; this block sets the
13
+ * COMMITTED default for path B so both paths may run equally long.
14
+ *
15
+ * Precedence in `run-quality-gate.mjs`: `SO_GATE_TIMEOUT_MS` (operator, wins) >
16
+ * this committed value > `DEFAULT_GATE_TIMEOUT_MS` (900 000).
17
+ *
18
+ * Tolerant parser: malformed values silently fall back to the default.
19
+ *
20
+ * Returns `{ 'timeout-path-b-ms': number }`.
21
+ */
22
+
23
+ /** 900 000 ms (15 min) — same ceiling as `GATE_TIMEOUT_MS` on path A (PRD §4). */
24
+ const DEFAULT_TIMEOUT_PATH_B_MS = 900_000;
25
+
26
+ /**
27
+ * Parse the top-level `gate:` YAML block from markdown content.
28
+ * Independent of the `## Session Config` section boundary.
29
+ *
30
+ * @param {string} content — full file contents
31
+ * @returns {{'timeout-path-b-ms': number}}
32
+ */
33
+ export function _parseGate(content) {
34
+ let timeoutPathBMs = DEFAULT_TIMEOUT_PATH_B_MS;
35
+
36
+ const lines = preprocessBlockLines(content);
37
+ let inBlock = false;
38
+ const blockLines = [];
39
+
40
+ for (const rawLine of lines) {
41
+ const line = rawLine.replace(/\r$/, '');
42
+ if (!inBlock) {
43
+ if (matchBlockHeader(line, 'gate')) inBlock = true;
44
+ continue;
45
+ }
46
+ if (line.length > 0 && !/^\s/.test(line)) break;
47
+ blockLines.push(line);
48
+ }
49
+
50
+ for (const rawLine of blockLines) {
51
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
52
+ if (!clean.trim()) continue;
53
+
54
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
55
+ if (!kvMatch) continue;
56
+
57
+ const k = kvMatch[1];
58
+ let v = kvMatch[2].trim();
59
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
60
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
61
+
62
+ if (k === 'timeout-path-b-ms') {
63
+ // A zero or negative ceiling would kill every command instantly — the
64
+ // failure mode is worse than the uncapped state this key exists to fix,
65
+ // so it falls back to the default rather than being honoured.
66
+ if (/^-?\d+$/.test(v)) {
67
+ const n = Number.parseInt(v, 10);
68
+ if (Number.isFinite(n) && n > 0) timeoutPathBMs = n;
69
+ }
70
+ }
71
+ }
72
+
73
+ return { 'timeout-path-b-ms': timeoutPathBMs };
74
+ }
@@ -0,0 +1,162 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+ import { preprocessBlockLines } from './block-preprocess.mjs';
3
+
4
+ /**
5
+ * reaper.mjs — Parser for the top-level `reaper:` YAML block
6
+ * (PRD `docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md` §4,
7
+ * Epic #1425 / issue #1432 B4).
8
+ *
9
+ * Configures the orphan watchdog that `hooks/post-tool-batch-wave-signal.mjs`
10
+ * (PostToolBatch) and `hooks/on-stop.mjs` (Stop + SubagentStop) trigger as a
11
+ * detached fire-and-forget scan. The scan itself lives in
12
+ * `scripts/lib/orphan-reaper.mjs`; this module only reads the numbers.
13
+ *
14
+ * DEFAULT OFF. `enabled` defaults to `false` — unlike `loop-guard`, this signal
15
+ * can SEND SIGNALS to processes, so it ships inert and is armed per repo only
16
+ * after its firing rate has been measured (`.claude/rules/host-resources.md`
17
+ * HR-101: a class above ~10 % is a broken instrument; HR-105: a rule nothing
18
+ * records is unfalsifiable).
19
+ *
20
+ * Tolerant parser: malformed values silently fall back to defaults.
21
+ *
22
+ * Returns `{ enabled, mode, 'min-age-seconds', 'min-scan-interval-seconds',
23
+ * 'kill-grace-ms', 'verify-wait-ms', 'max-hook-latency-ms', 'false-alarm-window' }`.
24
+ */
25
+
26
+ /**
27
+ * Startwerte für Stufe 1 — NOT calibrated. Every number carries its provenance
28
+ * from the PRD §4 parameter table ("keine Zahl ohne ihre Population").
29
+ *
30
+ * RUNTIME SSOT is `REAPER_DEFAULTS` in `scripts/lib/orphan-reaper.mjs`; these
31
+ * literals mirror it deliberately rather than importing it, because that module
32
+ * pulls `process-group.mjs` + `resource-probe/parsers.mjs` into the config
33
+ * import graph, which every `parseSessionConfig()` caller would then pay for.
34
+ * The parity is held by a test, not by an import
35
+ * (`tests/lib/config/reaper.test.mjs` § defaults mirror REAPER_DEFAULTS).
36
+ */
37
+ /** 300 s — DevWatchdog's hard limit for `tsgo`; the 2026-09-20 orphans were 7–17 min old. */
38
+ const DEFAULT_MIN_AGE_SECONDS = 300;
39
+ /** 30 s — DevWatchdog's normal scan cadence; keeps a PostToolBatch storm from taxing every tool call. */
40
+ const DEFAULT_MIN_SCAN_INTERVAL_SECONDS = 30;
41
+ /** 10 000 ms — `DEFAULT_KILL_GRACE_MS` from `dispatch-common.mjs:61`; repo convention, not newly invented. */
42
+ const DEFAULT_KILL_GRACE_MS = 10_000;
43
+ /** 500 ms — wait before reading the effect back; without it the 2026-09-20 check falsely reported "still alive". */
44
+ const DEFAULT_VERIFY_WAIT_MS = 500;
45
+ /** 50 ms — ceiling a scan may delay a hook by; above that the scan counts as too expensive. */
46
+ const DEFAULT_MAX_HOOK_LATENCY_MS = 50;
47
+ /** 50 decisions — rolling window over the JSONL audit (B5), so the rate has a population on quiet hosts too. */
48
+ const DEFAULT_FALSE_ALARM_WINDOW = 50;
49
+
50
+ /** `report` = decide + audit only (dryRun); `kill` = send signals (PRD Stufe 2, after calibration). */
51
+ const MODE_VALUES = new Set(['report', 'kill']);
52
+ const DEFAULT_MODE = 'report';
53
+
54
+ /**
55
+ * Coerce a raw string into a finite integer >= `min`, else `fallback`.
56
+ *
57
+ * @param {string} raw
58
+ * @param {number} fallback
59
+ * @param {number} min
60
+ * @returns {number}
61
+ */
62
+ function parseBoundedInt(raw, fallback, min) {
63
+ if (!/^-?\d+$/.test(raw)) return fallback;
64
+ const n = Number.parseInt(raw, 10);
65
+ if (!Number.isFinite(n) || n < min) return fallback;
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * Parse the top-level `reaper:` YAML block from markdown content.
71
+ * Independent of the `## Session Config` section boundary.
72
+ *
73
+ * @param {string} content — full file contents
74
+ * @returns {{enabled: boolean, mode: string, 'min-age-seconds': number,
75
+ * 'min-scan-interval-seconds': number, 'kill-grace-ms': number,
76
+ * 'verify-wait-ms': number, 'max-hook-latency-ms': number,
77
+ * 'false-alarm-window': number}}
78
+ */
79
+ export function _parseReaper(content) {
80
+ let enabled = false;
81
+ let mode = DEFAULT_MODE;
82
+ let minAgeSeconds = DEFAULT_MIN_AGE_SECONDS;
83
+ let minScanIntervalSeconds = DEFAULT_MIN_SCAN_INTERVAL_SECONDS;
84
+ let killGraceMs = DEFAULT_KILL_GRACE_MS;
85
+ let verifyWaitMs = DEFAULT_VERIFY_WAIT_MS;
86
+ let maxHookLatencyMs = DEFAULT_MAX_HOOK_LATENCY_MS;
87
+ let falseAlarmWindow = DEFAULT_FALSE_ALARM_WINDOW;
88
+
89
+ const lines = preprocessBlockLines(content);
90
+ let inBlock = false;
91
+ const blockLines = [];
92
+
93
+ for (const rawLine of lines) {
94
+ const line = rawLine.replace(/\r$/, '');
95
+ if (!inBlock) {
96
+ if (matchBlockHeader(line, 'reaper')) inBlock = true;
97
+ continue;
98
+ }
99
+ if (line.length > 0 && !/^\s/.test(line)) break;
100
+ blockLines.push(line);
101
+ }
102
+
103
+ for (const rawLine of blockLines) {
104
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
105
+ if (!clean.trim()) continue;
106
+
107
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
108
+ if (!kvMatch) continue;
109
+
110
+ const k = kvMatch[1];
111
+ let v = kvMatch[2].trim();
112
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
113
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
114
+
115
+ switch (k) {
116
+ case 'enabled':
117
+ // Default is false → only an explicit "true" arms the watchdog.
118
+ enabled = v.toLowerCase() === 'true';
119
+ break;
120
+ case 'mode':
121
+ // An unknown mode falls back to the INERT one — never to `kill`.
122
+ mode = MODE_VALUES.has(v.toLowerCase()) ? v.toLowerCase() : DEFAULT_MODE;
123
+ break;
124
+ case 'min-age-seconds':
125
+ minAgeSeconds = parseBoundedInt(v, DEFAULT_MIN_AGE_SECONDS, 0);
126
+ break;
127
+ case 'min-scan-interval-seconds':
128
+ minScanIntervalSeconds = parseBoundedInt(v, DEFAULT_MIN_SCAN_INTERVAL_SECONDS, 1);
129
+ break;
130
+ case 'kill-grace-ms':
131
+ killGraceMs = parseBoundedInt(v, DEFAULT_KILL_GRACE_MS, 0);
132
+ break;
133
+ case 'verify-wait-ms':
134
+ verifyWaitMs = parseBoundedInt(v, DEFAULT_VERIFY_WAIT_MS, 0);
135
+ break;
136
+ case 'max-hook-latency-ms':
137
+ maxHookLatencyMs = parseBoundedInt(v, DEFAULT_MAX_HOOK_LATENCY_MS, 1);
138
+ break;
139
+ case 'false-alarm-window':
140
+ falseAlarmWindow = parseBoundedInt(v, DEFAULT_FALSE_ALARM_WINDOW, 1);
141
+ break;
142
+ }
143
+ }
144
+
145
+ // Self-healing clamp: a `min-age-seconds` below the scan interval is dead
146
+ // config — a process younger than one scan period can be born and reaped
147
+ // between two scans, so the watchdog would fire on processes it has never
148
+ // seen before. Widen the age floor to at least one scan period (the same
149
+ // shape as loop-guard's window >= threshold clamp).
150
+ if (minAgeSeconds < minScanIntervalSeconds) minAgeSeconds = minScanIntervalSeconds;
151
+
152
+ return {
153
+ enabled,
154
+ mode,
155
+ 'min-age-seconds': minAgeSeconds,
156
+ 'min-scan-interval-seconds': minScanIntervalSeconds,
157
+ 'kill-grace-ms': killGraceMs,
158
+ 'verify-wait-ms': verifyWaitMs,
159
+ 'max-hook-latency-ms': maxHookLatencyMs,
160
+ 'false-alarm-window': falseAlarmWindow,
161
+ };
162
+ }
@@ -65,6 +65,8 @@ import { _parseHealthEndpoints, _parseEcosystemHealthBlockEnabled } from './conf
65
65
  import { _parseBrokenWindow } from './config/broken-window.mjs';
66
66
  import { _parseSlopcheck } from './config/slopcheck.mjs';
67
67
  import { _parseDiscoveryValidator } from './config/discovery-validator.mjs';
68
+ import { _parseReaper } from './config/reaper.mjs';
69
+ import { _parseGate } from './config/gate.mjs';
68
70
  import { _parseTemplatesFirst } from './config/templates-first.mjs';
69
71
  import { _parseVerificationAutoFix } from './config/verification-auto-fix.mjs';
70
72
  import { _parseDialectic } from './config/dialectic.mjs';
@@ -417,6 +419,16 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
417
419
  // discovery-validator: parsed from full content (PSA-006 enforcement / issue #567)
418
420
  const discoveryValidator = _parseDiscoveryValidator(mdContent);
419
421
 
422
+ // reaper: orphan-process watchdog, default OFF (PRD 2026-09-20 Prozessgruppen-Kill
423
+ // und Waisen-Wächter, Epic #1425 / #1432 B4). Consumed by the two hooks that
424
+ // trigger the detached scan — post-tool-batch-wave-signal.mjs and on-stop.mjs.
425
+ const reaper = _parseReaper(mdContent);
426
+
427
+ // gate: wall-clock ceiling for gate path B (run-quality-gate.mjs →
428
+ // gate-*.mjs), which had no timeout at all before #1425 A3. The operator
429
+ // override SO_GATE_TIMEOUT_MS still wins over this committed value.
430
+ const gate = _parseGate(mdContent);
431
+
420
432
  // templates-first: parsed from full content (PRD gsd Pattern 3 / issues #517, #519)
421
433
  const templatesFirst = _parseTemplatesFirst(mdContent);
422
434
 
@@ -555,6 +567,8 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
555
567
  'skill-evolution': skillEvolution,
556
568
  'dispatcher-autonomy': dispatcherAutonomy,
557
569
  'discovery-validator': discoveryValidator,
570
+ 'reaper': reaper,
571
+ 'gate': gate,
558
572
  'templates-first': templatesFirst,
559
573
  'verification-auto-fix': verificationAutoFix,
560
574
  'dialectic': dialectic,