session-orchestrator 5.4.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 (150) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +95 -3
  7. package/README.md +11 -11
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +15 -13
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +21 -2
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -4,19 +4,13 @@
4
4
  * reader exists for.
5
5
  */
6
6
 
7
- import {
8
- appendFileSync,
9
- lstatSync,
10
- mkdirSync,
11
- renameSync,
12
- statSync,
13
- unlinkSync,
14
- writeFileSync,
15
- } from 'node:fs';
7
+ import { mkdirSync, statSync } from 'node:fs';
16
8
  import path from 'node:path';
17
9
 
10
+ import { replaceRegularFile } from '../process-group.mjs';
18
11
  import { readTailWindow } from '../tail-window.mjs';
19
12
  import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
13
+ import { NOT_A_REGULAR_FILE, appendNoFollow } from './no-follow-append.mjs';
20
14
 
21
15
  /** Relative path of the JSONL kill audit (B5). Gitignored via `.gitignore:55`
22
16
  * (`.orchestrator/metrics/*.jsonl`), verified with `git check-ignore --no-index -v`.
@@ -42,16 +36,34 @@ const ARGS_HEAD_CHARS = 80;
42
36
  * a `kill` record with `args_head` and `result` serialises to 505 bytes, a
43
37
  * `report` record to 285 (measured on {@link buildAuditRecord} output 2026-10-01;
44
38
  * 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
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
46
42
  * `reaper.false-alarm-window` is ever configured above ~1,000.
47
43
  */
48
44
  const REAPER_AUDIT_MAX_BYTES = 1024 * 1024;
49
45
  const REAPER_AUDIT_KEEP_BYTES = 512 * 1024;
50
46
 
51
47
  /** First tail window {@link readAuditRecords} tries; it grows ×4 only while the
52
- * window holds fewer complete lines than requested. */
48
+ * window holds fewer complete lines, or fewer rate decisions, than requested,
49
+ * and never past {@link REAPER_AUDIT_MAX_BYTES}. */
53
50
  const AUDIT_TAIL_START_BYTES = 64 * 1024;
54
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
+
55
67
  /** Absolute path of the kill audit for a repo. One constant per path.
56
68
  * @param {string} repoRoot @returns {string} */
57
69
  export function auditPath(repoRoot) {
@@ -112,6 +124,19 @@ export function buildAuditRecord(entry, decision, {
112
124
  * rule). Best-effort — an audit write must never fail a scan — but a failure
113
125
  * prints one WARN line rather than vanishing.
114
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
+ *
115
140
  * @param {string} repoRoot
116
141
  * @param {object} record
117
142
  * @returns {void}
@@ -120,11 +145,9 @@ export function appendAuditRecord(repoRoot, record) {
120
145
  const target = auditPath(repoRoot);
121
146
  try {
122
147
  mkdirSync(path.dirname(target), { recursive: true });
123
- appendFileSync(target, `${JSON.stringify(record)}\n`, 'utf8');
148
+ appendNoFollow(target, `${JSON.stringify(record)}\n`);
124
149
  } catch (err) {
125
- process.stderr.write(
126
- `orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`,
127
- );
150
+ process.stderr.write(`orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`);
128
151
  }
129
152
  }
130
153
 
@@ -133,13 +156,30 @@ export function appendAuditRecord(repoRoot, record) {
133
156
  * data source {@link falseAlarmRate} needs (B5: "Dieselbe Datei ist die
134
157
  * Datenquelle für `reaper.false-alarm-window`").
135
158
  *
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.
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`.
143
183
  *
144
184
  * A malformed line is dropped from the rate's population rather than counted,
145
185
  * because an unreadable record carries no decision to classify — and the rate
@@ -153,61 +193,49 @@ export function appendAuditRecord(repoRoot, record) {
153
193
  export function readAuditRecords(repoRoot, limit = REAPER_DEFAULTS.falseAlarmWindow) {
154
194
  const target = auditPath(repoRoot);
155
195
  const want = Math.max(1, limit);
156
- let lines;
196
+ let span;
157
197
  try {
158
198
  let windowBytes = AUDIT_TAIL_START_BYTES;
159
199
  for (;;) {
160
- const { text, cut } = readTailWindow(target, windowBytes);
200
+ const { text, cut } = readTailWindow(target, windowBytes, { noFollow: true });
161
201
  const all = text.split('\n');
162
202
  if (cut) all.shift();
163
- lines = all.filter((l) => l.trim().length > 0);
164
- if (!cut || lines.length >= want) break;
165
- windowBytes *= 4;
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);
166
206
  }
167
207
  } catch {
168
208
  return [];
169
209
  }
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;
210
+ return span.records;
180
211
  }
181
212
 
182
213
  /**
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.
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.
192
217
  *
193
- * @param {string} target
194
- * @param {string} body
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
195
221
  */
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 */ }
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
208
233
  }
209
- throw err;
234
+ if (!parsed || typeof parsed !== 'object') continue;
235
+ newestFirst.push(parsed);
236
+ if (isRateDecision(parsed)) decisions += 1;
210
237
  }
238
+ return { records: newestFirst.reverse(), complete: taken >= want && decisions >= want };
211
239
  }
212
240
 
213
241
  /**
@@ -242,7 +270,9 @@ export function pruneReaperAudit(repoRoot, {
242
270
  }
243
271
  if (size <= maxBytes) return 0;
244
272
  try {
245
- const { text, cut } = readTailWindow(target, Math.min(keepBytes, maxBytes));
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 });
246
276
  // A cut window starts mid-record; with no newline at all it is ONE record
247
277
  // larger than the window, and keeping any part of it would keep a fragment.
248
278
  const newline = text.indexOf('\n');
@@ -250,9 +280,10 @@ export function pruneReaperAudit(repoRoot, {
250
280
  replaceRegularFile(target, kept);
251
281
  return size - Buffer.byteLength(kept, 'utf8');
252
282
  } catch (err) {
253
- process.stderr.write(
254
- `orphan-reaper: could not prune ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`,
255
- );
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`);
256
287
  return 0;
257
288
  }
258
289
  }
@@ -264,9 +295,10 @@ export function pruneReaperAudit(repoRoot, {
264
295
  *
265
296
  * POPULATION (the number's denominator, stated because a rate without one is a
266
297
  * 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.
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.
270
302
  *
271
303
  * FALSE ALARM: a record where that judgement was refuted afterwards —
272
304
  * `decision: 'reject'` (the identity re-check withdrew the candidate) or a kill
@@ -282,7 +314,7 @@ export function pruneReaperAudit(repoRoot, {
282
314
  */
283
315
  export function falseAlarmRate(auditRecords, windowSize = REAPER_DEFAULTS.falseAlarmWindow) {
284
316
  const all = Array.isArray(auditRecords) ? auditRecords : [];
285
- const firings = all.filter((r) => r && (r.decision === 'kill' || r.decision === 'dry-run' || r.decision === 'reject'));
317
+ const firings = all.filter(isRateDecision);
286
318
  const window = windowSize > 0 ? firings.slice(-windowSize) : firings;
287
319
  const n = window.length;
288
320
  if (n < 10) return { rate: null, n };
@@ -1,13 +1,15 @@
1
1
  /**
2
- * orphan-reaper/scan-throttle.mjs — the B4 throttle the two trigger hooks call.
2
+ * orphan-reaper/scan-throttle.mjs — the B4 throttle the trigger calls.
3
3
  *
4
- * This is the reaper's only hot-path surface: `hooks/on-stop.mjs` and
5
- * `hooks/post-tool-batch-wave-signal.mjs` import THIS module, not the scan, so a
6
- * tool batch pays for one `stat` and two tiny modules — never for the ledger,
7
- * `ps` or kill-ladder code the detached scan child loads.
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.
8
9
  */
9
10
 
10
- import { lstatSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { lstatSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
11
13
  import path from 'node:path';
12
14
 
13
15
  import { REAPER_DEFAULTS, underRepo } from './defaults.mjs';
@@ -24,40 +26,78 @@ export const SCAN_MARKER_RELPATH = '.orchestrator/tmp/reaper-last-scan';
24
26
  * file. Writing the marker is the CALLER's job ({@link touchScanMarker}), so
25
27
  * this stays a read-only predicate a hook can call cheaply.
26
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
+ *
27
42
  * @param {string} markerPath Absolute path — build it with {@link scanMarkerPath}.
28
43
  * @param {number} nowMs
29
44
  * @param {number} [minIntervalSeconds]
30
45
  * @param {object} [opts]
31
- * @param {(p: string) => {mtimeMs: number}} [opts.statFn]
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).
32
49
  * @returns {boolean}
33
50
  */
34
51
  export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEFAULTS.minScanIntervalSeconds, {
35
- statFn = statSync,
52
+ statFn = lstatSync,
36
53
  } = {}) {
37
54
  if (typeof markerPath !== 'string' || markerPath.length === 0) return false;
38
- let mtimeMs;
55
+ let stats;
39
56
  try {
40
- mtimeMs = statFn(markerPath)?.mtimeMs;
57
+ stats = statFn(markerPath);
41
58
  } catch {
42
59
  return true; // no marker yet → first scan
43
60
  }
61
+ if (typeof stats?.isFile === 'function' && !stats.isFile()) return true;
62
+ const mtimeMs = stats?.mtimeMs;
44
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;
45
69
  return (nowMs - mtimeMs) >= minIntervalSeconds * 1000;
46
70
  }
47
71
 
48
72
  /**
49
73
  * Stamp the throttle marker. Best-effort and never throws — a marker that could
50
74
  * not be written means the next scan runs, which is the safe direction for a
51
- * read-only probe.
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.
52
92
  *
53
- * A marker path that is a symlink or not a regular file is left alone and
54
- * reported as not written: `writeFileSync` follows a link, so a marker linked to
55
- * any file would overwrite that file on every hook (CWE-59). Silent, like every
56
- * other failure here — this runs inside a 50 ms hook budget.
57
- * Named ceiling (BV-004): lstat-then-write leaves a check-to-use window of one
58
- * syscall; a link planted inside it is still written through. Acceptable for a
59
- * gitignored throttle stamp; revisit (O_NOFOLLOW open) if the marker ever
60
- * carries data.
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.
61
101
  *
62
102
  * @param {string} markerPath
63
103
  * @param {object} [opts]
@@ -66,25 +106,37 @@ export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEF
66
106
  */
67
107
  export function touchScanMarker(markerPath, { writeFn } = {}) {
68
108
  try {
109
+ const stamp = `${new Date().toISOString()}\n`;
69
110
  if (writeFn) {
70
- writeFn(markerPath, `${new Date().toISOString()}\n`);
111
+ writeFn(markerPath, stamp);
71
112
  return true;
72
113
  }
73
- let existing = null;
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
+ }
74
123
  try {
75
- existing = lstatSync(markerPath);
124
+ renameSync(tmp, markerPath);
125
+ return true;
76
126
  } catch {
77
- /* no marker yet — the first stamp creates it */
127
+ removeOwnTmp(tmp);
128
+ return false;
78
129
  }
79
- if (existing && !existing.isFile()) return false;
80
- mkdirSync(path.dirname(markerPath), { recursive: true });
81
- writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
82
- return true;
83
130
  } catch {
84
131
  return false;
85
132
  }
86
133
  }
87
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
+
88
140
  /** Absolute path of the throttle marker for a repo. One constant per path.
89
141
  * @param {string} repoRoot @returns {string} */
90
142
  export function scanMarkerPath(repoRoot) {
@@ -243,6 +243,10 @@ export async function runOrphanScan({
243
243
  } catch {
244
244
  return empty('ledger-unreadable', malformed);
245
245
  }
246
+ // The default reader never throws: a refused or unreadable ledger comes back
247
+ // as no records with `state: 'unreadable'` (#1498). Read as "no register
248
+ // hits" it ran a clean-looking scan that measured nothing.
249
+ if (ledger?.state === 'unreadable') return empty('ledger-unreadable', malformed);
246
250
 
247
251
  let ownSessionId = null;
248
252
  try {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * orphan-reaper/trigger.mjs — the B4 trigger both hooks call (#1432 B4).
3
+ *
4
+ * `hooks/on-stop.mjs` (Stop + SubagentStop) and
5
+ * `hooks/post-tool-batch-wave-signal.mjs` (PostToolBatch) are the two trigger
6
+ * points the PRD declares. Until 2026-10-02 each carried its own verbatim copy
7
+ * of this code (138 identical lines, one comment apart, measured by diffing the
8
+ * blocks); the copies are now this one module (#1489).
9
+ *
10
+ * Its own file rather than part of `scan-throttle.mjs`, for two closures:
11
+ * - `scan-throttle.mjs` is re-exported by the detached scan CLI
12
+ * (`scripts/lib/orphan-reaper.mjs`); putting the trigger there would load the
13
+ * config parser and `platform.mjs` into every scan child for nothing.
14
+ * - The throttle stays a LAZY import below, so a host with the reaper disabled
15
+ * (the default) pays for one config read and no throttle module at all.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { performance } from 'node:perf_hooks';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import { _parseReaper } from '../config/reaper.mjs';
25
+ import { getProjectDir } from '../platform.mjs';
26
+
27
+ /**
28
+ * Filesystem path of the scan CLI, spawned as a PLAIN argv call.
29
+ *
30
+ * It used to be a `file://` URL handed to `node --input-type=module -e
31
+ * <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
32
+ * has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
33
+ * contract lives in that module instead of as source text duplicated here —
34
+ * and nothing this trigger builds is a program any more: every variable part is
35
+ * an argv value, which cannot become code whatever the checkout path contains.
36
+ *
37
+ * `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
38
+ * percent-encoded path for any checkout directory containing a space.
39
+ */
40
+ const ORPHAN_REAPER_SCRIPT = fileURLToPath(new URL('../orphan-reaper.mjs', import.meta.url));
41
+
42
+ /**
43
+ * Read the `reaper:` block from the project's Session Config host file.
44
+ * Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
45
+ * path must not import the full config orchestrator. A missing or unreadable
46
+ * file yields the parser defaults, i.e. DISABLED.
47
+ *
48
+ * @param {string} projectDir
49
+ * @returns {ReturnType<typeof _parseReaper>}
50
+ */
51
+ function loadReaperConfig(projectDir) {
52
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
53
+ try {
54
+ return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
55
+ } catch {
56
+ // missing or unreadable — try the next candidate
57
+ }
58
+ }
59
+ return _parseReaper('');
60
+ }
61
+
62
+ /**
63
+ * Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
64
+ *
65
+ * The hook itself does exactly two pieces of I/O — one config read and one
66
+ * `stat` of the throttle marker — and then hands the work to a DETACHED,
67
+ * unref'd child. The scan is never run inline: one `ps` round-trip out of Node
68
+ * was measured at ~47 ms over 287 KB of output, which alone would blow the
69
+ * 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
70
+ *
71
+ * The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
72
+ * the throttle window — otherwise a broken spawn would be retried on every
73
+ * single tool batch.
74
+ *
75
+ * `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
76
+ * with `performance.now()` and reported as ONE stderr WARN line when exceeded.
77
+ * A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
78
+ * per-fire telemetry record is exactly the always-on signal
79
+ * `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
80
+ * measurement covers preparation only — the scan itself runs detached, which is
81
+ * the whole reason the budget can be held.
82
+ *
83
+ * Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
84
+ *
85
+ * @param {object} [opts]
86
+ * @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
87
+ * @param {number} [opts.now] Injected clock (ms).
88
+ * @param {Function} [opts.spawnFn] Injected `spawn` (tests).
89
+ * @param {Function} [opts.statFn] Injected marker `lstatSync` (tests).
90
+ * @param {Function} [opts.writeFn] Injected marker writer (tests).
91
+ * @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
92
+ * budget (tests); defaults to `performance.now`.
93
+ * @returns {Promise<{spawned: boolean, reason: string}>} `reason` is one of
94
+ * `'disabled' | 'throttled' | 'spawned' | 'spawned-unthrottled' | 'error'`.
95
+ * `'spawned-unthrottled'` means the scan ran but the marker could not be
96
+ * stamped — the next fire will scan again, and the result must not read like
97
+ * a throttled spawn. `hooks/on-stop.mjs` records `reason` as `reaper_trigger`
98
+ * on its per-turn and per-agent records (HR-105: a signal nothing records is
99
+ * unfalsifiable); the PostToolBatch hook does not, because the marker is one
100
+ * file per repo — a defeated marker shows on the very next Stop record, and a
101
+ * per-batch record is the HR-101 shape the WARN above already refuses.
102
+ */
103
+ export async function maybeTriggerOrphanScan({
104
+ projectDir,
105
+ now,
106
+ spawnFn = spawn,
107
+ statFn,
108
+ writeFn,
109
+ clockFn = () => performance.now(),
110
+ } = {}) {
111
+ const startedAt = clockFn();
112
+ /** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
113
+ const checkLatency = (budgetMs) => {
114
+ const elapsed = clockFn() - startedAt;
115
+ if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
116
+ process.stderr.write(
117
+ `orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
118
+ + `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
119
+ );
120
+ }
121
+ };
122
+ let root;
123
+ try {
124
+ root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
125
+ } catch {
126
+ // No project dir, no config to read (#1498): `process.cwd()` throws ENOENT
127
+ // when the hook starts in a deleted directory with no `*_PROJECT_DIR` set.
128
+ // That is an unreadable config — the parser defaults, DISABLED — and not a
129
+ // failure of an armed reaper, so it must not surface as `error`: on-stop
130
+ // stamps `reaper_trigger` only for a reaper that is enabled.
131
+ return { spawned: false, reason: 'disabled' };
132
+ }
133
+ try {
134
+ const cfg = loadReaperConfig(root);
135
+ // Cheapest gate first: disabled means no stat, no spawn, no module load.
136
+ if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
137
+
138
+ // The throttle module only — the scan's ledger, `ps` and kill-ladder code
139
+ // load in the detached child, never on this hook path (#1437).
140
+ const reaper = await import('./scan-throttle.mjs');
141
+ const markerPath = reaper.scanMarkerPath(root);
142
+ const nowMs = typeof now === 'number' ? now : Date.now();
143
+
144
+ if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
145
+ checkLatency(cfg['max-hook-latency-ms']);
146
+ return { spawned: false, reason: 'throttled' };
147
+ }
148
+ // An unwritable marker does not cancel the scan — the throttle fails toward
149
+ // scanning (see shouldScanNow) — but the result says the throttle is off.
150
+ // No output: this runs on every hook fire (each tool batch, each Stop) in
151
+ // every session on the host.
152
+ const stamped = reaper.touchScanMarker(markerPath, { writeFn });
153
+
154
+ const child = spawnFn(
155
+ process.execPath,
156
+ [
157
+ ORPHAN_REAPER_SCRIPT,
158
+ '--repo-root', root,
159
+ '--mode', cfg.mode,
160
+ '--min-age-seconds', String(cfg['min-age-seconds']),
161
+ '--kill-grace-ms', String(cfg['kill-grace-ms']),
162
+ '--verify-wait-ms', String(cfg['verify-wait-ms']),
163
+ '--false-alarm-window', String(cfg['false-alarm-window']),
164
+ ],
165
+ { detached: true, stdio: 'ignore' },
166
+ );
167
+ if (child && typeof child.unref === 'function') child.unref();
168
+ checkLatency(cfg['max-hook-latency-ms']);
169
+ return { spawned: true, reason: stamped ? 'spawned' : 'spawned-unthrottled' };
170
+ } catch {
171
+ return { spawned: false, reason: 'error' };
172
+ }
173
+ }
@@ -25,7 +25,11 @@
25
25
  * trigger hooks spawn. The implementation lives in `orphan-reaper/`:
26
26
  *
27
27
  * defaults.mjs `REAPER_DEFAULTS` + the repo-path join (node:path only)
28
- * scan-throttle.mjs B4 throttle marker — the trigger hooks import THIS
28
+ * trigger.mjs `maybeTriggerOrphanScan` — the ONE trigger both hooks
29
+ * import; deliberately NOT re-exported here, so this CLI's
30
+ * closure stays free of the config parser and platform.mjs
31
+ * scan-throttle.mjs B4 throttle marker — loaded lazily by trigger.mjs
32
+ * no-follow-append.mjs the O_NOFOLLOW|O_NONBLOCK append both ledgers share
29
33
  * ps-snapshot.mjs the binding `ps` format, its parser and runners
30
34
  * reaper-decide.mjs the PURE candidate decision and its identity checks
31
35
  * reaper-audit.mjs B5 audit record, writer, bounded reader, pruner, HR-101 rate