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,87 @@
1
+ /**
2
+ * orphan-reaper/no-follow-append.mjs — the ONE hardened JSONL append the
3
+ * reaper's two ledgers share: the B5 kill audit (`reaper-audit.mjs`) and the
4
+ * gate-process ledger (`../process-group.mjs`). It was inline in the audit
5
+ * writer; the gate ledger still used a plain `appendFileSync(path)` (#1489).
6
+ *
7
+ * Imports nothing but `node:fs`, so `process-group.mjs` takes it without
8
+ * growing its closure. The read-side twin is `readTailWindow(file, n,
9
+ * { noFollow: true })` in `../tail-window.mjs`.
10
+ */
11
+
12
+ import { appendFileSync, closeSync, constants as fsConstants, fstatSync, openSync } from 'node:fs';
13
+
14
+ /** The one refusal every hardened ledger writer prints — the appends here and
15
+ * the atomic prune rewrites (`replaceRegularFile`) in both ledger modules. */
16
+ export const NOT_A_REGULAR_FILE = 'not a regular file (a symlink is never written through) — left untouched';
17
+
18
+ /**
19
+ * `open(2)` flags of the append. `O_NOFOLLOW` makes the kernel refuse a
20
+ * symlink as the last path component (ELOOP) in the same call that opens the
21
+ * file, so there is no lstat-then-open window; `O_NONBLOCK` turns a planted FIFO
22
+ * with no reader into ENXIO instead of blocking in `open` (a plain append open
23
+ * of a FIFO waits for a reader that never comes — reproduced 2026-10-02, #1487).
24
+ * On a regular file `O_NONBLOCK` changes nothing.
25
+ * Named ceiling (BV-004): both are POSIX-only — Node leaves them undefined on
26
+ * Windows, where a linked ledger is still followed; revisit if the reaper or the
27
+ * gate ledger ever runs there.
28
+ */
29
+ const APPEND_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
30
+ | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
31
+
32
+ /** @returns {Error & {code: string}} */
33
+ function notARegularFile() {
34
+ return Object.assign(new Error(NOT_A_REGULAR_FILE), { code: 'ERR_NOT_REGULAR_FILE' });
35
+ }
36
+
37
+ /**
38
+ * Append `data` to `target`, creating it if absent — but never THROUGH a link
39
+ * and never blocked by a FIFO.
40
+ *
41
+ * The check is the open itself ({@link APPEND_FLAGS}) plus an `fstat` of the
42
+ * descriptor that was opened — never a separate lstat. That `fstat` refuses
43
+ * what the open let through: a FIFO that HAD a reader, a directory, a device,
44
+ * and a HARD-linked file (`nlink !== 1`): `ln <victim> <ledger>` passes every
45
+ * symlink check, and each append would then land in the victim (reproduced
46
+ * 2026-10-02 against the audit).
47
+ *
48
+ * THROWS — mapping a failure to a WARN line is the caller's job, as with
49
+ * `readTailWindow`. A symlink or any non-regular file throws
50
+ * `code: 'ERR_NOT_REGULAR_FILE'` with message {@link NOT_A_REGULAR_FILE}; a
51
+ * hard-linked file throws `code: 'ERR_HARD_LINKED'`; a FIFO without a reader
52
+ * throws the kernel's ENXIO unchanged; every other fs error passes through.
53
+ *
54
+ * @param {string} target Absolute path; its directory must already exist.
55
+ * @param {string} data
56
+ * @returns {void}
57
+ */
58
+ export function appendNoFollow(target, data) {
59
+ let fd;
60
+ try {
61
+ fd = openSync(target, APPEND_FLAGS);
62
+ } catch (err) {
63
+ throw err?.code === 'ELOOP' ? notARegularFile() : err;
64
+ }
65
+ try {
66
+ const stats = fstatSync(fd);
67
+ if (!stats.isFile()) throw notARegularFile();
68
+ // nlink 0: the name was replaced between open and fstat (a concurrent prune
69
+ // renames a fresh inode over it). Not a planted link — say so, so the
70
+ // operator does not go hunting for one.
71
+ if (stats.nlink === 0) {
72
+ throw Object.assign(
73
+ new Error('replaced while being opened (a concurrent rewrite renamed a new file over it) — this record was not written'),
74
+ { code: 'ERR_UNLINKED' },
75
+ );
76
+ }
77
+ if (stats.nlink !== 1) {
78
+ throw Object.assign(
79
+ new Error(`hard-linked (${stats.nlink} names) — an append would write into every one of them; left untouched`),
80
+ { code: 'ERR_HARD_LINKED' },
81
+ );
82
+ }
83
+ appendFileSync(fd, data, 'utf8');
84
+ } finally {
85
+ try { closeSync(fd); } catch { /* nothing left to release */ }
86
+ }
87
+ }
@@ -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,323 @@
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 { mkdirSync, statSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { replaceRegularFile } from '../process-group.mjs';
11
+ import { readTailWindow } from '../tail-window.mjs';
12
+ import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
13
+ import { NOT_A_REGULAR_FILE, appendNoFollow } from './no-follow-append.mjs';
14
+
15
+ /** Relative path of the JSONL kill audit (B5). Gitignored via `.gitignore:55`
16
+ * (`.orchestrator/metrics/*.jsonl`), verified with `git check-ignore --no-index -v`.
17
+ * Under the `ledger-delete-protected` policy rule: in-process fs only, never a
18
+ * shell `rm`/`mv`. */
19
+ export const REAPER_AUDIT_RELPATH = '.orchestrator/metrics/reaper-audit.jsonl';
20
+
21
+ /**
22
+ * HR-101 ceiling: above this false-alarm rate the INSTRUMENT is suspect, and
23
+ * the answer is to re-aim it — never to raise the threshold it fires on. Surfaced
24
+ * as `instrumentSuspect` in the scan result; nothing in the reaper acts on it,
25
+ * because acting on a broken instrument is the failure it names.
26
+ */
27
+ export const FALSE_ALARM_SUSPECT_RATE = 0.1;
28
+
29
+ /** Characters of `args` copied into an audit record — and ONLY for a command
30
+ * that already matched the read-only allowlist, so a foreign process's command
31
+ * line (which may carry paths or tokens) never reaches the audit. */
32
+ const ARGS_HEAD_CHARS = 80;
33
+
34
+ /**
35
+ * Size ceiling of the audit file and what a prune keeps. Named ceiling (BV-004):
36
+ * a `kill` record with `args_head` and `result` serialises to 505 bytes, a
37
+ * `report` record to 285 (measured on {@link buildAuditRecord} output 2026-10-01;
38
+ * no real audit file existed on this host), so the kept 512 KiB hold ~1,000-1,800
39
+ * records against the 50 decisions the false-alarm window reads — fewer
40
+ * decisions where reports dominate: a scan re-reporting R processes keeps
41
+ * ~1,800/(R+1) of them, below 50 from R ≈ 35. Revisit if
42
+ * `reaper.false-alarm-window` is ever configured above ~1,000.
43
+ */
44
+ const REAPER_AUDIT_MAX_BYTES = 1024 * 1024;
45
+ const REAPER_AUDIT_KEEP_BYTES = 512 * 1024;
46
+
47
+ /** First tail window {@link readAuditRecords} tries; it grows ×4 only while the
48
+ * window holds fewer complete lines, or fewer rate decisions, than requested,
49
+ * and never past {@link REAPER_AUDIT_MAX_BYTES}. */
50
+ const AUDIT_TAIL_START_BYTES = 64 * 1024;
51
+
52
+ /**
53
+ * Whether an audit record is one of the HR-101 rate's DECISIONS: the reaper
54
+ * judged a process a reapable orphan (`kill` | `dry-run` | `reject`). The one
55
+ * definition {@link readAuditRecords} (how far back to read) and
56
+ * {@link falseAlarmRate} (what to count) share — if they disagreed, the reader
57
+ * would stop growing at a population the rate does not count.
58
+ *
59
+ * @param {{decision?: unknown}|null|undefined} record
60
+ * @returns {boolean}
61
+ */
62
+ function isRateDecision(record) {
63
+ const decision = record?.decision;
64
+ return decision === 'kill' || decision === 'dry-run' || decision === 'reject';
65
+ }
66
+
67
+ /** Absolute path of the kill audit for a repo. One constant per path.
68
+ * @param {string} repoRoot @returns {string} */
69
+ export function auditPath(repoRoot) {
70
+ return underRepo(repoRoot, REAPER_AUDIT_RELPATH);
71
+ }
72
+
73
+ /**
74
+ * Build one B5 audit record.
75
+ *
76
+ * Exactly ONE `trigger` per record, taken from the single verdict
77
+ * `decideReapCandidates` assigned at its fixed priority — a kill carries
78
+ * `orphan-ppid1`, a report carries the reason it was reported for. That is what
79
+ * makes "why exactly was this killed" answerable afterwards; a record listing
80
+ * two triggers would answer it with a shrug.
81
+ *
82
+ * `reason` is separate from `trigger` and present on every WITHDRAWAL: the
83
+ * trigger says what made this a candidate, the reason says what took it back.
84
+ * `args_head` is present ONLY for a command that cleared the read-only
85
+ * allowlist — see {@link ARGS_HEAD_CHARS}.
86
+ *
87
+ * @param {object} entry A candidate or a reported/rejected entry.
88
+ * @param {'kill'|'report'|'reject'|'dry-run'} decision
89
+ * @param {object} [extra]
90
+ * @param {string} extra.timestamp
91
+ * @param {string|null} [extra.sessionId]
92
+ * @param {object} [extra.result]
93
+ * @param {string} [extra.reason]
94
+ * @returns {object}
95
+ */
96
+ export function buildAuditRecord(entry, decision, {
97
+ timestamp, sessionId = null, result, reason,
98
+ } = {}) {
99
+ /** @type {Record<string, unknown>} */
100
+ const record = {
101
+ timestamp,
102
+ session_id: sessionId,
103
+ pid: entry.pid,
104
+ pgid: entry.pgid ?? null,
105
+ trigger: entry.trigger ?? entry.reason ?? null,
106
+ threshold: entry.threshold ?? null,
107
+ actual: entry.actual ?? (typeof entry.ageSeconds === 'number' ? { ageSeconds: entry.ageSeconds } : null),
108
+ unit: 'seconds',
109
+ command_signature: entry.commandSignature ?? null,
110
+ decision,
111
+ };
112
+ if (typeof entry.args === 'string' && entry.args.length > 0) {
113
+ record.args_head = entry.args.slice(0, ARGS_HEAD_CHARS);
114
+ }
115
+ if (result !== undefined) record.result = result;
116
+ const effectiveReason = reason ?? (decision === 'report' ? entry.reason : undefined);
117
+ if (effectiveReason !== undefined && effectiveReason !== null) record.reason = effectiveReason;
118
+ return record;
119
+ }
120
+
121
+ /**
122
+ * Default audit sink: append one JSONL line to {@link REAPER_AUDIT_RELPATH}.
123
+ * In-process fs only (the path sits under the `ledger-delete-protected` policy
124
+ * rule). Best-effort — an audit write must never fail a scan — but a failure
125
+ * prints one WARN line rather than vanishing.
126
+ *
127
+ * Refuses a symlinked or non-regular audit exactly as {@link pruneReaperAudit}
128
+ * does: `appendFileSync(path)` follows a link, so `ln -s events.jsonl
129
+ * reaper-audit.jsonl` routed every kill record into the link target (CWE-59,
130
+ * #1487). The write goes through {@link appendNoFollow} (`no-follow-append.mjs`,
131
+ * shared with the gate-process ledger): an `O_NOFOLLOW|O_NONBLOCK` open plus an
132
+ * `fstat` of the opened descriptor, which also refuses a FIFO and a HARD-linked
133
+ * audit (`ln <victim> reaper-audit.jsonl` passes every symlink check, and each
134
+ * append then landed in the victim — reproduced 2026-10-02). That protects the
135
+ * append only: the scan child then READS the same path for the rate and opens
136
+ * it again to prune — both reads therefore go through `readTailWindow`'s
137
+ * `noFollow` open, the read-side twin. The prune's rewrite is immune to hard
138
+ * links — it renames a new inode over the name.
139
+ *
140
+ * @param {string} repoRoot
141
+ * @param {object} record
142
+ * @returns {void}
143
+ */
144
+ export function appendAuditRecord(repoRoot, record) {
145
+ const target = auditPath(repoRoot);
146
+ try {
147
+ mkdirSync(path.dirname(target), { recursive: true });
148
+ appendNoFollow(target, `${JSON.stringify(record)}\n`);
149
+ } catch (err) {
150
+ process.stderr.write(`orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Default audit READER — the other half of {@link appendAuditRecord}, and the
156
+ * data source {@link falseAlarmRate} needs (B5: "Dieselbe Datei ist die
157
+ * Datenquelle für `reaper.false-alarm-window`").
158
+ *
159
+ * Returns the parsed records of the newest `limit` non-empty lines, oldest
160
+ * first, EXTENDED BACKWARDS until they hold the newest `limit` rate decisions
161
+ * ({@link isRateDecision}) or reach the start of the file. `limit` is
162
+ * `reaper.false-alarm-window`, which counts DECISIONS; a line window let
163
+ * `report` records — re-written by every scan for every long-lived foreign or
164
+ * unattributed process — push the decisions out, so the rate was judged on the
165
+ * newest handful (measured on HEAD 3479e1bf: 30 scans of 4 reports + 1 decision,
166
+ * window 50 → n 10, rate 0 against a true 6/30; #1487). Widened, never
167
+ * narrowed: the newest `limit` lines are always the suffix of the result, so a
168
+ * caller that wanted lines still finds them there.
169
+ *
170
+ * Read from a tail window that grows ×4 only while it holds fewer than `limit`
171
+ * lines or decisions; the first line of a window that does not start at byte 0
172
+ * is a fragment and is dropped before anything is parsed. Named ceiling
173
+ * (BV-004): the window stops at {@link REAPER_AUDIT_MAX_BYTES} (1 MiB, ~3,700
174
+ * report records of 285 bytes) — the prune's own ceiling, enforced HERE rather
175
+ * than assumed from the prune, because the prune refuses a linked audit and
176
+ * runs only after this read (a 40 MB link target was read whole, 64 MB window,
177
+ * 293 MB RSS per scan — #1487). Decisions older than the newest 1 MiB are not
178
+ * read; revisit if that ceiling is raised.
179
+ *
180
+ * The open is `readTailWindow`'s `noFollow` form: a symlinked, FIFO or other
181
+ * non-regular audit reads as `[]` — refused like the writers refuse it — and a
182
+ * planted FIFO can no longer block the scan child in `open`.
183
+ *
184
+ * A malformed line is dropped from the rate's population rather than counted,
185
+ * because an unreadable record carries no decision to classify — and the rate
186
+ * reports its own `n`, so a shrinking population is visible in the number's
187
+ * denominator.
188
+ *
189
+ * @param {string} repoRoot
190
+ * @param {number} [limit]
191
+ * @returns {object[]} parsed records in chronological order; `[]` on any failure
192
+ */
193
+ export function readAuditRecords(repoRoot, limit = REAPER_DEFAULTS.falseAlarmWindow) {
194
+ const target = auditPath(repoRoot);
195
+ const want = Math.max(1, limit);
196
+ let span;
197
+ try {
198
+ let windowBytes = AUDIT_TAIL_START_BYTES;
199
+ for (;;) {
200
+ const { text, cut } = readTailWindow(target, windowBytes, { noFollow: true });
201
+ const all = text.split('\n');
202
+ if (cut) all.shift();
203
+ span = newestSpan(all.filter((l) => l.trim().length > 0), want);
204
+ if (!cut || span.complete || windowBytes >= REAPER_AUDIT_MAX_BYTES) break;
205
+ windowBytes = Math.min(windowBytes * 4, REAPER_AUDIT_MAX_BYTES);
206
+ }
207
+ } catch {
208
+ return [];
209
+ }
210
+ return span.records;
211
+ }
212
+
213
+ /**
214
+ * Walk a window's complete lines newest-first until the walk has covered `want`
215
+ * lines AND `want` rate decisions — the population {@link readAuditRecords}
216
+ * documents. `complete: false` means the window ran out first.
217
+ *
218
+ * @param {string[]} lines Complete, non-empty lines in file order.
219
+ * @param {number} want
220
+ * @returns {{records: object[], complete: boolean}} records in file order
221
+ */
222
+ function newestSpan(lines, want) {
223
+ const newestFirst = [];
224
+ let taken = 0;
225
+ let decisions = 0;
226
+ for (let i = lines.length - 1; i >= 0 && (taken < want || decisions < want); i -= 1) {
227
+ taken += 1;
228
+ let parsed;
229
+ try {
230
+ parsed = JSON.parse(lines[i]);
231
+ } catch {
232
+ continue; // an unreadable line carries no decision to classify
233
+ }
234
+ if (!parsed || typeof parsed !== 'object') continue;
235
+ newestFirst.push(parsed);
236
+ if (isRateDecision(parsed)) decisions += 1;
237
+ }
238
+ return { records: newestFirst.reverse(), complete: taken >= want && decisions >= want };
239
+ }
240
+
241
+ /**
242
+ * Size-bounded prune of the audit: once the file passes `maxBytes`, rewrite it
243
+ * atomically with its newest `keepBytes`, cut at a line boundary. The same
244
+ * rewrite shape as `pruneGateProcessLedger` (`process-group.mjs`), the other
245
+ * ledger the scan's housekeeping prunes; the audit is pruned by SIZE rather than
246
+ * age because its population is a rolling window of decisions, not of days.
247
+ *
248
+ * Never throws; a failure — including a symlinked or non-regular audit, which
249
+ * is refused rather than written through — prints one WARN line and leaves the
250
+ * file as it was.
251
+ * Named ceiling (BV-004): an append that lands between the read and the write
252
+ * is lost — a window of one read + one write, once per ~0.5 MB of audit.
253
+ *
254
+ * @param {string} repoRoot
255
+ * @param {object} [opts]
256
+ * @param {number} [opts.maxBytes]
257
+ * @param {number} [opts.keepBytes]
258
+ * @returns {number} bytes removed; 0 when nothing was pruned
259
+ */
260
+ export function pruneReaperAudit(repoRoot, {
261
+ maxBytes = REAPER_AUDIT_MAX_BYTES,
262
+ keepBytes = REAPER_AUDIT_KEEP_BYTES,
263
+ } = {}) {
264
+ const target = auditPath(repoRoot);
265
+ let size;
266
+ try {
267
+ size = statSync(target).size;
268
+ } catch {
269
+ return 0;
270
+ }
271
+ if (size <= maxBytes) return 0;
272
+ try {
273
+ // `noFollow`: `statSync` above follows a link, so without it the prune read
274
+ // a linked target's tail before `replaceRegularFile` refused the write.
275
+ const { text, cut } = readTailWindow(target, Math.min(keepBytes, maxBytes), { noFollow: true });
276
+ // A cut window starts mid-record; with no newline at all it is ONE record
277
+ // larger than the window, and keeping any part of it would keep a fragment.
278
+ const newline = text.indexOf('\n');
279
+ const kept = !cut ? text : (newline === -1 ? '' : text.slice(newline + 1));
280
+ replaceRegularFile(target, kept);
281
+ return size - Buffer.byteLength(kept, 'utf8');
282
+ } catch (err) {
283
+ const why = err?.code === 'ELOOP' || err?.code === 'ERR_NOT_REGULAR_FILE'
284
+ ? NOT_A_REGULAR_FILE
285
+ : (err?.message ?? String(err));
286
+ process.stderr.write(`orphan-reaper: could not prune ${REAPER_AUDIT_RELPATH}: ${why}\n`);
287
+ return 0;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * False-alarm rate over the last `windowSize` audit decisions — the HR-101
293
+ * instrument-health check ("a signal may only warn if it is rare"; above ~10%
294
+ * the instrument is broken and gets re-aimed, never re-thresholded).
295
+ *
296
+ * POPULATION (the number's denominator, stated because a rate without one is a
297
+ * claim): audit records where the reaper judged a process a reapable orphan —
298
+ * `decision` in `kill` | `dry-run` | `reject` ({@link isRateDecision}, the same
299
+ * predicate {@link readAuditRecords} reads back far enough for). `report`
300
+ * records are excluded: reporting a foreign or non-read-only process is the
301
+ * correct outcome, not a firing of the kill signal.
302
+ *
303
+ * FALSE ALARM: a record where that judgement was refuted afterwards —
304
+ * `decision: 'reject'` (the identity re-check withdrew the candidate) or a kill
305
+ * whose `result.ok` is false (the signal did not take effect).
306
+ *
307
+ * Returns `{rate: null}` below 10 records: a rate over a handful of decisions
308
+ * says nothing, and `null` is distinguishable from a measured 0 (a missing
309
+ * measurement must never look like a zero).
310
+ *
311
+ * @param {object[]} auditRecords In chronological order; the LAST `windowSize` are used.
312
+ * @param {number} [windowSize]
313
+ * @returns {{rate: number|null, n: number}}
314
+ */
315
+ export function falseAlarmRate(auditRecords, windowSize = REAPER_DEFAULTS.falseAlarmWindow) {
316
+ const all = Array.isArray(auditRecords) ? auditRecords : [];
317
+ const firings = all.filter(isRateDecision);
318
+ const window = windowSize > 0 ? firings.slice(-windowSize) : firings;
319
+ const n = window.length;
320
+ if (n < 10) return { rate: null, n };
321
+ const falseAlarms = window.filter((r) => r.decision === 'reject' || r?.result?.ok === false).length;
322
+ return { rate: falseAlarms / n, n };
323
+ }