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
@@ -8,12 +8,12 @@
8
8
  * - plain constants (no filesystem work): SO_SHARED_DIR, SO_OS, SO_IS_WINDOWS,
9
9
  * SO_IS_WSL, SO_PATH_SEP
10
10
  * - pure resolvers: detectPlatform, resolvePluginRoot, resolveProjectDir,
11
- * resolveStateDir, resolveConfigFile
11
+ * resolveSessionRoot, resolveStateDir, resolveConfigFile
12
12
  *
13
13
  * NOTHING in this module touches the filesystem at import time.
14
14
  */
15
15
 
16
- import { existsSync, statSync } from 'node:fs';
16
+ import { existsSync, realpathSync, statSync } from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
  import { resolvePluginRoot as _resolvePluginRootRobust } from './plugin-root.mjs';
@@ -249,6 +249,210 @@ export function resolveProjectDir(platform) {
249
249
  return cwd;
250
250
  }
251
251
 
252
+ // ---------------------------------------------------------------------------
253
+ // resolveSessionRoot (#1492)
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Basename of a worktree the HARNESS creates for an `isolation: "worktree"`
258
+ * subagent: `agent-` + the agent id. Measured 2026-10-02 over this host's
259
+ * transcripts: 13481 `.claude/worktrees/agent-<hex>` hits, every agent id of the
260
+ * form `a` + 16 hex (`agent-a00828908cdd83bd8`). A worktree a SESSION enters
261
+ * (`EnterWorktree`, `claude --worktree <name>`) lives in the same directory under
262
+ * a chosen name (`landing-produktschau`, `s71-politur`), so the name is the only
263
+ * discriminator the path offers.
264
+ */
265
+ const HARNESS_AGENT_WORKTREE_RE = /^agent-[0-9a-f]{8,}$/;
266
+
267
+ /**
268
+ * The first non-blank launch-dir env var, in `resolveProjectDir()` order, or `''`.
269
+ * Trimmed: a whitespace-only value is truthy and would otherwise win
270
+ * (`development.md` § Error Handling, env-var fallback whitespace trap).
271
+ *
272
+ * The ONE definition of "the launch dir the harness states": the clamp in
273
+ * {@link resolveSessionRoot} and the relocation trace in `hooks/enforce-scope.mjs`
274
+ * must read the same chain, so a second copy of it is a second definition.
275
+ *
276
+ * @returns {string}
277
+ */
278
+ export function launchDirFromEnv() {
279
+ for (const name of ['CLAUDE_PROJECT_DIR', 'CODEX_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'PI_PROJECT_DIR']) {
280
+ const value = (process.env[name] || '').trim();
281
+ if (value !== '') return value;
282
+ }
283
+ return '';
284
+ }
285
+
286
+ /**
287
+ * The nearest directory at or above `cwd` holding a `.git` entry — a directory,
288
+ * or the FILE a linked worktree carries — or `''` when none does. Git's own repo
289
+ * discovery walks up looking for exactly this entry, so where git answers, both
290
+ * agree (measured 2026-10-02: same answer, 0.017 ms here vs 4.57 ms for a
291
+ * `git rev-parse --show-toplevel` spawn). Existence only — the entry is not
292
+ * validated, which git would do.
293
+ *
294
+ * Not `walkUpFor(cwd, '.git', 'any')`: its home-directory boundary exists for
295
+ * PLATFORM markers (#1139); a repo whose root is `$HOME` is still a repo root.
296
+ *
297
+ * @param {string} cwd
298
+ * @returns {string}
299
+ */
300
+ export function dotGitAncestor(cwd) {
301
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
302
+ if (existsSync(path.join(dir, '.git'))) return dir;
303
+ if (path.dirname(dir) === dir) return '';
304
+ }
305
+ }
306
+
307
+ /**
308
+ * `<P>` when `root` is a harness subagent worktree `<P>/.claude/worktrees/agent-<hex>`
309
+ * whose parent `<P>` is itself a working copy; otherwise `root` unchanged.
310
+ *
311
+ * @param {string} root
312
+ * @returns {string}
313
+ */
314
+ function _liftHarnessAgentWorktree(root) {
315
+ if (!HARNESS_AGENT_WORKTREE_RE.test(path.basename(root))) return root;
316
+ const worktreesDir = path.dirname(root);
317
+ if (path.basename(worktreesDir) !== 'worktrees') return root;
318
+ const stateDir = path.dirname(worktreesDir);
319
+ if (path.basename(stateDir) !== '.claude') return root;
320
+ const parent = path.dirname(stateDir);
321
+ return existsSync(path.join(parent, '.git')) ? parent : root;
322
+ }
323
+
324
+ /**
325
+ * The NATIVE realpath, or the resolved spelling when the path does not exist.
326
+ *
327
+ * `realpathSync.native`, not the JS `realpathSync`: on a case-insensitive
328
+ * volume (APFS default) the JS form resolves symlinks but keeps the CALLER's
329
+ * letter case, so `/users/me/repo` stayed `/users/…` beside the git-found
330
+ * `/Users/…`, `path.relative` answered `../../users/…` and the clamp was
331
+ * skipped (#1504 point 8, measured 2026-10-03 on a home dir typed lowercase:
332
+ * JS `/users/<me>`, native `/Users/<me>`). Every comparison in the clamp chain
333
+ * goes through this one function — mixing the two forms re-opens the split.
334
+ *
335
+ * @param {string} p
336
+ * @returns {string}
337
+ */
338
+ function _canonical(p) {
339
+ try { return realpathSync.native(p); } catch { return path.resolve(p); }
340
+ }
341
+
342
+ /**
343
+ * Is `child` a STRICT descendant of `parent` (never `parent` itself)? Both sides
344
+ * canonical, so macOS `/tmp` vs `/private/tmp` cannot split one directory in two.
345
+ *
346
+ * @param {string} child
347
+ * @param {string} parent
348
+ * @returns {boolean}
349
+ */
350
+ function _isStrictlyInside(child, parent) {
351
+ const rel = path.relative(_canonical(parent), _canonical(child));
352
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
353
+ }
354
+
355
+ /**
356
+ * The SESSION ROOT (#1492) — the working copy whose control files belong to the
357
+ * session a hook fires in: `wave-scope.json`, the `.orchestrator/` ledgers, the
358
+ * event records. One resolver for every hook that reads them (enforce-scope,
359
+ * enforce-commands, post-bash-write-verify, pre-task-scope-disjoint).
360
+ *
361
+ * NOT the launch dir whenever the session works elsewhere: `$CLAUDE_PROJECT_DIR`
362
+ * stays on the dir the session was LAUNCHED in after it enters a worktree
363
+ * (docs/en/hooks § "Worktrees are different"), while the coordinator writes
364
+ * `wave-scope.json` into that worktree. Read from the launch dir, the manifest
365
+ * was absent and every scope gate allowed everything for the whole session,
366
+ * silently (#1492, measured in session main-2026-10-02-session-21); preferred
367
+ * for the dispatch ledger it also gave every worktree session of one launch dir
368
+ * ONE shared ledger and the wave key `w?` (measured on be6a2e3e).
369
+ *
370
+ * NOT the payload `cwd` itself: it follows the session's `cd` (docs/en/hooks
371
+ * § "cwd follows Claude"), and a `cd sub` must not move the session's state into
372
+ * `<root>/sub` — a second, empty ledger whose collisions were allowed (#1489 Pkt 6).
373
+ *
374
+ * NEVER ABOVE the launch dir: a session launched in a repo SUBDIRECTORY (a
375
+ * monorepo package, a project under a git-tracked `$HOME`) keeps its manifest
376
+ * there. The repo root above it holds none, so resolving to it switched every
377
+ * scope gate off (review HIGH-1 on 4fdbc469, measured DENY → ALLOW).
378
+ *
379
+ * Precedence:
380
+ * 1. no payload `cwd` → `resolveProjectDir()`, the pre-#1492 answer unchanged;
381
+ * 2. the repo root of `cwd` — `toplevel` when the caller already holds
382
+ * `git rev-parse --show-toplevel` of it, else the nearest `.git` ancestor
383
+ * (`dotGitAncestor`), which answers the same without a spawn, so neither a
384
+ * timed-out git (review MED on 63f35e8c) nor a hot-path budget can send a
385
+ * worktree session back to the launch dir — lifted to `<P>` when it is a
386
+ * harness subagent worktree `<P>/.claude/worktrees/agent-<hex>` (below),
387
+ * then replaced by the launch dir from env (`launchDirFromEnv`) when it
388
+ * lies strictly ABOVE it;
389
+ * 3. the launch dir from env (`CLAUDE_PROJECT_DIR` → `CODEX_PROJECT_DIR` →
390
+ * `CURSOR_PROJECT_DIR` → `PI_PROJECT_DIR`), only when `cwd` is in no repo;
391
+ * 4. `cwd`.
392
+ *
393
+ * The clamp reads the SAME launch-dir chain as rung 3, not `$CLAUDE_PROJECT_DIR`
394
+ * alone. The Cursor and Pi bridges delete `CLAUDE_PROJECT_DIR` and set their
395
+ * own `*_PROJECT_DIR` to the payload `cwd` on every call (`cursor-hook-bridge.mjs`,
396
+ * `pi-hook-bridge.mjs`); a clamp on `$CLAUDE_PROJECT_DIR` alone therefore never
397
+ * fired there, and a Cursor/Pi workspace in a repo SUBDIRECTORY climbed to the
398
+ * repo root, found no manifest and allowed every write (review HIGH-1 on
399
+ * b57e572c, measured end-to-end through `cursor-hook-bridge.mjs`: DENY at
400
+ * ff3e43b5 → ALLOW). For a Claude session nothing changes — `$CLAUDE_PROJECT_DIR`
401
+ * is first in the chain. For a bridge session the answer is the payload `cwd`
402
+ * whenever it sits in a repo, which is exactly what the bridges resolved before
403
+ * #1492 (`resolveProjectDir()` → their `*_PROJECT_DIR` → the payload `cwd`).
404
+ * The clamp does not ask whether `cwd` lies inside the launch dir: an
405
+ * `isolation: "worktree"` agent's `cwd` never does, and it must clamp too (see
406
+ * THE LIFT).
407
+ *
408
+ * THE LIFT. An `isolation: "worktree"` subagent's hook payload carries the
409
+ * worktree the harness made for it, `<P>/.claude/worktrees/agent-<hex>`. That
410
+ * worktree is a repo root of its own and holds no manifest, so rung 2 alone
411
+ * would disarm scope enforcement for every such agent. The harness puts it under
412
+ * the LAUNCH checkout, so `<P>` is what `$CLAUDE_PROJECT_DIR` resolved to before
413
+ * #1492 — the lift keeps exactly that, and the clamp keeps it for a launch in a
414
+ * subdirectory of `<P>`. It is keyed on the PATH SHAPE, never on which candidate
415
+ * holds a manifest: a root picked because a control file is readable there
416
+ * would let any directory that lacks one become the deciding root.
417
+ *
418
+ * Ceilings (BV-004):
419
+ * - An agent of a session that ENTERED a worktree is still unenforced, as before
420
+ * #1492: its worktree sits under the launch checkout (measured: session
421
+ * 0d5e4fc3, 2026-09-25 — cwd `<main>/.claude/worktrees/landing-produktschau`,
422
+ * agent worktreePath `<main>/.claude/worktrees/agent-acd5bfd0aa590e2e8`), so
423
+ * the lift lands on the launch root, where that session has no manifest.
424
+ * Fixing it needs a design (e.g. find the manifest bound to the payload
425
+ * `session_id` among the repo's worktrees). Revisit when an entered-worktree
426
+ * session dispatches `isolation: "worktree"` agents under a manifest.
427
+ * - Rung 2 is `cd`-relocatable: a session whose `cwd` sits in ANOTHER repo (a
428
+ * submodule, a nested or sibling checkout) is checked against that repo's
429
+ * manifest — usually none, so its gates allow — where the launch dir's
430
+ * manifest used to apply. Same relocation `pre-task-scope-disjoint` has
431
+ * keyed its ledger on since #1489. Revisit if an `orchestrator.scope.*`
432
+ * record or a review ever shows an out-of-scope write made after such a `cd`.
433
+ * - A session worktree NAMED like a harness one (`agent-` + 8 or more hex) is
434
+ * lifted to its parent — the pre-#1492 launch-dir behaviour for that one
435
+ * session, not a new failure. Revisit if the harness changes its naming.
436
+ * - A Cursor/Pi bridge session follows its payload `cwd`, as it did before
437
+ * #1492: the bridges hand over no launch dir distinct from that `cwd`, so a
438
+ * bridge session whose `cwd` moves into a subdirectory without a manifest is
439
+ * unenforced there. Not a new failure — the pre-#1492 bridge answer. Revisit
440
+ * when a bridge hands over a stable workspace root separately from the
441
+ * payload `cwd` — then clamp on that instead.
442
+ *
443
+ * @param {string|undefined} cwd the hook payload's `cwd`
444
+ * @param {string} [toplevel] `git rev-parse --show-toplevel` of `cwd`, `''` when unknown
445
+ * @returns {string} Absolute path
446
+ */
447
+ export function resolveSessionRoot(cwd, toplevel = '') {
448
+ if (typeof cwd !== 'string' || cwd.trim() === '') return resolveProjectDir();
449
+ const repoRoot = (typeof toplevel === 'string' ? toplevel : '') || dotGitAncestor(cwd);
450
+ if (!repoRoot) return launchDirFromEnv() || cwd;
451
+ const root = _liftHarnessAgentWorktree(repoRoot);
452
+ const launch = launchDirFromEnv();
453
+ return launch !== '' && _isStrictlyInside(launch, root) ? launch : root;
454
+ }
455
+
252
456
  // ---------------------------------------------------------------------------
253
457
  // resolveStateDir
254
458
  // ---------------------------------------------------------------------------
@@ -260,7 +260,9 @@ async function resolveLatestVersion({ cacheDir, now, fetchImpl }) {
260
260
  * @type {Record<"claude"|"codex"|"cursor"|"pi", string>}
261
261
  */
262
262
  const PLATFORM_UPDATE_INSTRUCTIONS = {
263
- claude: '/plugin update session-orchestrator@kanevry, then restart Claude Code',
263
+ claude:
264
+ '/plugin update session-orchestrator@kanevry, then restart Claude Code ' +
265
+ '(from a clone: `npm run update:local` also installs the deps Claude Code refuses — docs/install.md § Upgrade)',
264
266
  codex:
265
267
  'run `codex plugin marketplace upgrade kanevry && codex plugin add session-orchestrator@kanevry` ' +
266
268
  '(docs/codex-setup.md), then restart Codex',
@@ -25,17 +25,18 @@
25
25
  import { spawn } from 'node:child_process';
26
26
  import { createHash } from 'node:crypto';
27
27
  import {
28
- appendFileSync,
29
28
  existsSync,
30
29
  lstatSync,
31
30
  mkdirSync,
32
- readFileSync,
33
31
  renameSync,
34
32
  unlinkSync,
35
33
  writeFileSync,
36
34
  } from 'node:fs';
37
35
  import path from 'node:path';
38
36
 
37
+ import { NOT_A_REGULAR_FILE, appendNoFollow } from './orphan-reaper/no-follow-append.mjs';
38
+ import { readTailWindow } from './tail-window.mjs';
39
+
39
40
  /**
40
41
  * Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
41
42
  * used by the gate's group ladder here and by `wave-executor/foreign-dispatch.mjs`
@@ -490,6 +491,26 @@ function ledgerPathFor(repoRoot) {
490
491
  return path.join(repoRoot, ...GATE_PROCESS_LEDGER_RELPATH.split('/'));
491
492
  }
492
493
 
494
+ /**
495
+ * Read the whole ledger without following a link and without blocking on a
496
+ * FIFO: `readTailWindow`'s `noFollow` open with a window larger than any file,
497
+ * so a regular ledger reads exactly as `readFileSync` read it. A plain
498
+ * `readFileSync` FOLLOWED a symlinked ledger (a foreign file parsed as kill
499
+ * candidates) and blocked for good in `open` on a planted FIFO — inside the
500
+ * gate's own registration path (#1489). THROWS like `readFileSync`: ENOENT,
501
+ * ELOOP for a symlink, `ERR_NOT_REGULAR_FILE` for a FIFO or directory.
502
+ *
503
+ * Named ceiling (BV-004): the file is read whole, as before — fine for a ledger
504
+ * pruned to 24 h (one line per gate command); revisit if it ever stops being
505
+ * pruned.
506
+ *
507
+ * @param {string} target
508
+ * @returns {string}
509
+ */
510
+ function readLedgerNoFollow(target) {
511
+ return readTailWindow(target, Number.MAX_SAFE_INTEGER, { noFollow: true }).text;
512
+ }
513
+
493
514
  /**
494
515
  * Append one {@link GateProcessRecord} to the gate-process ledger.
495
516
  *
@@ -499,6 +520,12 @@ function ledgerPathFor(repoRoot) {
499
520
  * vanishing (a ledger that silently stops being written is indistinguishable
500
521
  * from a host with no gate processes).
501
522
  *
523
+ * The write is {@link appendNoFollow}, never `appendFileSync(path)`: that
524
+ * followed a symlinked ledger, so every gate registration landed in the link's
525
+ * target, and its open of a planted FIFO blocked until a reader appeared — the
526
+ * gate hung in its own registration path (#1489). A link, FIFO, directory or
527
+ * hard-linked ledger is now refused with the same one WARN line.
528
+ *
502
529
  * @param {string} repoRoot
503
530
  * @param {GateProcessRecord} record
504
531
  * @param {object} [opts]
@@ -513,7 +540,7 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
513
540
  if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
514
541
  const line = `${JSON.stringify(record)}\n`;
515
542
  if (appendFn) appendFn(target, line);
516
- else appendFileSync(target, line, 'utf8');
543
+ else appendNoFollow(target, line);
517
544
  } catch (err) {
518
545
  process.stderr.write(
519
546
  `process-group: could not record gate process in ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
@@ -532,14 +559,24 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
532
559
  * counted separately — they are not candidates for anything, and their PIDs are
533
560
  * the most likely to have been recycled.
534
561
  *
562
+ * A symlinked, FIFO or otherwise non-regular ledger yields NO records, like an
563
+ * unreadable one ({@link readLedgerNoFollow}): never a link target's lines as
564
+ * candidates, never a blocked open — no records name no process group to
565
+ * signal. But it is not reported as empty (#1498): `state` tells the three
566
+ * outcomes apart — `read`, `absent` (no ledger yet, the ordinary case) and
567
+ * `unreadable` (refused or failed) — because the scan's `ledger-unreadable`
568
+ * skip was unreachable while a refused ledger looked exactly like a clean one.
569
+ *
535
570
  * @param {string} repoRoot
536
571
  * @param {object} [opts]
537
572
  * @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
538
573
  * return '' for a missing file.
539
574
  * @param {number} [opts.nowMs]
540
575
  * @param {number} [opts.maxAgeMs]
541
- * @returns {{records: GateProcessRecord[], malformedLines: number, expired: number}}
542
- * `records` are the fresh, parseable entries in source order.
576
+ * @returns {{records: GateProcessRecord[], malformedLines: number, expired: number,
577
+ * state: 'read'|'absent'|'unreadable'}}
578
+ * `records` are the fresh, parseable entries in source order; empty unless
579
+ * `state` is `read`.
543
580
  */
544
581
  export function readGateProcessLedger(repoRoot, {
545
582
  readFn,
@@ -547,12 +584,19 @@ export function readGateProcessLedger(repoRoot, {
547
584
  maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
548
585
  } = {}) {
549
586
  const target = ledgerPathFor(repoRoot);
550
- let raw = '';
587
+ let raw;
551
588
  try {
552
- if (readFn) raw = readFn(target) ?? '';
553
- else if (existsSync(target)) raw = readFileSync(target, 'utf8');
554
- } catch {
555
- return { records: [], malformedLines: 0, expired: 0 };
589
+ // No `existsSync` pre-check: it FOLLOWS a link, so a dangling symlink read
590
+ // as "no ledger yet" while `appendNoFollow` refused it on every
591
+ // registration (#1498 review). The no-follow open itself tells the cases
592
+ // apart — ENOENT only when nothing is at the path.
593
+ raw = readFn ? (readFn(target) ?? '') : readLedgerNoFollow(target);
594
+ } catch (err) {
595
+ // ENOENT: no ledger yet, or one that vanished (or a `readFn` reporting a
596
+ // missing file). Anything else — ELOOP (a symlink, dangling or not),
597
+ // ERR_NOT_REGULAR_FILE, EACCES — was refused.
598
+ const state = err?.code === 'ENOENT' ? 'absent' : 'unreadable';
599
+ return { records: [], malformedLines: 0, expired: 0, state };
556
600
  }
557
601
 
558
602
  /** @type {GateProcessRecord[]} */
@@ -580,7 +624,7 @@ export function readGateProcessLedger(repoRoot, {
580
624
  records.push(parsed);
581
625
  }
582
626
 
583
- return { records, malformedLines, expired };
627
+ return { records, malformedLines, expired, state: 'read' };
584
628
  }
585
629
 
586
630
  /**
@@ -594,17 +638,19 @@ export function readGateProcessLedger(repoRoot, {
594
638
  * ledger linked to `events.jsonl` emptied the link's target (CWE-59, reproduced
595
639
  * 2026-10-01). `rename` replaces the directory entry itself, never a link's
596
640
  * target, and the `wx` flag refuses a pre-planted file or link at the tmp name.
597
- * Kept local, not imported, because this module deliberately has no local
598
- * imports; `orphan-reaper/reaper-audit.mjs` carries the same few lines.
641
+ * The ONE copy (#1498): `orphan-reaper/reaper-audit.mjs` imports it for the
642
+ * audit prune — both ledgers its scan housekeeping rewrites. It lives here, not
643
+ * there, because this module imports nothing from `orphan-reaper/` beyond the
644
+ * fs-only `no-follow-append.mjs`, so the graph stays acyclic, and every
645
+ * importer of the audit module already loads this one.
599
646
  *
600
647
  * @param {string} target
601
648
  * @param {string} body
602
- * @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} io
649
+ * @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} [io]
650
+ * fs seam for tests; defaults to `node:fs`.
603
651
  */
604
- function replaceRegularFile(target, body, io) {
605
- if (!io.lstatSync(target).isFile()) {
606
- throw new Error('not a regular file (a symlink is never written through) — left untouched');
607
- }
652
+ export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync, renameSync, unlinkSync }) {
653
+ if (!io.lstatSync(target).isFile()) throw new Error(NOT_A_REGULAR_FILE);
608
654
  const tmp = `${target}.tmp-${process.pid}`;
609
655
  try {
610
656
  io.writeFileSync(tmp, body, { encoding: 'utf8', flag: 'wx' });
@@ -630,8 +676,10 @@ function replaceRegularFile(target, body, io) {
630
676
  *
631
677
  * In-process fs only — never a shell `rm`/`mv` (PSA-003, and
632
678
  * `.orchestrator/metrics/**` deletions are a blocked-command rule for a reason).
633
- * The rewrite goes through {@link replaceRegularFile}: a symlinked or
634
- * non-regular ledger is refused with one WARN and left untouched.
679
+ * The read is {@link readLedgerNoFollow} and the rewrite goes through
680
+ * {@link replaceRegularFile}: a symlinked, FIFO or otherwise non-regular ledger
681
+ * is refused with one WARN and left untouched — refused at the READ, so a link
682
+ * target is never read and a FIFO never blocks the gate's registration path.
635
683
  *
636
684
  * Named ceiling (BV-004): read-filter-write is not atomic against a concurrent
637
685
  * append, so a line appended between the read and the write is lost. Acceptable
@@ -655,13 +703,26 @@ export function pruneGateProcessLedger(repoRoot, {
655
703
  maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
656
704
  fs: fsSeam,
657
705
  } = {}) {
658
- const io = fsSeam ?? { existsSync, readFileSync, writeFileSync, lstatSync, renameSync, unlinkSync };
706
+ const io = fsSeam ?? {
707
+ existsSync,
708
+ readFileSync: readLedgerNoFollow,
709
+ writeFileSync,
710
+ lstatSync,
711
+ renameSync,
712
+ unlinkSync,
713
+ };
659
714
  const target = ledgerPathFor(repoRoot);
660
715
  let raw;
661
716
  try {
662
717
  if (!io.existsSync(target)) return 0;
663
718
  raw = io.readFileSync(target, 'utf8');
664
- } catch {
719
+ } catch (err) {
720
+ // A refused ledger (ELOOP: a symlink; ERR_NOT_REGULAR_FILE: a FIFO, a
721
+ // directory) gets the same one WARN the rewrite gives it; an unreadable or
722
+ // vanished one stays silent, as before.
723
+ if (err?.code === 'ELOOP' || err?.code === 'ERR_NOT_REGULAR_FILE') {
724
+ process.stderr.write(`process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${NOT_A_REGULAR_FILE}\n`);
725
+ }
665
726
  return 0;
666
727
  }
667
728
 
@@ -141,8 +141,11 @@ import { resolveBaselineRange, isQueryFailure } from './vcs-repo-spec.mjs';
141
141
  // F1 bug this filter exists to prevent — a caller could hand
142
142
  // `writeBaseline()` an unfiltered count with no `filterExcluded()` pass at
143
143
  // all). `computeDrift()` filters the numerator at measure time by running
144
- // the live `git diff --name-only` output through the same
145
- // `filterExcluded()` helper.
144
+ // the live change set (committed range ∪ working tree ∪ untracked, see its
145
+ // JSDoc) through the same `filterExcluded()` helper. One classification sits
146
+ // OUTSIDE this list on purpose: untracked paths under `.orchestrator/` (see
147
+ // `isUntrackedRuntimeWrite()` below) — it keys on tracked status, which only
148
+ // the live numerator has, so a pattern here could not express it.
146
149
  // Deliberately narrows skills/session-end/plan-verification.md:42-45's
147
150
  // per-session-state exclusion from "the whole .claude/ directory" down to
148
151
  // just the session ARTEFACTS — `.claude/rules/**` stays COUNTED because a
@@ -165,6 +168,24 @@ export const DRIFT_EXCLUDE_PATTERNS = [
165
168
  '.codex/STATE.md', '.codex/wave-scope.json', '.codex/metrics/**',
166
169
  '.cursor/STATE.md', '.cursor/wave-scope.json', '.cursor/metrics/**',
167
170
  '.pi/STATE.md', '.pi/wave-scope.json', '.pi/metrics/**',
171
+ // The plugin's OWN mid-session writes. They only became visible to the
172
+ // numerator when it started reading the working tree and untracked files
173
+ // (#1027). Two classes are excluded HERE, whatever their tracked status:
174
+ // - `.orchestrator/metrics/**` — a consumer repo keeps its `*.jsonl`
175
+ // TRACKED by design (skills/bootstrap/standard-template.md "Gitignore
176
+ // guidance") and hooks append to it all session.
177
+ // - `<state-dir>/filescopes/**` and `<state-dir>/worktrees/**` — bootstrap
178
+ // writes no ignore line for either. An agent worktree is a nested repo,
179
+ // so `ls-files --others` lists each as ONE `.claude/worktrees/<id>/`
180
+ // entry (measured 2026-10-02): one false file per isolated agent.
181
+ // Every OTHER plugin write under `.orchestrator/` (runtime/, tmp/, debug/,
182
+ // session.lock, current-session.json, ...) is excluded by
183
+ // `isUntrackedRuntimeWrite()` while untracked, which is how a consumer repo
184
+ // holds them. Its deliverables (`steering/`, `policy/`, `bootstrap.lock`)
185
+ // are tracked and count.
186
+ '.claude/filescopes/**', '.codex/filescopes/**', '.cursor/filescopes/**', '.pi/filescopes/**',
187
+ '.claude/worktrees/**', '.codex/worktrees/**', '.cursor/worktrees/**', '.pi/worktrees/**',
188
+ '.orchestrator/metrics/**',
168
189
  ];
169
190
 
170
191
  // ---------------------------------------------------------------------------
@@ -218,6 +239,38 @@ function filterExcluded(files) {
218
239
  );
219
240
  }
220
241
 
242
+ /**
243
+ * Is this UNTRACKED path the plugin's own runtime output? Same rule as
244
+ * `isOwnRuntimeArtifact()` in scripts/lib/project-hygiene.mjs (untracked
245
+ * under `.orchestrator/` = ours), mirrored rather than imported: it is not
246
+ * exported, and importing that module would pull js-yaml and the command
247
+ * blocker into this one for a one-line prefix test. Applied to the
248
+ * `ls-files --others` source only — a tracked `.orchestrator/` file is a
249
+ * deliverable or a ledger, and `DRIFT_EXCLUDE_PATTERNS` decides which.
250
+ *
251
+ * Ceiling (BV-004): a runtime file a consumer has COMMITTED (e.g. a
252
+ * `session.lock` swept in by `git add -A`) counts whenever the plugin
253
+ * rewrites it, and a NEW `steering/`/`policy/` file counts only once it is
254
+ * tracked. Revisit if a drift WARN traces to a tracked runtime file, or a
255
+ * missed WARN to an uncommitted steering/policy deliverable.
256
+ *
257
+ * @param {string} file repo-root-relative path from `git ls-files --others`
258
+ * @returns {boolean}
259
+ */
260
+ function isUntrackedRuntimeWrite(file) {
261
+ return file.startsWith('.orchestrator/');
262
+ }
263
+
264
+ /** Split git `--name-only`/`ls-files` stdout into trimmed non-empty paths. */
265
+ function outputPaths(stdout) {
266
+ return stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
267
+ }
268
+
269
+ // Per-query bound for the three numerator git calls — the same 10 s that
270
+ // scripts/lib/project-hygiene.mjs gives its read-only git queries. A timeout
271
+ // throws like any other failure and lands in the same skip.
272
+ const DRIFT_GIT_TIMEOUT_MS = 10_000;
273
+
221
274
  /**
222
275
  * Internal: read + parse STATE.md's frontmatter exactly once. Shared by
223
276
  * `readBaseline()` and `computeDrift()` so a single `computeDrift()` call
@@ -464,13 +517,38 @@ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFi
464
517
  }
465
518
 
466
519
  /**
467
- * Compute the S2 warn-only scope-drift ratio: how many files have actually
468
- * changed since the session's frozen `session-start-ref`, filtered through
520
+ * Compute the S2 warn-only scope-drift ratio: how many files differ in this
521
+ * checkout — committed since the session's frozen `session-start-ref`, plus
522
+ * whatever is uncommitted or untracked right now — filtered through
469
523
  * `DRIFT_EXCLUDE_PATTERNS`, against the `plannedFiles` count frozen by
470
524
  * `writeBaseline()`. Sync (`readFileSync` + `execFileSync('git', ...)`
471
525
  * only). Never throws, never denies — this is a WARN-only tripwire; the
472
526
  * CALLER decides whether/how to surface `breached`.
473
527
  *
528
+ * Numerator (#1027): the deduplicated union of three git queries — the
529
+ * committed range diff, the working tree against HEAD (staged + unstaged),
530
+ * and untracked files (`ls-files --others --exclude-standard`, minus the
531
+ * plugin's own untracked `.orchestrator/` writes, see
532
+ * `isUntrackedRuntimeWrite()`). Until #1027 only the first was read, so
533
+ * uncommitted edits and every NEW module — the typical shape of an
534
+ * overshoot — were invisible until someone committed them. All three must
535
+ * succeed: if any fails (including ENOBUFS and the per-query timeout) the
536
+ * result is the `unresolvable-ref` skip, never a ratio over a partial
537
+ * numerator. That reason is deliberately not split by cause: its readers
538
+ * (skills/wave-executor/references/wave-loop-review.md step 7a,
539
+ * skills/session-end/plan-verification.md step 5) render every skip
540
+ * silently or verbatim and none branches on it.
541
+ *
542
+ * Named ceiling (BV-004) — over-report: only the committed source is tied to
543
+ * `session-start-ref`. The working-tree and untracked sources read the
544
+ * checkout as it is NOW, so a file that was already dirty when the session
545
+ * started, and a same-checkout peer session's files, count as this session's
546
+ * drift. Fixing it means persisting a start-dirty snapshot at freeze time and
547
+ * subtracting it — a storage change, out of scope for this warn-only
548
+ * tripwire. Revisit if a drift WARN traces to pre-session dirt or to a
549
+ * peer's files (`git status` at session start shows the former,
550
+ * PSA-001 signals the latter).
551
+ *
474
552
  * Skip precedence (first match wins, so `reason` is deterministic):
475
553
  * `no-state-md` → `unreadable-state-md` → `no-baseline` →
476
554
  * `stale-baseline` → (`no-baseline-ref` | `unresolvable-ref`)
@@ -509,13 +587,14 @@ export async function writeBaseline({ repoRoot, intent, ownerBoundary, plannedFi
509
587
  *
510
588
  * Named ceiling (BV-004) on the local-branch tail of the resolution chain:
511
589
  * when the chain lands on a LOCAL `main`/`master` and HEAD is already that
512
- * branch, `main...HEAD` has merge-base === HEAD and the diff is empty, so
513
- * the ratio reads `0` instead of skipping. That is the honest floor of the
514
- * available information (no remote-tracking ref exists to say where the
515
- * session started) and it is harmless here because both a `0` ratio and a
516
- * skip are silent under `wave-loop.md`'s WARN-on-breach rule. Revisit if
517
- * this function ever gains a non-warn consumer that treats `skipped:false`
518
- * as "measured successfully".
590
+ * branch, `main...HEAD` has merge-base === HEAD and the COMMITTED source is
591
+ * empty, so the session's commits are invisible and the ratio counts only
592
+ * the uncommitted and untracked files instead of skipping. That is the
593
+ * honest floor of the available information (no remote-tracking ref exists
594
+ * to say where the session started); it can only under-report, and an
595
+ * under-report is as silent as a skip under `wave-loop-review.md`'s
596
+ * WARN-on-breach rule. Revisit if this function ever gains a non-warn
597
+ * consumer that treats `skipped:false` as "measured successfully".
519
598
  *
520
599
  * @param {object} args
521
600
  * @param {string|undefined} args.repoRoot
@@ -578,26 +657,38 @@ export function computeDrift({ repoRoot, threshold = 2.0 } = {}) {
578
657
  refUsed = resolved.range;
579
658
  }
580
659
 
581
- let stdout;
660
+ // Untracked files can be numerous, so the 1 MiB default buffer would turn
661
+ // a large tree into an ENOBUFS skip.
662
+ const gitOpts = { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: DRIFT_GIT_TIMEOUT_MS };
663
+ const changedFiles = new Set();
582
664
  try {
583
- stdout = execFileSync('git', ['diff', '--name-only', diffRange], { cwd, encoding: 'utf8' });
665
+ const committed = execFileSync('git', ['diff', '--name-only', diffRange], gitOpts);
666
+ const workingTree = execFileSync('git', ['diff', '--name-only', 'HEAD'], gitOpts);
667
+ // `--full-name` + `:/` keep these paths in the same repo-root-relative
668
+ // space as `git diff --name-only`, even if `cwd` is a subdirectory.
669
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--full-name', '--', ':/'], gitOpts);
670
+ for (const file of [...outputPaths(committed), ...outputPaths(workingTree)]) changedFiles.add(file);
671
+ for (const file of outputPaths(untracked)) {
672
+ if (!isUntrackedRuntimeWrite(file)) changedFiles.add(file);
673
+ }
584
674
  } catch {
585
- // The range resolved but the diff against it failed — a present-but-dead
586
- // `session-start-ref` (rebase, force-push, deleted commit), or a
587
- // resolved base that vanished between resolution and diff. Neither can
588
- // produce a trustworthy numerator.
675
+ // The range resolved but a numerator query failed — a present-but-dead
676
+ // `session-start-ref` (rebase, force-push, deleted commit), a resolved
677
+ // base that vanished between resolution and diff, a working-tree or
678
+ // untracked query git could not answer, output over `maxBuffer`
679
+ // (ENOBUFS), or a query past `DRIFT_GIT_TIMEOUT_MS`. None can produce a
680
+ // trustworthy numerator, and a partial one must not be reported as
681
+ // measured. All share `unresolvable-ref`: every reader renders a skip
682
+ // silently and none branches on the reason (census in the JSDoc above).
589
683
  return { ok: true, skipped: true, reason: 'unresolvable-ref' };
590
684
  }
591
685
 
592
- const changedFiles = stdout
593
- .split('\n')
594
- .map((line) => line.trim())
595
- .filter((line) => line.length > 0);
596
-
597
686
  // Same `filterExcluded()` primitive `writeBaseline()`'s denominator
598
- // (`countPlannedFiles()`) calls (#894 review finding F1) — both sides of
599
- // the ratio are provably produced by one function.
600
- const actualFiles = filterExcluded(changedFiles).length;
687
+ // (`countPlannedFiles()`) calls (#894 review finding F1), so the pattern
688
+ // filter on both sides of the ratio is one function. The untracked
689
+ // `.orchestrator/` rule above is numerator-only by construction: a planned
690
+ // path carries no tracked status to test.
691
+ const actualFiles = filterExcluded([...changedFiles]).length;
601
692
 
602
693
  const plannedFilesRaw = baseline.plannedFiles;
603
694
  const plannedFiles = typeof plannedFilesRaw === 'number' && Number.isFinite(plannedFilesRaw)
@@ -51,6 +51,7 @@ import { join, resolve } from 'node:path';
51
51
 
52
52
  import { digestSha256Short } from './crypto-digest-utils.mjs';
53
53
  import { isMainModule } from './is-main-module.mjs';
54
+ import { isCoordinatorRecordId } from './scope-gate.mjs';
54
55
 
55
56
  /** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
56
57
  export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
@@ -329,6 +330,11 @@ function parseJsonl(raw) {
329
330
  * only iterates the entries is unaffected. (A file that parses to a non-array or
330
331
  * to an empty scope stays a silent skip: an empty scope is legitimate.)
331
332
  *
333
+ * `coordinator.json` is skipped too (#1493.2): the coordinator's planned direct
334
+ * edits are a declaration, never a dispatch, so no injection or echo can ever
335
+ * claim its digest — digesting it reported the coordinator's own scope as
336
+ * `injection-missing` on every wave where it planned an edit.
337
+ *
332
338
  * @param {string} stateDir
333
339
  * @param {number} wave
334
340
  * @param {{readDir?: typeof readdirSync, readFile?: typeof readFileSync}} [io]
@@ -347,6 +353,7 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
347
353
  for (const name of names) {
348
354
  const file = typeof name === 'string' ? name : name?.name;
349
355
  if (typeof file !== 'string' || !file.endsWith('.json')) continue;
356
+ if (isCoordinatorRecordId(file.slice(0, -'.json'.length))) continue;
350
357
  let parsed;
351
358
  try {
352
359
  parsed = JSON.parse(readFile(join(dir, file), 'utf8'));