session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -0,0 +1,395 @@
1
+ /**
2
+ * orphan-reaper/reaper-decide.mjs — the PURE half of the reaper: which processes
3
+ * are reapable orphans, decided from a `ps` snapshot and the gate-process ledger.
4
+ *
5
+ * ## Why PPID 1 is necessary and never sufficient
6
+ *
7
+ * Measured on this host 2026-09-21: 538 of 784 processes (68.6%) have PPID 1 —
8
+ * on macOS launchd is the parent of nearly everything. A reaper keyed on PPID 1
9
+ * alone would be a weapon pointed at the operating system. So a candidate is a
10
+ * CONJUNCTION: in our own gate-process ledger, PPID 1, old enough, a read-only
11
+ * gate command, and an identity that still verifies against the ledger record.
12
+ *
13
+ * ## Coverage: the ledger is joined by PID **and** by PGID
14
+ *
15
+ * The binding `ps` format (`PS_ARGS` in `ps-snapshot.mjs`) publishes `pgid`, so a row joins the
16
+ * ledger either as the recorded LEADER (`row.pid === record.pid`) or as any
17
+ * member of the recorded GROUP (`row.pgid === record.pgid`). The group join is
18
+ * what covers the measured grandchild case: `npm run typecheck` (82507) spawns
19
+ * `node scripts/typecheck.mjs` (82591) which spawns `tsgo`, all three in pgid
20
+ * 82507 — when only the leader dies, 82591 sits at PPID 1 and the ledger knows
21
+ * its group but not its pid. Named ceiling (BV-004): the join FINDS every
22
+ * member, but a member is only reaped when its own command passes the read-only
23
+ * allowlist — `tsgo`/`vitest` descendants are, a repo-local `node scripts/…`
24
+ * runner is NOT and is reported as `not-read-only` (measured 2026-09-22); the
25
+ * group still dies through its allowlisted sibling. Revisit trigger: an orphaned
26
+ * node runner that outlives every allowlisted sibling.
27
+ *
28
+ * A non-leader cannot be identified the leader's way: its start time is its own
29
+ * and its command line is not the recorded one. Its identity is therefore
30
+ * {@link verifyGroupMemberIdentity} — born no earlier than its group leader
31
+ * (a descendant is never older than the process that forked it), plus either
32
+ * the record's signature token or a command name the read-only allowlist knows.
33
+ *
34
+ * The KILL TARGET is the group in both cases (`-pgid`), so one record still
35
+ * covers its whole group with one ladder.
36
+ *
37
+ * Named ceiling (BV-004): a process that `setsid`-ed out of its group carries no
38
+ * ledger identity in this format and is not a candidate BY DESIGN (PRD
39
+ * § Umfangsgrenze: orphan-confidence via PPID history is Stufe 2 / C4). Revisit
40
+ * if such a case is ever observed for a process the ledger DID record.
41
+ */
42
+
43
+ import { verifyProcessIdentity } from '../process-group.mjs';
44
+ import { REAPER_DEFAULTS } from './defaults.mjs';
45
+
46
+ /** @typedef {import('./ps-snapshot.mjs').PsRow} PsRow */
47
+
48
+ /**
49
+ * The read-only gate command class — an ALLOWLIST, which is why no denylist is
50
+ * needed: a dev server or MCP server matches nothing here and is therefore never
51
+ * a candidate, orphaned or not (PRD § Out-of-Scope: two orphaned `browser-kit`
52
+ * servers were reported, not killed).
53
+ *
54
+ * IN — commands that only read the working copy and can be re-run at will:
55
+ * `tsgo`, `tsc`, `vitest`, `eslint`, `node …vitest…`,
56
+ * `npm test` / `npm run typecheck` / `npm run lint`.
57
+ * DELIBERATELY OUT — anything that holds state or serves a port: dev servers,
58
+ * MCP servers, `npm run build`, `git` (writes the index), database processes.
59
+ *
60
+ * Matched per STATEMENT (see {@link isReadOnlyCommand}), with a `(^|[\s/])`
61
+ * boundary so both `tsgo --noEmit` and `/opt/homebrew/bin/tsgo --noEmit` and
62
+ * `npx tsgo` hit.
63
+ *
64
+ * `eslint` carries a negative lookahead for its WRITING flags: `eslint . --fix`
65
+ * is this repo's own `lint:fix` script and rewrites the working copy, so it is
66
+ * not re-runnable at will and has no business on a read-only allowlist.
67
+ */
68
+ export const READ_ONLY_COMMAND_PATTERNS = Object.freeze([
69
+ /(^|[\s/])tsgo(\s|$)/,
70
+ /(^|[\s/])tsc(\s|$)/,
71
+ /(^|[\s/])vitest(\s|$)/,
72
+ /(^|[\s/])eslint(?!\S)(?!.*\s--(fix|fix-dry-run|output-file)(\s|=|$))/,
73
+ /(^|[\s/])node\s+\S*vitest/,
74
+ /(^|[\s/])npm\s+(run\s+)?(test|typecheck|lint)(\s|$)/,
75
+ ]);
76
+
77
+ /**
78
+ * Shell operators that separate one STATEMENT from the next in a `ps` `args`
79
+ * line. `ps` prints argv joined by spaces with the quoting already stripped, so
80
+ * a quote-aware lexer (`command-blocker.mjs` `splitChainSegments`) would have
81
+ * nothing left to be aware of here — and it would put 2.190 lines into the
82
+ * static import closure of a module that is destined for a hot-path hook
83
+ * (#1432) and lazily imports even `events.mjs` for that reason.
84
+ */
85
+ const STATEMENT_SEPARATOR_RE = /\s*(?:&&|\|\||[;|&])\s*/;
86
+
87
+ /**
88
+ * True when EVERY statement of `args` is a read-only gate command per
89
+ * {@link READ_ONLY_COMMAND_PATTERNS}.
90
+ *
91
+ * Per-statement and ALL, not the whole string and ANY: judged over the whole
92
+ * line, `sh -c npm run build && npm test` matches the `npm test` pattern as a
93
+ * SUBSTRING and a build — which writes — is allowlisted by the read-only half
94
+ * of its own command. This is the destructive-guard's per-statement rule
95
+ * (`.claude/rules/guard-design.md` § "Widening a matcher without narrowing its
96
+ * bypass"), applied in the other direction: there one appended statement lifts
97
+ * a block, here one appended statement must be able to REVOKE a permission.
98
+ *
99
+ * An empty statement (a trailing `&&`, a doubled separator) is skipped rather
100
+ * than counted as a failure; a line with no statement at all is not read-only.
101
+ *
102
+ * @param {string} args
103
+ * @param {readonly RegExp[]} [patterns]
104
+ * @returns {boolean}
105
+ */
106
+ export function isReadOnlyCommand(args, patterns = READ_ONLY_COMMAND_PATTERNS) {
107
+ const s = typeof args === 'string' ? args : '';
108
+ if (s.trim().length === 0) return false;
109
+ const statements = s.split(STATEMENT_SEPARATOR_RE).filter((part) => part.trim().length > 0);
110
+ if (statements.length === 0) return false;
111
+ return statements.every((statement) => patterns.some((re) => re.test(statement)));
112
+ }
113
+
114
+ /**
115
+ * Identity check for a NON-LEADER member of a recorded process group — PURE.
116
+ *
117
+ * {@link verifyProcessIdentity} cannot serve here: it compares the row's start
118
+ * time against the LEADER's and the row's first token against the LEADER's
119
+ * signature, and a descendant matches neither (`node scripts/typecheck.mjs` was
120
+ * forked by `npm run typecheck` seconds after it).
121
+ *
122
+ * Both checks must hold, and both are one-sided on purpose:
123
+ * 1. NOT OLDER than the group leader, within `toleranceMs`. A process that
124
+ * existed BEFORE the leader cannot be its descendant, so an older row in the
125
+ * same pgid is a recycled group id, never a grandchild.
126
+ * 2. Either the leader's signature token prefixes the row's command (the
127
+ * descendant re-execs the same binary — `npm` → `npm`), or the row's command
128
+ * is itself on the read-only allowlist (`tsgo`, `node …vitest` — NOT a
129
+ * repo-local `node scripts/typecheck.mjs`, see the module head).
130
+ *
131
+ * @param {PsRow|null} row
132
+ * @param {import('../process-group.mjs').GateProcessRecord} record
133
+ * @param {object} [opts]
134
+ * @param {number} [opts.nowMs]
135
+ * @param {number} [opts.toleranceMs]
136
+ * @param {readonly RegExp[]} [opts.readOnlyPatterns]
137
+ * @returns {{match: boolean, reason: 'ok'|'gone'|'start-time-mismatch'|'signature-mismatch'}}
138
+ */
139
+ export function verifyGroupMemberIdentity(row, record, {
140
+ nowMs = Date.now(),
141
+ toleranceMs = 2000,
142
+ readOnlyPatterns = READ_ONLY_COMMAND_PATTERNS,
143
+ } = {}) {
144
+ if (!row || typeof row !== 'object') return { match: false, reason: 'gone' };
145
+ const etimeSeconds = typeof row.etimeSeconds === 'number' && Number.isFinite(row.etimeSeconds)
146
+ ? row.etimeSeconds
147
+ : null;
148
+ // An unmeasurable age is a refusal, never a pass — the same fail-closed
149
+ // direction `verifyProcessIdentity` takes for the leader.
150
+ if (etimeSeconds === null) return { match: false, reason: 'start-time-mismatch' };
151
+ const observedStart = nowMs - etimeSeconds * 1000;
152
+ const leaderStart = Number(record?.startTime);
153
+ if (!Number.isFinite(leaderStart)) return { match: false, reason: 'start-time-mismatch' };
154
+ if (observedStart < leaderStart - toleranceMs) {
155
+ return { match: false, reason: 'start-time-mismatch' };
156
+ }
157
+
158
+ const args = typeof row.args === 'string' ? row.args : '';
159
+ const token = signatureTokenOf(record?.commandSignature);
160
+ const firstToken = args.trim().split(/\s+/)[0] ?? '';
161
+ const tokenOk = token.length > 0 && firstToken === token;
162
+ if (!tokenOk && !isReadOnlyCommand(args, readOnlyPatterns)) {
163
+ return { match: false, reason: 'signature-mismatch' };
164
+ }
165
+ return { match: true, reason: 'ok' };
166
+ }
167
+
168
+ /**
169
+ * The command-name half of a command signature (`npm:6f1c…` → `npm`).
170
+ * A local four-liner rather than an import: `process-group.mjs` keeps its own
171
+ * copy private, and re-exporting it for one caller would widen that module's
172
+ * interface for no second consumer.
173
+ *
174
+ * @param {unknown} signature
175
+ * @returns {string}
176
+ */
177
+ function signatureTokenOf(signature) {
178
+ const s = typeof signature === 'string' ? signature : '';
179
+ const i = s.lastIndexOf(':');
180
+ return i === -1 ? s : s.slice(0, i);
181
+ }
182
+
183
+ /**
184
+ * Decide which processes are reapable orphans — PURE.
185
+ *
186
+ * No I/O, no signal, no clock of its own: `nowMs` is an argument. The only
187
+ * imports it reaches are {@link verifyProcessIdentity} and
188
+ * {@link verifyGroupMemberIdentity}, both themselves pure.
189
+ *
190
+ * Exactly ONE verdict per examined row, at a FIXED priority, so a row can never
191
+ * appear twice and a trigger is never ambiguous:
192
+ *
193
+ * 1. not in the ledger → `rejected: not-in-ledger`
194
+ * 2. record with `pgid !== pid` → `rejected: pgid-mismatch`
195
+ * 3. PPID !== 1 → `rejected: has-parent`
196
+ * 4. younger than `minAgeSeconds` → `rejected: too-young`
197
+ * 5. no `sessionId` on the record → `reported: unattributed` (never killed)
198
+ * 6. foreign session, live or of unmeasurable liveness
199
+ * → `reported: foreign-live-session`
200
+ * / `foreign-session-liveness-unknown` (never killed)
201
+ * 7. not a read-only command → `reported: not-read-only` (never killed)
202
+ * 8. identity does not verify → `rejected: identity-mismatch | signature-mismatch`
203
+ * 9. otherwise → `candidates` with `trigger: 'orphan-ppid1'`
204
+ *
205
+ * Step 2 is a LEDGER-INTEGRITY rejection, not a property of the row: under
206
+ * `detached: true` the leader IS its own group, so `pgid === pid` is the
207
+ * documented invariant (`process-group.mjs` — "the child calls setsid, so it IS
208
+ * its own group leader"). A record violating it describes a group this module
209
+ * did not create, and its `pgid` is the value that gets negated and signalled.
210
+ *
211
+ * Step 5 is fail-closed and was inert until 2026-09-22: `sessionId` was `null`
212
+ * in 377 of 377 live records because the gate runner passed none, and a null
213
+ * owner was silently read as "nobody's, therefore mine". An unattributed process
214
+ * is REPORTED — it is not evidence of ownership in either direction.
215
+ *
216
+ * Step 6 precedes step 7 on purpose: a foreign live session's process is
217
+ * reported for its OWNER, whether or not it also happens to be read-only
218
+ * (PRD FA3: "gemeldet, aber nicht automatisch getötet").
219
+ *
220
+ * Named ceiling (BV-004) on the `rejected` volume: every PPID-1 row that is not
221
+ * in the ledger is materialised as `not-in-ledger` — ~538 entries on this
222
+ * 784-process host. That is deliberate (the AC requires the rejection reason to
223
+ * be traceable in the RESULT) and bounded, because only candidates and reports
224
+ * are ever persisted to the audit; `rejected` stays in memory for one scan.
225
+ * Rows with a live parent that are ALSO not in the ledger are skipped entirely —
226
+ * they are neither orphan-shaped nor ours, and materialising them would double
227
+ * the array for no signal. Revisit if a consumer ever persists `rejected`.
228
+ *
229
+ * @param {PsRow[]} snapshot
230
+ * @param {import('../process-group.mjs').GateProcessRecord[]} ledgerRecords
231
+ * @param {number} nowMs
232
+ * @param {object} [opts]
233
+ * @param {string|null} [opts.ownSessionId]
234
+ * @param {string[]|null} [opts.livePeerSessionIds] Session ids `detectPeers()`
235
+ * reports LIVE — or `null` when the probe could not measure at all.
236
+ * @param {number} [opts.minAgeSeconds]
237
+ * @param {readonly RegExp[]} [opts.readOnlyPatterns]
238
+ * @param {number} [opts.identityToleranceMs]
239
+ * @returns {{candidates: object[], reported: object[], rejected: object[]}}
240
+ */
241
+ export function decideReapCandidates(snapshot, ledgerRecords, nowMs, {
242
+ ownSessionId = null,
243
+ livePeerSessionIds = [],
244
+ minAgeSeconds = REAPER_DEFAULTS.minAgeSeconds,
245
+ readOnlyPatterns = READ_ONLY_COMMAND_PATTERNS,
246
+ identityToleranceMs = 2000,
247
+ } = {}) {
248
+ const rows = Array.isArray(snapshot) ? snapshot : [];
249
+ const records = Array.isArray(ledgerRecords) ? ledgerRecords : [];
250
+ // THREE states, not two: a measured list, an empty measured list, or `null`
251
+ // for "liveness could not be measured". A failed peer probe must not read as
252
+ // "no peers are alive" — that would turn an unmeasured foreign session into a
253
+ // kill target (a missing measurement must never look like a zero).
254
+ const peers = livePeerSessionIds === null || livePeerSessionIds === undefined
255
+ ? null
256
+ : new Set((Array.isArray(livePeerSessionIds) ? livePeerSessionIds : [])
257
+ .filter((id) => typeof id === 'string' && id.length > 0));
258
+
259
+ /** Leader index: the pid the ledger recorded. @type {Map<number, object>} */
260
+ const byPid = new Map();
261
+ /** Group index: every member of a recorded group joins through this.
262
+ * @type {Map<number, object>} */
263
+ const byPgid = new Map();
264
+ for (const rec of records) {
265
+ if (!rec || typeof rec.pid !== 'number') continue;
266
+ byPid.set(rec.pid, rec);
267
+ // First record wins for a pgid: a recycled group id would otherwise let a
268
+ // newer record claim an older group's members.
269
+ if (typeof rec.pgid === 'number' && !byPgid.has(rec.pgid)) byPgid.set(rec.pgid, rec);
270
+ }
271
+
272
+ const candidates = [];
273
+ const reported = [];
274
+ const rejected = [];
275
+
276
+ for (const row of rows) {
277
+ // A `pid` hit is the LEADER; a `pgid` hit is any other member of its group
278
+ // (the measured grandchild case — see the module header). The leader wins,
279
+ // so a row is never judged by the weaker of the two identities.
280
+ const leaderRecord = byPid.get(row.pid) ?? null;
281
+ const record = leaderRecord
282
+ ?? (typeof row.pgid === 'number' ? byPgid.get(row.pgid) ?? null : null);
283
+ const isLeader = leaderRecord !== null;
284
+ const orphanShaped = row.ppid === 1;
285
+
286
+ if (!record) {
287
+ if (orphanShaped) {
288
+ rejected.push({
289
+ pid: row.pid, ppid: row.ppid, ageSeconds: row.etimeSeconds, reason: 'not-in-ledger',
290
+ });
291
+ }
292
+ continue;
293
+ }
294
+
295
+ const recordSessionId = typeof record.sessionId === 'string' && record.sessionId.length > 0
296
+ ? record.sessionId
297
+ : null;
298
+ const base = {
299
+ pid: row.pid,
300
+ pgid: record.pgid,
301
+ ppid: row.ppid,
302
+ isLeader,
303
+ ageSeconds: row.etimeSeconds,
304
+ rssKb: row.rssKb,
305
+ cpuPct: row.cpuPct,
306
+ commandSignature: record.commandSignature ?? null,
307
+ // Normalised to `null`: an empty-string owner is an ABSENT owner, and
308
+ // leaving `''` here would let a downstream truthiness check read it as one.
309
+ sessionId: recordSessionId,
310
+ };
311
+
312
+ // Ledger integrity before anything else: `record.pgid` is the value that
313
+ // gets NEGATED and signalled, and `pgid === pid` is the invariant every
314
+ // record this module writes satisfies.
315
+ if (record.pgid !== record.pid) {
316
+ rejected.push({ ...base, reason: 'pgid-mismatch' });
317
+ continue;
318
+ }
319
+
320
+ if (!orphanShaped) {
321
+ rejected.push({ ...base, reason: 'has-parent' });
322
+ continue;
323
+ }
324
+ if (row.etimeSeconds < minAgeSeconds) {
325
+ rejected.push({ ...base, reason: 'too-young', threshold: { minAgeSeconds } });
326
+ continue;
327
+ }
328
+
329
+ if (recordSessionId === null) {
330
+ // Fail-closed: an unowned record is not an unowned PROCESS. Report it and
331
+ // let the operator (or a fixed producer) decide.
332
+ reported.push({ ...base, reason: 'unattributed' });
333
+ continue;
334
+ }
335
+ const foreignSessionId = recordSessionId !== ownSessionId ? recordSessionId : null;
336
+ if (foreignSessionId !== null) {
337
+ if (peers === null) {
338
+ // Unmeasurable liveness → report, never reap. The only foreign process
339
+ // this function reaps is one whose session a SUCCESSFUL probe proved dead.
340
+ reported.push({ ...base, reason: 'foreign-session-liveness-unknown' });
341
+ continue;
342
+ }
343
+ if (peers.has(foreignSessionId)) {
344
+ // `args` ONLY for a command the allowlist already cleared — the same
345
+ // rule `ARGS_HEAD_CHARS` (`reaper-audit.mjs`) states, applied at the source rather than at
346
+ // the audit writer, so a foreign dev server's command line (paths,
347
+ // tokens) never enters the result in the first place.
348
+ reported.push({
349
+ ...base,
350
+ ...(isReadOnlyCommand(row.args, readOnlyPatterns) ? { args: row.args } : {}),
351
+ reason: 'foreign-live-session',
352
+ });
353
+ continue;
354
+ }
355
+ }
356
+
357
+ if (!isReadOnlyCommand(row.args, readOnlyPatterns)) {
358
+ // No `args` here: a command the allowlist did not recognise is not ours to
359
+ // copy around (dev servers, MCP servers, anything with a command line we
360
+ // have no reason to retain).
361
+ reported.push({ ...base, reason: 'not-read-only' });
362
+ continue;
363
+ }
364
+
365
+ const identity = isLeader
366
+ ? verifyProcessIdentity(
367
+ row.pid,
368
+ { startTime: record.startTime, commandSignature: record.commandSignature },
369
+ { snapshotLine: row, nowMs, toleranceMs: identityToleranceMs },
370
+ )
371
+ : verifyGroupMemberIdentity(row, record, {
372
+ nowMs, toleranceMs: identityToleranceMs, readOnlyPatterns,
373
+ });
374
+ if (!identity.match) {
375
+ rejected.push({
376
+ ...base,
377
+ reason: identity.reason === 'signature-mismatch' ? 'signature-mismatch' : 'identity-mismatch',
378
+ identity: { match: identity.match, reason: identity.reason },
379
+ });
380
+ continue;
381
+ }
382
+
383
+ candidates.push({
384
+ ...base,
385
+ args: row.args,
386
+ ledgerRecord: record,
387
+ identity: { match: identity.match, reason: identity.reason },
388
+ trigger: 'orphan-ppid1',
389
+ threshold: { minAgeSeconds },
390
+ actual: { ageSeconds: row.etimeSeconds },
391
+ });
392
+ }
393
+
394
+ return { candidates, reported, rejected };
395
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * orphan-reaper/scan-throttle.mjs — the B4 throttle the trigger calls.
3
+ *
4
+ * Together with `trigger.mjs` this is the reaper's only hot-path surface:
5
+ * `hooks/on-stop.mjs` and `hooks/post-tool-batch-wave-signal.mjs` reach THIS
6
+ * module (lazily, via `trigger.mjs`), not the scan, so a tool batch pays for one
7
+ * `stat` and a few tiny modules — never for the ledger, `ps` or kill-ladder code
8
+ * the detached scan child loads.
9
+ */
10
+
11
+ import { randomBytes } from 'node:crypto';
12
+ import { lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
16
+
17
+ /** Relative path of the scan-throttle marker (B4). Gitignored via
18
+ * `.gitignore:141` (`.orchestrator/tmp/`). */
19
+ export const SCAN_MARKER_RELPATH = '.orchestrator/tmp/reaper-last-scan';
20
+
21
+ /**
22
+ * Throttle gate (B4): may a scan run now?
23
+ *
24
+ * Reads the marker's mtime; a missing or unreadable marker means "yes" — the
25
+ * first scan of a host must not be blocked by the absence of its own throttle
26
+ * file. Writing the marker is the CALLER's job ({@link touchScanMarker}), so
27
+ * this stays a read-only predicate a hook can call cheaply.
28
+ *
29
+ * `lstat`, never `stat`: a symlinked (or otherwise non-regular) marker is not
30
+ * one {@link touchScanMarker} wrote, and `stat` read the TARGET's mtime — so a
31
+ * link to any often-written file (`events.jsonl`) throttled every scan for good
32
+ * (#1487). Such a marker counts as absent, i.e. "scan" — and that scan's stamp
33
+ * replaces a link with a regular marker, so the next fire throttles again
34
+ * ({@link touchScanMarker}). Counting it as absent is the fail-safe
35
+ * direction for this throttle: too many scans cost one detached child per hook
36
+ * fire — visible in the process table, and in the `scan_completed` rate
37
+ * whenever a scan finds anything — in an opt-in state (`reaper.enabled`
38
+ * defaults false); too few silently disable the reaper, whose
39
+ * orphans were measured at 86-588 % CPU and up to 8 GB RSS (HR-107), and a
40
+ * disabled reaper is a state nothing can falsify (HR-105).
41
+ *
42
+ * @param {string} markerPath Absolute path — build it with {@link scanMarkerPath}.
43
+ * @param {number} nowMs
44
+ * @param {number} [minIntervalSeconds]
45
+ * @param {object} [opts]
46
+ * @param {(p: string) => {mtimeMs: number, isFile?: () => boolean}} [opts.statFn]
47
+ * Defaults to `lstatSync`; an injected stat without `isFile` skips the
48
+ * regular-file check (test seam).
49
+ * @returns {boolean}
50
+ */
51
+ export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEFAULTS.minScanIntervalSeconds, {
52
+ statFn = lstatSync,
53
+ } = {}) {
54
+ if (typeof markerPath !== 'string' || markerPath.length === 0) return false;
55
+ let stats;
56
+ try {
57
+ stats = statFn(markerPath);
58
+ } catch {
59
+ return true; // no marker yet → first scan
60
+ }
61
+ if (typeof stats?.isFile === 'function' && !stats.isFile()) return true;
62
+ const mtimeMs = stats?.mtimeMs;
63
+ if (typeof mtimeMs !== 'number' || Number.isNaN(mtimeMs)) return true;
64
+ // A marker stamped in the future (`touch -t`, a clock stepped backwards) made
65
+ // the age negative, so every fire read "too soon" — and the marker is only
66
+ // re-stamped AFTER a scan, so the reaper never scanned again (#1487). Fail
67
+ // toward a scan, the same direction as every other unreadable marker here.
68
+ if (mtimeMs > nowMs) return true;
69
+ return (nowMs - mtimeMs) >= minIntervalSeconds * 1000;
70
+ }
71
+
72
+ /**
73
+ * Stamp the throttle marker. Best-effort and never throws — a marker that could
74
+ * not be written means the next scan runs, which is the safe direction for a
75
+ * read-only probe (the reasoning is on {@link shouldScanNow}). Callers must not
76
+ * read a `false` as a stamped throttle: `maybeTriggerOrphanScan`
77
+ * (`trigger.mjs`) returns it as `reason: 'spawned-unthrottled'`, and
78
+ * `hooks/on-stop.mjs` records that reason as `reaper_trigger` on its
79
+ * Stop/SubagentStop records — so an unwritable marker is countable in
80
+ * events.jsonl, not only visible as one detached scan child per hook fire.
81
+ *
82
+ * Written to a sibling tmp file, then renamed over the marker path. A rename
83
+ * replaces the directory ENTRY and never follows a symlink sitting there, so a
84
+ * planted or accidental link is healed by the next stamp — not written through
85
+ * (CWE-59: `writeFileSync` on the marker replaced the link's target), and not
86
+ * refused either: refusing it returned `false` on every fire, which switched the
87
+ * throttle off for good (#1489 Pkt 9). With no lstat-then-write step there is no
88
+ * check-to-use window left. The tmp name carries pid + random bytes and is
89
+ * opened `wx` (O_EXCL), so anything already at that name fails the stamp
90
+ * instead of being written through; a tmp this call created is removed again
91
+ * when the rename fails, or every failed fire would leave one behind.
92
+ *
93
+ * A DIRECTORY at the marker path is the one shape that stays unhealed: a rename
94
+ * cannot replace it (EISDIR), and removing it would delete content this module
95
+ * never wrote. It stays `false`. Silent, like every other failure here — this
96
+ * runs inside a 50 ms hook budget.
97
+ * Named ceiling (BV-004): the PARENT path is resolved normally, so a symlinked
98
+ * `.orchestrator/tmp/` puts the marker into the link's target directory, where
99
+ * it can replace only an entry of its own name. Acceptable for a gitignored
100
+ * throttle stamp; revisit if the marker ever carries data.
101
+ *
102
+ * @param {string} markerPath
103
+ * @param {object} [opts]
104
+ * @param {(p: string, data: string) => void} [opts.writeFn]
105
+ * @returns {boolean} whether the marker was written
106
+ */
107
+ export function touchScanMarker(markerPath, { writeFn } = {}) {
108
+ try {
109
+ const stamp = `${new Date().toISOString()}\n`;
110
+ if (writeFn) {
111
+ writeFn(markerPath, stamp);
112
+ return true;
113
+ }
114
+ mkdirSync(path.dirname(markerPath), { recursive: true });
115
+ const tmp = `${markerPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
116
+ try {
117
+ writeFileSync(tmp, stamp, { encoding: 'utf8', flag: 'wx' });
118
+ } catch (err) {
119
+ // EEXIST: the `wx` open was refused, so the entry at `tmp` is not ours.
120
+ if (err?.code !== 'EEXIST') removeOwnTmp(tmp);
121
+ return false;
122
+ }
123
+ try {
124
+ renameSync(tmp, markerPath);
125
+ return true;
126
+ } catch {
127
+ removeOwnTmp(tmp);
128
+ return false;
129
+ }
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /** Best-effort removal of a tmp file {@link touchScanMarker} created. @param {string} tmp */
136
+ function removeOwnTmp(tmp) {
137
+ try { unlinkSync(tmp); } catch { /* never created, or already gone */ }
138
+ }
139
+
140
+ /** Absolute path of the throttle marker for a repo. One constant per path.
141
+ * @param {string} repoRoot @returns {string} */
142
+ export function scanMarkerPath(repoRoot) {
143
+ return underRepo(repoRoot, SCAN_MARKER_RELPATH);
144
+ }