session-orchestrator 5.3.0 → 5.4.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -0,0 +1,41 @@
1
+ /**
2
+ * orphan-reaper/defaults.mjs — the reaper's parameter table and its repo-path join.
3
+ *
4
+ * Imports nothing but `node:path` on purpose: the trigger hooks reach it through
5
+ * `scan-throttle.mjs` on a hot path whose import closure is measured (#1432), and
6
+ * every other reaper module shares these numbers instead of re-spelling them.
7
+ */
8
+
9
+ import path from 'node:path';
10
+
11
+ /**
12
+ * Stufe-1 parameters. Every number carries its provenance from the PRD
13
+ * parameter table (`docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md`
14
+ * § 4) — NOT calibrated, so the provenance is what makes a later re-measurement
15
+ * possible ("keine Zahl ohne ihre Population").
16
+ */
17
+ export const REAPER_DEFAULTS = Object.freeze({
18
+ /** `reaper.min-age-seconds`: DevWatchdogs' hard limit for `tsgo`; the
19
+ * 2026-09-20 orphans were 7-17 min old, comfortably above it. */
20
+ minAgeSeconds: 300,
21
+ /** `reaper.min-scan-interval-seconds`: DevWatchdogs' normal scan cadence —
22
+ * keeps a `PostToolBatch` storm from taxing every tool call. */
23
+ minScanIntervalSeconds: 30,
24
+ /** `reaper.kill-grace-ms`: `DEFAULT_KILL_GRACE_MS` from `process-group.mjs` —
25
+ * a repo convention, not a new number. A literal rather than an import so this
26
+ * module stays out of `process-group.mjs`'s closure. */
27
+ killGraceMs: 10_000,
28
+ /** `reaper.verify-wait-ms`: without it the 2026-09-20 hand-run cleanup
29
+ * reported "still alive" for processes that were already gone. */
30
+ verifyWaitMs: 500,
31
+ /** `reaper.max-hook-latency-ms`: ceiling a scan may delay a hook by. */
32
+ maxHookLatencyMs: 50,
33
+ /** `reaper.false-alarm-window`: last N audit decisions — a ROLLING window
34
+ * rather than calendar time, so a quiet host still has a population (HR-101). */
35
+ falseAlarmWindow: 50,
36
+ });
37
+
38
+ /** @param {string} repoRoot @param {string} relpath @returns {string} */
39
+ export function underRepo(repoRoot, relpath) {
40
+ return path.join(repoRoot, ...relpath.split('/'));
41
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * orphan-reaper/ps-snapshot.mjs — the binding `ps` format: how the reaper
3
+ * measures the process table, and how it reads what it measured.
4
+ */
5
+
6
+ import { spawn } from 'node:child_process';
7
+
8
+ import { parseEtimeToSeconds } from '../resource-probe/parsers.mjs';
9
+
10
+ /**
11
+ * The binding `ps` invocation (Discovery d-2, 2026-09-21, Darwin 25.6.0).
12
+ *
13
+ * Headerless (`=` per field) and six NUMERIC fields before `args`, because on
14
+ * macOS `comm` is a 16-character-truncated PATH that may contain spaces (68 of
15
+ * 784 processes carried a space, 13 of them survived the truncation) — appending
16
+ * `args` after `comm` breaks whitespace splitting outright. `-ww` disables column
17
+ * truncation; `ps` escapes control characters, so one process is one line.
18
+ * `rss` is in KiB. Roundtrip measured at ~47 ms for 287 KB / 784 processes.
19
+ *
20
+ * `pgid=` is the sixth column and is what lets a row join the ledger as a GROUP
21
+ * MEMBER rather than only as the recorded leader (see `reaper-decide.mjs`).
22
+ * Availability measured 2026-09-22 on Darwin 25.6.0 — `ps -Aww -o
23
+ * pid=,ppid=,pgid=,rss=,etime=,%cpu=,args=` exits 0 and prints the column; it is
24
+ * POSIX (`pgid` is a standard `-o` keyword) and present on Linux `procps` too.
25
+ *
26
+ * A targeted call, NOT the full `probe()` — that one spawns up to five
27
+ * subprocesses and has no caching (PRD § B4).
28
+ */
29
+ export const PS_ARGS = Object.freeze(['-Aww', '-o', 'pid=,ppid=,pgid=,rss=,etime=,%cpu=,args=']);
30
+
31
+ /**
32
+ * The TARGETED variant of {@link PS_ARGS}: one pid, same seven columns.
33
+ *
34
+ * Used immediately before every signal and once after the ladder (B3/B6). It is
35
+ * a separate call rather than a re-run of the full `-A` scan because the whole
36
+ * point is freshness at the moment of signalling — a 784-row snapshot taken for
37
+ * the population is already stale by the time the ladder's 10 s grace elapses,
38
+ * and re-taking it per signal would cost 287 KB to learn one row.
39
+ *
40
+ * @param {number} pid
41
+ * @returns {string[]}
42
+ */
43
+ export function psPidArgs(pid) {
44
+ return ['-ww', '-p', String(pid), '-o', 'pid=,ppid=,pgid=,rss=,etime=,%cpu=,args='];
45
+ }
46
+
47
+ /** Wall-clock ceiling for one `ps` call. Same 2 s as `runPsDetailed()` in
48
+ * `resource-probe/probe-platform.mjs`, whose spawn/timeout shape this mirrors. */
49
+ const PS_TIMEOUT_MS = 2000;
50
+
51
+ /**
52
+ * One parsed `ps` row.
53
+ * @typedef {object} PsRow
54
+ * @property {number} pid
55
+ * @property {number} ppid
56
+ * @property {number} pgid Process-group id — the ledger's group join key.
57
+ * @property {number} rssKb Resident set size in KiB.
58
+ * @property {number} etimeSeconds Elapsed seconds since exec.
59
+ * @property {number} cpuPct
60
+ * @property {string} args Full command line.
61
+ */
62
+
63
+ /**
64
+ * Parse `ps` output in the {@link PS_ARGS} format, tolerantly — and COUNT what
65
+ * it drops.
66
+ *
67
+ * A silently skipping parser turns a partial read into a clean verdict, which is
68
+ * the exact failure mode a reaper must not have; hence the `malformed` count
69
+ * ({@link parsePsSnapshotDetailed}). A row whose `etime` does not parse is
70
+ * treated as malformed rather than returned with a null age: every returned row
71
+ * must carry a usable age, because age is a load-bearing gate here.
72
+ *
73
+ * @param {string|null|undefined} text
74
+ * @returns {{rows: PsRow[], malformed: number}}
75
+ */
76
+ export function parsePsSnapshotDetailed(text) {
77
+ if (text === null || text === undefined) return { rows: [], malformed: 0 };
78
+ /** @type {PsRow[]} */
79
+ const rows = [];
80
+ let malformed = 0;
81
+ // Six whitespace-free fields, then `args` as the ENTIRE rest of the line —
82
+ // args legitimately contains spaces, so it must never be split.
83
+ const rowRe = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
84
+ for (const raw of String(text).split(/\r?\n/)) {
85
+ if (raw.trim().length === 0) continue;
86
+ const m = rowRe.exec(raw);
87
+ if (!m) { malformed += 1; continue; }
88
+ const etimeSeconds = parseEtimeToSeconds(m[5]);
89
+ const cpuPct = parseFloat(m[6]);
90
+ if (etimeSeconds === null || Number.isNaN(cpuPct)) { malformed += 1; continue; }
91
+ rows.push({
92
+ pid: parseInt(m[1], 10),
93
+ ppid: parseInt(m[2], 10),
94
+ pgid: parseInt(m[3], 10),
95
+ rssKb: parseInt(m[4], 10),
96
+ etimeSeconds,
97
+ cpuPct,
98
+ args: m[7] ?? '',
99
+ });
100
+ }
101
+ return { rows, malformed };
102
+ }
103
+
104
+ /**
105
+ * {@link parsePsSnapshotDetailed} without the drop count.
106
+ * @param {string|null|undefined} text
107
+ * @returns {PsRow[]}
108
+ */
109
+ export function parsePsSnapshot(text) {
110
+ return parsePsSnapshotDetailed(text).rows;
111
+ }
112
+
113
+ /**
114
+ * Default `ps` runner: the {@link PS_ARGS} call with a hard 2 s deadline,
115
+ * SIGKILL on overrun, and `null` on any failure. Never throws.
116
+ *
117
+ * Mirrors `runPsDetailed()` in `resource-probe/probe-platform.mjs` — the same
118
+ * spawn/settle/timeout shape, a different column set.
119
+ *
120
+ * @param {number} [timeoutMs]
121
+ * @returns {Promise<string|null>}
122
+ */
123
+ export function runPs(timeoutMs = PS_TIMEOUT_MS) {
124
+ return runPsArgs([...PS_ARGS], timeoutMs);
125
+ }
126
+
127
+ /**
128
+ * Default TARGETED `ps` runner (B3/B6): {@link psPidArgs} with the same hard
129
+ * deadline and the same `null`-on-any-failure contract as {@link runPs}.
130
+ *
131
+ * `ps -p <gone-pid>` exits NON-ZERO with empty stdout on macOS, which
132
+ * {@link runPsArgs} maps to `null` — so "gone" and "could not measure" arrive
133
+ * here as the same value. That is why the caller treats a `null` as UNMEASURED
134
+ * and refuses the signal, rather than reading it as proof of death: refusing on
135
+ * an absent measurement costs one skipped reap, believing it costs a bystander.
136
+ *
137
+ * @param {number} pid
138
+ * @param {number} [timeoutMs]
139
+ * @returns {Promise<string|null>}
140
+ */
141
+ export function runPsPid(pid, timeoutMs = PS_TIMEOUT_MS) {
142
+ if (!Number.isInteger(pid) || pid <= 0) return Promise.resolve(null);
143
+ return runPsArgs(psPidArgs(pid), timeoutMs);
144
+ }
145
+
146
+ /**
147
+ * Spawn `ps` with `args`, capped at `timeoutMs`, `null` on any failure.
148
+ * Never throws. Mirrors `runPsDetailed()` in `resource-probe/probe-platform.mjs`.
149
+ *
150
+ * @param {string[]} args
151
+ * @param {number} timeoutMs
152
+ * @returns {Promise<string|null>}
153
+ */
154
+ function runPsArgs(args, timeoutMs) {
155
+ return new Promise((resolve) => {
156
+ if (process.platform === 'win32') { resolve(null); return; }
157
+ let settled = false;
158
+ const finish = (value) => {
159
+ if (settled) return;
160
+ settled = true;
161
+ resolve(value);
162
+ };
163
+ try {
164
+ const child = spawn('ps', args, { stdio: ['ignore', 'pipe', 'ignore'] });
165
+ const chunks = [];
166
+ child.stdout.on('data', (c) => chunks.push(c));
167
+ child.on('error', () => finish(null));
168
+ child.on('close', (code) => {
169
+ if (code !== 0) return finish(null);
170
+ finish(Buffer.concat(chunks).toString('utf8'));
171
+ });
172
+ const timer = setTimeout(() => {
173
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
174
+ finish(null);
175
+ }, timeoutMs);
176
+ timer.unref?.();
177
+ } catch {
178
+ finish(null);
179
+ }
180
+ });
181
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * orphan-reaper/reaper-audit.mjs — the B5 kill audit: its record shape, its
3
+ * writer, its bounded reader and pruner, and the HR-101 false-alarm rate the
4
+ * reader exists for.
5
+ */
6
+
7
+ import {
8
+ appendFileSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ renameSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ import { readTailWindow } from '../tail-window.mjs';
19
+ import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
20
+
21
+ /** Relative path of the JSONL kill audit (B5). Gitignored via `.gitignore:55`
22
+ * (`.orchestrator/metrics/*.jsonl`), verified with `git check-ignore --no-index -v`.
23
+ * Under the `ledger-delete-protected` policy rule: in-process fs only, never a
24
+ * shell `rm`/`mv`. */
25
+ export const REAPER_AUDIT_RELPATH = '.orchestrator/metrics/reaper-audit.jsonl';
26
+
27
+ /**
28
+ * HR-101 ceiling: above this false-alarm rate the INSTRUMENT is suspect, and
29
+ * the answer is to re-aim it — never to raise the threshold it fires on. Surfaced
30
+ * as `instrumentSuspect` in the scan result; nothing in the reaper acts on it,
31
+ * because acting on a broken instrument is the failure it names.
32
+ */
33
+ export const FALSE_ALARM_SUSPECT_RATE = 0.1;
34
+
35
+ /** Characters of `args` copied into an audit record — and ONLY for a command
36
+ * that already matched the read-only allowlist, so a foreign process's command
37
+ * line (which may carry paths or tokens) never reaches the audit. */
38
+ const ARGS_HEAD_CHARS = 80;
39
+
40
+ /**
41
+ * Size ceiling of the audit file and what a prune keeps. Named ceiling (BV-004):
42
+ * a `kill` record with `args_head` and `result` serialises to 505 bytes, a
43
+ * `report` record to 285 (measured on {@link buildAuditRecord} output 2026-10-01;
44
+ * no real audit file existed on this host), so the kept 512 KiB hold ~1,000-1,800
45
+ * decisions against the 50 the false-alarm window reads. Revisit if
46
+ * `reaper.false-alarm-window` is ever configured above ~1,000.
47
+ */
48
+ const REAPER_AUDIT_MAX_BYTES = 1024 * 1024;
49
+ const REAPER_AUDIT_KEEP_BYTES = 512 * 1024;
50
+
51
+ /** First tail window {@link readAuditRecords} tries; it grows ×4 only while the
52
+ * window holds fewer complete lines than requested. */
53
+ const AUDIT_TAIL_START_BYTES = 64 * 1024;
54
+
55
+ /** Absolute path of the kill audit for a repo. One constant per path.
56
+ * @param {string} repoRoot @returns {string} */
57
+ export function auditPath(repoRoot) {
58
+ return underRepo(repoRoot, REAPER_AUDIT_RELPATH);
59
+ }
60
+
61
+ /**
62
+ * Build one B5 audit record.
63
+ *
64
+ * Exactly ONE `trigger` per record, taken from the single verdict
65
+ * `decideReapCandidates` assigned at its fixed priority — a kill carries
66
+ * `orphan-ppid1`, a report carries the reason it was reported for. That is what
67
+ * makes "why exactly was this killed" answerable afterwards; a record listing
68
+ * two triggers would answer it with a shrug.
69
+ *
70
+ * `reason` is separate from `trigger` and present on every WITHDRAWAL: the
71
+ * trigger says what made this a candidate, the reason says what took it back.
72
+ * `args_head` is present ONLY for a command that cleared the read-only
73
+ * allowlist — see {@link ARGS_HEAD_CHARS}.
74
+ *
75
+ * @param {object} entry A candidate or a reported/rejected entry.
76
+ * @param {'kill'|'report'|'reject'|'dry-run'} decision
77
+ * @param {object} [extra]
78
+ * @param {string} extra.timestamp
79
+ * @param {string|null} [extra.sessionId]
80
+ * @param {object} [extra.result]
81
+ * @param {string} [extra.reason]
82
+ * @returns {object}
83
+ */
84
+ export function buildAuditRecord(entry, decision, {
85
+ timestamp, sessionId = null, result, reason,
86
+ } = {}) {
87
+ /** @type {Record<string, unknown>} */
88
+ const record = {
89
+ timestamp,
90
+ session_id: sessionId,
91
+ pid: entry.pid,
92
+ pgid: entry.pgid ?? null,
93
+ trigger: entry.trigger ?? entry.reason ?? null,
94
+ threshold: entry.threshold ?? null,
95
+ actual: entry.actual ?? (typeof entry.ageSeconds === 'number' ? { ageSeconds: entry.ageSeconds } : null),
96
+ unit: 'seconds',
97
+ command_signature: entry.commandSignature ?? null,
98
+ decision,
99
+ };
100
+ if (typeof entry.args === 'string' && entry.args.length > 0) {
101
+ record.args_head = entry.args.slice(0, ARGS_HEAD_CHARS);
102
+ }
103
+ if (result !== undefined) record.result = result;
104
+ const effectiveReason = reason ?? (decision === 'report' ? entry.reason : undefined);
105
+ if (effectiveReason !== undefined && effectiveReason !== null) record.reason = effectiveReason;
106
+ return record;
107
+ }
108
+
109
+ /**
110
+ * Default audit sink: append one JSONL line to {@link REAPER_AUDIT_RELPATH}.
111
+ * In-process fs only (the path sits under the `ledger-delete-protected` policy
112
+ * rule). Best-effort — an audit write must never fail a scan — but a failure
113
+ * prints one WARN line rather than vanishing.
114
+ *
115
+ * @param {string} repoRoot
116
+ * @param {object} record
117
+ * @returns {void}
118
+ */
119
+ export function appendAuditRecord(repoRoot, record) {
120
+ const target = auditPath(repoRoot);
121
+ try {
122
+ mkdirSync(path.dirname(target), { recursive: true });
123
+ appendFileSync(target, `${JSON.stringify(record)}\n`, 'utf8');
124
+ } catch (err) {
125
+ process.stderr.write(
126
+ `orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`,
127
+ );
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Default audit READER — the other half of {@link appendAuditRecord}, and the
133
+ * data source {@link falseAlarmRate} needs (B5: "Dieselbe Datei ist die
134
+ * Datenquelle für `reaper.false-alarm-window`").
135
+ *
136
+ * Returns the parsed records among the LAST `limit` non-empty lines, oldest
137
+ * first — the same population a whole-file read sliced to `limit` lines yields,
138
+ * read from a tail window instead. The window grows only while it holds fewer
139
+ * complete lines than `limit`, so records larger than expected cannot shrink the
140
+ * population; {@link pruneReaperAudit} bounds the worst case at the whole file.
141
+ * The first line of a window that does not start at byte 0 is a fragment and is
142
+ * dropped before anything is parsed.
143
+ *
144
+ * A malformed line is dropped from the rate's population rather than counted,
145
+ * because an unreadable record carries no decision to classify — and the rate
146
+ * reports its own `n`, so a shrinking population is visible in the number's
147
+ * denominator.
148
+ *
149
+ * @param {string} repoRoot
150
+ * @param {number} [limit]
151
+ * @returns {object[]} parsed records in chronological order; `[]` on any failure
152
+ */
153
+ export function readAuditRecords(repoRoot, limit = REAPER_DEFAULTS.falseAlarmWindow) {
154
+ const target = auditPath(repoRoot);
155
+ const want = Math.max(1, limit);
156
+ let lines;
157
+ try {
158
+ let windowBytes = AUDIT_TAIL_START_BYTES;
159
+ for (;;) {
160
+ const { text, cut } = readTailWindow(target, windowBytes);
161
+ const all = text.split('\n');
162
+ if (cut) all.shift();
163
+ lines = all.filter((l) => l.trim().length > 0);
164
+ if (!cut || lines.length >= want) break;
165
+ windowBytes *= 4;
166
+ }
167
+ } catch {
168
+ return [];
169
+ }
170
+ const records = [];
171
+ for (const line of lines.slice(-want)) {
172
+ try {
173
+ const parsed = JSON.parse(line);
174
+ if (parsed && typeof parsed === 'object') records.push(parsed);
175
+ } catch {
176
+ /* an unreadable line carries no decision to classify */
177
+ }
178
+ }
179
+ return records;
180
+ }
181
+
182
+ /**
183
+ * Replace the audit atomically: write `<target>.tmp-<pid>` beside it, then
184
+ * rename over it. Throws instead of writing when the target is a symlink or not
185
+ * a regular file. `writeFileSync(target)` would truncate first — a scan child
186
+ * killed between truncate and write left the "why was this killed" record empty
187
+ * — and would write THROUGH a symlink, cutting e.g. a linked `events.jsonl` to
188
+ * 512 KiB (CWE-59, reproduced 2026-10-01). `rename` replaces the link itself,
189
+ * and `wx` refuses a pre-planted file or link at the tmp name. The same few
190
+ * lines as `process-group.mjs`'s `replaceRegularFile`, kept local because that
191
+ * module is not this one's to depend on for a file write.
192
+ *
193
+ * @param {string} target
194
+ * @param {string} body
195
+ */
196
+ function replaceRegularFile(target, body) {
197
+ if (!lstatSync(target).isFile()) {
198
+ throw new Error('not a regular file (a symlink is never written through) — left untouched');
199
+ }
200
+ const tmp = `${target}.tmp-${process.pid}`;
201
+ try {
202
+ writeFileSync(tmp, body, { encoding: 'utf8', flag: 'wx' });
203
+ renameSync(tmp, target);
204
+ } catch (err) {
205
+ // EEXIST: the tmp name belongs to someone else — never remove it.
206
+ if (err?.code !== 'EEXIST') {
207
+ try { unlinkSync(tmp); } catch { /* nothing was created */ }
208
+ }
209
+ throw err;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Size-bounded prune of the audit: once the file passes `maxBytes`, rewrite it
215
+ * atomically with its newest `keepBytes`, cut at a line boundary. The same
216
+ * rewrite shape as `pruneGateProcessLedger` (`process-group.mjs`), the other
217
+ * ledger the scan's housekeeping prunes; the audit is pruned by SIZE rather than
218
+ * age because its population is a rolling window of decisions, not of days.
219
+ *
220
+ * Never throws; a failure — including a symlinked or non-regular audit, which
221
+ * is refused rather than written through — prints one WARN line and leaves the
222
+ * file as it was.
223
+ * Named ceiling (BV-004): an append that lands between the read and the write
224
+ * is lost — a window of one read + one write, once per ~0.5 MB of audit.
225
+ *
226
+ * @param {string} repoRoot
227
+ * @param {object} [opts]
228
+ * @param {number} [opts.maxBytes]
229
+ * @param {number} [opts.keepBytes]
230
+ * @returns {number} bytes removed; 0 when nothing was pruned
231
+ */
232
+ export function pruneReaperAudit(repoRoot, {
233
+ maxBytes = REAPER_AUDIT_MAX_BYTES,
234
+ keepBytes = REAPER_AUDIT_KEEP_BYTES,
235
+ } = {}) {
236
+ const target = auditPath(repoRoot);
237
+ let size;
238
+ try {
239
+ size = statSync(target).size;
240
+ } catch {
241
+ return 0;
242
+ }
243
+ if (size <= maxBytes) return 0;
244
+ try {
245
+ const { text, cut } = readTailWindow(target, Math.min(keepBytes, maxBytes));
246
+ // A cut window starts mid-record; with no newline at all it is ONE record
247
+ // larger than the window, and keeping any part of it would keep a fragment.
248
+ const newline = text.indexOf('\n');
249
+ const kept = !cut ? text : (newline === -1 ? '' : text.slice(newline + 1));
250
+ replaceRegularFile(target, kept);
251
+ return size - Buffer.byteLength(kept, 'utf8');
252
+ } catch (err) {
253
+ process.stderr.write(
254
+ `orphan-reaper: could not prune ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`,
255
+ );
256
+ return 0;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * False-alarm rate over the last `windowSize` audit decisions — the HR-101
262
+ * instrument-health check ("a signal may only warn if it is rare"; above ~10%
263
+ * the instrument is broken and gets re-aimed, never re-thresholded).
264
+ *
265
+ * POPULATION (the number's denominator, stated because a rate without one is a
266
+ * claim): audit records where the reaper judged a process a reapable orphan —
267
+ * `decision` in `kill` | `dry-run` | `reject`. `report` records are excluded:
268
+ * reporting a foreign or non-read-only process is the correct outcome, not a
269
+ * firing of the kill signal.
270
+ *
271
+ * FALSE ALARM: a record where that judgement was refuted afterwards —
272
+ * `decision: 'reject'` (the identity re-check withdrew the candidate) or a kill
273
+ * whose `result.ok` is false (the signal did not take effect).
274
+ *
275
+ * Returns `{rate: null}` below 10 records: a rate over a handful of decisions
276
+ * says nothing, and `null` is distinguishable from a measured 0 (a missing
277
+ * measurement must never look like a zero).
278
+ *
279
+ * @param {object[]} auditRecords In chronological order; the LAST `windowSize` are used.
280
+ * @param {number} [windowSize]
281
+ * @returns {{rate: number|null, n: number}}
282
+ */
283
+ export function falseAlarmRate(auditRecords, windowSize = REAPER_DEFAULTS.falseAlarmWindow) {
284
+ const all = Array.isArray(auditRecords) ? auditRecords : [];
285
+ const firings = all.filter((r) => r && (r.decision === 'kill' || r.decision === 'dry-run' || r.decision === 'reject'));
286
+ const window = windowSize > 0 ? firings.slice(-windowSize) : firings;
287
+ const n = window.length;
288
+ if (n < 10) return { rate: null, n };
289
+ const falseAlarms = window.filter((r) => r.decision === 'reject' || r?.result?.ok === false).length;
290
+ return { rate: falseAlarms / n, n };
291
+ }