session-orchestrator 5.4.0 → 5.6.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 (151) 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 +115 -3
  7. package/README.md +30 -15
  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 +19 -15
  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 +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  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 +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -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 +60 -6
  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/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -16,6 +16,9 @@ import path from 'node:path';
16
16
 
17
17
  import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
18
18
 
19
+ /** The harness state dirs a `wave-scope.json` may live in, highest precedence first. */
20
+ const SCOPE_FILE_DIRS = Object.freeze(['.pi', '.cursor', '.codex', '.claude']);
21
+
19
22
  /**
20
23
  * Find the wave-scope.json file for the given project root.
21
24
  *
@@ -28,11 +31,12 @@ import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './comma
28
31
  * Returns the absolute path string, or null if none exist.
29
32
  * Sync (uses fs.existsSync).
30
33
  *
34
+ * @deprecated Use findOwnScopeFile because first-file selection ignores manifest ownership and can select a peer session's manifest.
31
35
  * @param {string} projectRoot — absolute path to project root
32
36
  * @returns {string|null}
33
37
  */
34
38
  export function findScopeFile(projectRoot) {
35
- for (const dir of ['.pi', '.cursor', '.codex', '.claude']) {
39
+ for (const dir of SCOPE_FILE_DIRS) {
36
40
  const candidate = path.join(projectRoot, dir, 'wave-scope.json');
37
41
  if (existsSync(candidate)) {
38
42
  return candidate;
@@ -41,6 +45,67 @@ export function findScopeFile(projectRoot) {
41
45
  return null;
42
46
  }
43
47
 
48
+ /**
49
+ * The manifest that governs THIS session: walk the {@link findScopeFile}
50
+ * precedence (`.pi` > `.cursor` > `.codex` > `.claude`) and return the first
51
+ * candidate that is NOT provably bound to another session (#1504 point 6).
52
+ *
53
+ * `findScopeFile` stops at the first file that EXISTS. When that file is a
54
+ * peer's — another harness session's manifest in the same working copy — every
55
+ * guard that stands down on a foreign manifest stood down, and this session's
56
+ * own `.claude/wave-scope.json` one rung lower was never read: a peer's
57
+ * manifest switched this session's scope gates off.
58
+ *
59
+ * Disposition per candidate, decided INSIDE the loop (an identity check after
60
+ * the loop makes the first readable candidate the veto-holder —
61
+ * `.claude/rules/identity-and-locks.md`):
62
+ * - `'own'` or `'unknown'` → return it. `'unknown'` covers an unbound/legacy
63
+ * manifest, a corrupt or unreadable one (judged as `{}`), and an empty
64
+ * `ownIds` (a harness that supplies no session id). Each means ENFORCE
65
+ * under the binding contract, so each stays the decider exactly as under
66
+ * `findScopeFile` — a truncated manifest cannot skip itself.
67
+ * - `'foreign'` → remember the first one and `continue`.
68
+ *
69
+ * When EVERY existing candidate is foreign, `path` is null and `foreignPath`
70
+ * names the first of them, so the caller reaches its existing foreign
71
+ * disposition (allow + `orchestrator.scope.foreign_session_ignored`) unchanged.
72
+ *
73
+ * `classify` is injected, not imported: importing the identity module here
74
+ * would add it to the import closure of every hook that reaches this file
75
+ * through the `hardening.mjs` barrel, while only the guards that stand down on
76
+ * a foreign manifest need it — and each of them already binds it. Pass
77
+ * `classifyManifestSession` from `session-identity/own-session.mjs`.
78
+ *
79
+ * COST: identical to `findScopeFile` when no manifest exists (≤4 `existsSync`).
80
+ * With one, one extra sync read + parse of that small file. The caller still
81
+ * reads the returned file itself and classifies THAT read, so a rebind between
82
+ * the two reads is judged at the same decision point as before.
83
+ *
84
+ * Sync. Never throws (unless `classify` does).
85
+ *
86
+ * @param {string} projectRoot absolute path to the project root
87
+ * @param {Set<string>} ownIds process-local own ids (`readProcessLocalSessionIds`)
88
+ * @param {(scope: object, ownIds: Set<string>) => { verdict: string }} classify
89
+ * @returns {{ path: string|null, foreignPath: string|null }}
90
+ */
91
+ export function findOwnScopeFile(projectRoot, ownIds, classify) {
92
+ let foreignPath = null;
93
+ for (const dir of SCOPE_FILE_DIRS) {
94
+ const candidate = path.join(projectRoot, dir, 'wave-scope.json');
95
+ if (!existsSync(candidate)) continue;
96
+ let scope = {};
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'));
99
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) scope = parsed;
100
+ } catch {
101
+ // unreadable / corrupt → `{}` → no binding → 'unknown' → this candidate decides
102
+ }
103
+ if (classify(scope, ownIds).verdict !== 'foreign') return { path: candidate, foreignPath };
104
+ foreignPath ??= candidate;
105
+ }
106
+ return { path: null, foreignPath };
107
+ }
108
+
44
109
  /**
45
110
  * Read the enforcement level from a scope file.
46
111
  * Defaults to "strict" (fail-closed) on parse error or missing field.
@@ -190,7 +255,8 @@ function literalScopePrefix(entry) {
190
255
  * verbatim presence + literal-prefix coverage; it does not prove that e.g.
191
256
  * `src/**\/*.ts` ⊆ `src/**\/*.js` is false. The concrete-path branch above is
192
257
  * exact and carries the incident-relevant load (the union the coordinator
193
- * actually writes is verbatim, deduplicated agent scopes). Erring toward
258
+ * actually writes is the deduplicated agent scopes in canonical spelling, see
259
+ * {@link unionFileScopes}). Erring toward
194
260
  * over-approximating coverage keeps a legitimate union from being rejected on a
195
261
  * glob technicality rather than pretending to a precision this matcher lacks.
196
262
  *
@@ -211,16 +277,21 @@ export function assertFileScopeSubset(fileScope, allowedPaths) {
211
277
  const missing = [];
212
278
  for (const entry of fileScope) {
213
279
  if (typeof entry !== 'string' || entry.length === 0) continue;
280
+ // The AGENT side is compared in canonical spelling — the spelling the union
281
+ // emits and the write path Gate 7 matches. `allowedPaths` stays as written:
282
+ // a hand-written `./x` there is a pattern Gate 7 cannot match, and spelling
283
+ // it away here would report coverage the hook then denies.
284
+ const spelled = scopeEntrySpelling(entry);
214
285
  let covered;
215
- if (isGlobScopeEntry(entry)) {
286
+ if (isGlobScopeEntry(spelled)) {
216
287
  // GLOB entry: verbatim presence OR literal-prefix coverage.
217
- const prefix = literalScopePrefix(entry);
218
- covered = allowedPaths.some((p) => p === entry || pathMatchesPattern(prefix, p));
288
+ const prefix = literalScopePrefix(spelled);
289
+ covered = allowedPaths.some((p) => p === spelled || pathMatchesPattern(prefix, p));
219
290
  } else {
220
291
  // CONCRETE entry: must match ≥1 allowedPaths pattern.
221
- covered = allowedPaths.some((p) => pathMatchesPattern(entry, p));
292
+ covered = allowedPaths.some((p) => pathMatchesPattern(spelled, p));
222
293
  }
223
- if (!covered) missing.push(entry);
294
+ if (!covered) missing.push(entry); // as declared, so the plan entry is findable
224
295
  }
225
296
  return { ok: missing.length === 0, missing };
226
297
  }
@@ -329,13 +400,16 @@ export function testSiblingExpansionApplies(opts = {}) {
329
400
  *
330
401
  * ## Why a GLOB and not a computed path
331
402
  * The emitted sibling is `tests/**\/{basename}*.test.mjs`, never a concrete
332
- * path. Measured over all 430 tracked production `.mjs` in this repo:
333
- * a naive 1:1 mirror (`scripts/lib/X.mjs → tests/lib/X.test.mjs`) is right
334
- * 301/430 = 70.0% (`hooks/_lib` 0/5, `scripts/ci/` 0/1); a same-basename test
335
- * ANYWHERE under `tests/` is right 368/430 = 85.6%. The glob takes the 85.6%
336
- * form, and its failure mode is HARMLESS — it grants write access to files that
337
- * may not exist. A computed concrete path is wrong 30% of the time AND still
338
- * denies the real test, which is the original bug wearing a new face.
403
+ * path: a same-basename test ANYWHERE under `tests/` resolves for far more
404
+ * production files than a naive 1:1 mirror (`scripts/lib/X.mjs →
405
+ * tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
406
+ * measuring command and the SHA — live in ONE place:
407
+ * skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
408
+ * Expansion (#970). Cite and re-measure them there, never restate them here: an
409
+ * undated copy drifted to a second, contradicting set of numbers (#1026.5,
410
+ * PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
411
+ * to files that may not exist. A computed concrete path is wrong more often AND
412
+ * still denies the real test, which is the original bug wearing a new face.
339
413
  *
340
414
  * ## Required behaviours (each is a nameable regression)
341
415
  * 1. `[]` → `[]` STRUCTURALLY. Discovery waves use an empty scope as a
@@ -403,8 +477,11 @@ export function testSiblingsFor(entries, opts = {}) {
403
477
  : DEFAULT_TEST_PATH_PATTERNS;
404
478
  const out = [];
405
479
  const seen = new Set();
406
- for (const entry of Array.isArray(entries) ? entries : []) {
407
- if (typeof entry !== 'string' || entry.length === 0) continue;
480
+ for (const declared of Array.isArray(entries) ? entries : []) {
481
+ if (typeof declared !== 'string' || declared.length === 0) continue;
482
+ // Canonical spelling, as the union carries it: a verbatim `./tests/x.mjs`
483
+ // misses `tests/**` and would be mirrored as if it were production code.
484
+ const entry = scopeEntrySpelling(declared);
408
485
  if (path.isAbsolute(entry)) continue; // behaviour 3
409
486
  if (isGlobScopeEntry(entry)) continue; // no single basename to mirror
410
487
  if (isTestPathEntry(entry, testPatterns)) continue; // behaviours 2 + 4
@@ -940,6 +1017,53 @@ export function isPeerRecordId(id) {
940
1017
  return typeof id === 'string' && id.startsWith(PEER_RECORD_PREFIX);
941
1018
  }
942
1019
 
1020
+ /**
1021
+ * Record id of the COORDINATOR's own planned direct edits (#1020): one
1022
+ * `{id: 'coordinator', files}` record in a wave's aggregate sidecar, and
1023
+ * `<state-dir>/filescopes/wave-<N>/coordinator.json` in the per-agent shape.
1024
+ * Exactly lowercase — `materialize-wave-scope.mjs` rejects any other casing.
1025
+ *
1026
+ * The coordinator is a declarant but never a DISPATCH: no `FILE-SCOPE` block is
1027
+ * injected for it, no digest is echoed back, no agent is counted for it. Every
1028
+ * consumer that joins scope files against dispatches must skip it, or it reports
1029
+ * the coordinator's own scope as an omitted injection (#1493.2). SSOT beside
1030
+ * {@link PEER_RECORD_PREFIX} for the same reason that one lives here.
1031
+ */
1032
+ export const COORDINATOR_RECORD_ID = 'coordinator';
1033
+
1034
+ /**
1035
+ * Is this record id the coordinator's (see {@link COORDINATOR_RECORD_ID})?
1036
+ * Exact match; a non-string id is not the coordinator.
1037
+ *
1038
+ * @param {unknown} id
1039
+ * @returns {boolean}
1040
+ */
1041
+ export function isCoordinatorRecordId(id) {
1042
+ return id === COORDINATOR_RECORD_ID;
1043
+ }
1044
+
1045
+ /**
1046
+ * Is this repo-relative path inside a wave's per-agent scope declarations,
1047
+ * `<state-dir>/filescopes/` (shape (a) and the aggregate sidecar, #1020)?
1048
+ *
1049
+ * Those files are coordinator CONTROL STATE like `wave-scope.json` itself,
1050
+ * written by `materialize-wave-scope.mjs` once per wave — never a wave
1051
+ * territory, so a write there is not a scope violation (#1027 Pkt 5). The
1052
+ * caller passes the manifest's own repo-relative path, so the state dir is the
1053
+ * one THIS manifest lives in, never a guessed `.claude/`.
1054
+ *
1055
+ * @param {string} relPath repo-relative, forward slashes
1056
+ * @param {string} scopeRelPath repo-relative path of the active `wave-scope.json`
1057
+ * @returns {boolean}
1058
+ */
1059
+ export function isScopeDeclarationPath(relPath, scopeRelPath) {
1060
+ if (typeof relPath !== 'string' || typeof scopeRelPath !== 'string') return false;
1061
+ const scopeRel = scopeRelPath.replace(/\\/g, '/');
1062
+ const cut = scopeRel.lastIndexOf('/');
1063
+ if (cut <= 0) return false;
1064
+ return relPath.startsWith(`${scopeRel.slice(0, cut)}/filescopes/`);
1065
+ }
1066
+
943
1067
  /**
944
1068
  * Merge many agents' declared file scopes into ONE deduplicated, order-stable
945
1069
  * list — the mechanical form of "allowedPaths is the UNION of all agent file
@@ -965,6 +1089,14 @@ export function isPeerRecordId(id) {
965
1089
  * non-array / non-object members and non-string, empty entries are skipped.
966
1090
  * Pure, sync, no I/O — hook-safe per the module header.
967
1091
  *
1092
+ * ENTRIES ARE EMITTED IN CANONICAL SPELLING ({@link scopeEntrySpelling}) and
1093
+ * deduplicated on it. Gate 7 matches the repo-relative WRITE path against each
1094
+ * entry, so a verbatim `./scripts/lib/foo.mjs` was a pattern no write could
1095
+ * ever match — the declaring agent's own write was denied — and kept a second
1096
+ * spelling of `scripts/lib/foo.mjs` beside it. The agent-side checks
1097
+ * ({@link assertFileScopeSubset}, {@link testSiblingsFor}) compare the same
1098
+ * spelling, so a per-agent file that keeps the plan's `./x` still passes.
1099
+ *
968
1100
  * PEER RECORDS ARE EXCLUDED (#1195 follow-through). A record whose id starts
969
1101
  * with `peer-session-` declares a territory NO agent of this wave may write —
970
1102
  * it exists so a peer's paths take part in the DISJOINTNESS check and so
@@ -995,9 +1127,10 @@ export function unionFileScopes(scopes) {
995
1127
  if (files === null) continue;
996
1128
  for (const entry of files) {
997
1129
  if (typeof entry !== 'string' || entry.length === 0) continue;
998
- if (seen.has(entry)) continue;
999
- seen.add(entry);
1000
- out.push(entry);
1130
+ const spelled = scopeEntrySpelling(entry);
1131
+ if (seen.has(spelled)) continue;
1132
+ seen.add(spelled);
1133
+ out.push(spelled);
1001
1134
  }
1002
1135
  }
1003
1136
  return out;
@@ -1122,8 +1255,11 @@ function globsDisagreeOnLiteralSegment(x, y) {
1122
1255
  * NEGATIVE — the whole point of this function is that an unreviewed scope is
1123
1256
  * exactly the one that collides.
1124
1257
  *
1258
+ * `files` holds each entry's comparison SPELLING ({@link scopeEntrySpelling});
1259
+ * `declared` holds the entry as written, index-aligned, for the evidence report.
1260
+ *
1125
1261
  * @param {Array<{id?: string, files?: string[]}>} agentScopes
1126
- * @returns {Array<{id: string, declaredId: string|null, files: string[]}>}
1262
+ * @returns {Array<{id: string, declaredId: string|null, files: string[], declared: string[]}>}
1127
1263
  */
1128
1264
  function normalizeAgentScopes(agentScopes) {
1129
1265
  const out = [];
@@ -1131,14 +1267,50 @@ function normalizeAgentScopes(agentScopes) {
1131
1267
  const raw = agentScopes[i];
1132
1268
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue;
1133
1269
  const declaredId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null;
1134
- const files = Array.isArray(raw.files)
1270
+ const declared = Array.isArray(raw.files)
1135
1271
  ? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
1136
1272
  : [];
1137
- out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files });
1273
+ const files = declared.map(scopeEntrySpelling);
1274
+ out.push({ id: declaredId ?? `<unnamed#${i}>`, declaredId, files, declared });
1138
1275
  }
1139
1276
  return out;
1140
1277
  }
1141
1278
 
1279
+ /**
1280
+ * One scope entry's canonical SPELLING — THE spelling rule of this module.
1281
+ * Used by the collision comparison (#1026.3), by {@link unionFileScopes} (whose
1282
+ * output IS `allowedPaths`, so Gate 7 sees this spelling), and on the agent side
1283
+ * of {@link assertFileScopeSubset} / {@link testSiblingsFor}, so a per-agent file
1284
+ * that keeps the plan's declaration verbatim still agrees with the union.
1285
+ * Equal-meaning spellings compared unequal before: `./scripts/lib/foo.mjs` vs
1286
+ * `scripts/lib/foo.mjs` was reported disjoint (measured 2026-10-03 @ 8292050e).
1287
+ * It changes no grant's MEANING — only how one is spelled.
1288
+ *
1289
+ * Collapses runs of `/` and drops `.` segments (`./a`, `a/./b`). A leading `/`
1290
+ * (absolute entry) and a trailing `/` survive: the trailing slash is meaning,
1291
+ * not spelling — `scripts/lib/` is a prefix grant, while `scripts/lib` grants
1292
+ * only the literal path `scripts/lib` (`pathMatchesPattern('scripts/lib/foo.mjs',
1293
+ * 'scripts/lib') === false`), so those two stay disjoint.
1294
+ *
1295
+ * NAMED CEILING: `..` segments are left alone — `a/../b` is not provably `b`
1296
+ * when `a` is a glob segment. Revisit if a plan ever declares a `..` entry.
1297
+ *
1298
+ * EXPORTED as the ONE spelling rule (#1510 g): `hooks/pre-task-scope-disjoint.mjs`
1299
+ * applies it (late-bound) before its own dedupe and dir-promotion, instead of a
1300
+ * hand-copied twin that had already drifted (`a/././b` kept a `./` segment).
1301
+ * Whitespace is NOT trimmed here: no writer of a scope entry produces padding
1302
+ * (the hook's prose cleaner trims before this rule runs; sidecar entries are
1303
+ * JSON strings the plan writes), and trimming would rewrite a declared path.
1304
+ *
1305
+ * @param {string} entry - a non-empty scope entry
1306
+ * @returns {string}
1307
+ */
1308
+ export function scopeEntrySpelling(entry) {
1309
+ const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
1310
+ // `./` alone would spell as '' — keep the entry rather than invent a meaning.
1311
+ return spelled.length > 0 ? spelled : entry;
1312
+ }
1313
+
1142
1314
  /**
1143
1315
  * Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
1144
1316
  * Returns the collision `kind`, or `null` when the two entries are disjoint.
@@ -1316,17 +1488,19 @@ export function findScopeCollisions(agentScopes, opts = {}) {
1316
1488
  const b = agents[j];
1317
1489
  if (a.id === b.id) continue; // duplicate-id record: reported separately
1318
1490
  const buckets = new Map();
1319
- for (const x of a.files) {
1320
- for (const y of b.files) {
1321
- const kind = classifyEntryCollision(x, y, expand);
1491
+ // Classify on the comparison spelling; report the entry AS DECLARED, so
1492
+ // the operator can find it in the plan (#1026.3).
1493
+ for (let xi = 0; xi < a.files.length; xi++) {
1494
+ for (let yi = 0; yi < b.files.length; yi++) {
1495
+ const kind = classifyEntryCollision(a.files[xi], b.files[yi], expand);
1322
1496
  if (kind === null) continue;
1323
1497
  let evidence = buckets.get(kind);
1324
1498
  if (evidence === undefined) {
1325
1499
  evidence = new Set();
1326
1500
  buckets.set(kind, evidence);
1327
1501
  }
1328
- evidence.add(x);
1329
- evidence.add(y);
1502
+ evidence.add(a.declared[xi]);
1503
+ evidence.add(b.declared[yi]);
1330
1504
  }
1331
1505
  }
1332
1506
  for (const kind of KIND_ORDER) {
@@ -64,6 +64,9 @@ import { serializeSessionLineChecked as defaultSerialize } from './session-schem
64
64
  import { resolveStateMdPath as defaultResolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
65
65
  import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs';
66
66
  import { canonicalizeSessions } from './sessions-canonical.mjs';
67
+ // Already in the hook import set (`emitEvent` lives there): the rotation-aware
68
+ // reader adds no new file to the SessionStart/SessionEnd hook graph.
69
+ import { scanEventsBackwards } from './events.mjs';
67
70
  // Leaf constants module (no imports of its own) and ALREADY in the hook import
68
71
  // set via session-schema/validator.mjs — importing it here adds no new file to
69
72
  // the SessionStart/SessionEnd hook graph. The profile enum must not be
@@ -326,6 +329,187 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
326
329
  };
327
330
  }
328
331
 
332
+ /**
333
+ * Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
334
+ * `null` — {@link gatherSessionEvents} reads back as far as a candidate's own
335
+ * `session.started`, however old, so no day count bounds it. A cut history
336
+ * leaves the candidate's oldest events unread: its record then lists
337
+ * `started_at` among `_backfill_incomplete_fields` — the same answer as before
338
+ * this reader existed, never a guessed time.
339
+ */
340
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
341
+
342
+ /**
343
+ * Every record of the ledger — rotated archives included — whose raw line
344
+ * contains `id`, oldest first. A backward scan in the existing rotation-aware
345
+ * reader (`scanEventsBackwards`), reversed: one scan's order is global, so the
346
+ * file order {@link collectSessionEvents} depends on survives.
347
+ *
348
+ * @param {string} eventsPath
349
+ * @param {string} id
350
+ * @param {number|null} deadlineMs — epoch ms the scan must end by; `null` = unbounded
351
+ * @returns {{records: Array<object>, lostFields: string[]}|null} `null` when the
352
+ * deadline had passed before the scan or cut it short, or a recoverable cut
353
+ * lies in the session's window — a partial history is never handed on as a
354
+ * whole one. `lostFields` names the record fields a PERMANENTLY pruned archive
355
+ * inside the window may have changed (see {@link windowGapVerdict}).
356
+ */
357
+ function recordsMentioning(eventsPath, id, deadlineMs) {
358
+ let budgetMs;
359
+ if (deadlineMs !== null) {
360
+ budgetMs = deadlineMs - Date.now();
361
+ if (!(budgetMs > 0)) return null;
362
+ }
363
+ const newestFirst = [];
364
+ const { truncated, unreadable, gaps } = scanEventsBackwards({
365
+ filePath: eventsPath,
366
+ filter: id,
367
+ budgetMs,
368
+ onRecord: (record) => {
369
+ newestFirst.push(record);
370
+ return false;
371
+ },
372
+ });
373
+ // #1505.5b: a source the walk could not read cut the history exactly like a
374
+ // deadline does — its records are simply absent — so it defers the candidate
375
+ // the same way. Ceiling (BV-004): a PERMANENTLY unreadable archive defers its
376
+ // candidates on every run (`skipped-history-unread`, visible, never a record
377
+ // dated NOW).
378
+ if (truncated || (Array.isArray(unreadable) && unreadable.length > 0)) return null;
379
+ const verdict = windowGapVerdict(gaps, newestFirst);
380
+ if (verdict.defer) return null;
381
+ return { records: newestFirst.reverse(), lostFields: verdict.lostFields };
382
+ }
383
+
384
+ /**
385
+ * What a pruned/unindexed archive (`gaps` of {@link scanEventsBackwards}) means
386
+ * for the session whose found records are `records` (#1512).
387
+ *
388
+ * The walk reports every gap of the WHOLE ledger, mostly far older than the
389
+ * candidate; deferring on any of them would skip the candidate forever. So a
390
+ * gap counts only when its `[first_ts, last_ts]` intersects the session's
391
+ * window `[earliest, latest]` timestamp of the records the walk found.
392
+ *
393
+ * A counted gap's KIND then decides. `unindexed-archive` — the file still sits
394
+ * in `_archive/` under a name the walk does not index — can resolve (a rename
395
+ * puts it back), so it DEFERS (`skipped-history-unread`). `missing-archive` —
396
+ * pruned, its bytes gone — never resolves, so deferring would skip the candidate
397
+ * on every run forever; the record is written instead, naming in `lostFields`
398
+ * what the lost span may have held: `session_type` and `session_profile` (the
399
+ * LAST `shape_resolved` decides both) always, `completed_at` when the gap reaches past the latest found
400
+ * record. An unknown kind defers (fail toward not writing a wrong record).
401
+ *
402
+ * Fail direction (BV-004): a gap missing either bound, or a session with no
403
+ * dated record, is NOT counted — an unbounded gap would intersect every later
404
+ * session and recreate the defer-forever bug. Such a candidate is written as
405
+ * before #1512, its unread start listed in `_backfill_incomplete_fields`.
406
+ * Likewise a gap that ate the session's START lies just before the earliest
407
+ * found record and stays outside the window — same incomplete-start answer.
408
+ * Symmetrically a gap that starts AFTER the newest found record (it ate the
409
+ * session's END) is not counted either, so a `shape_resolved` or stop event
410
+ * lost there goes unnamed in `lostFields`; revisit if a backfilled record's
411
+ * `completed_at` is later contradicted by a recovered archive.
412
+ * Ceiling: an `unindexed-archive` nobody renames back defers its candidate on
413
+ * every run, visibly. Revisit if tombstones without `first_ts`/`last_ts` still
414
+ * appear in ledgers, or if `skipped-history-unread` persists across many runs.
415
+ *
416
+ * @param {Array<{kind?: string, first_ts?: string|null, last_ts?: string|null}>|undefined} gaps
417
+ * @param {Array<object>} records
418
+ * @returns {{defer: boolean, lostFields: string[]}}
419
+ */
420
+ function windowGapVerdict(gaps, records) {
421
+ const none = { defer: false, lostFields: [] };
422
+ if (!Array.isArray(gaps) || gaps.length === 0) return none;
423
+ let lo = Infinity;
424
+ let hi = -Infinity;
425
+ for (const r of records) {
426
+ const ts = typeof r?.timestamp === 'string' ? Date.parse(r.timestamp) : NaN;
427
+ if (Number.isNaN(ts)) continue;
428
+ lo = Math.min(lo, ts);
429
+ hi = Math.max(hi, ts);
430
+ }
431
+ if (lo > hi) return none;
432
+ const lost = new Set();
433
+ for (const g of gaps) {
434
+ const first = typeof g?.first_ts === 'string' ? Date.parse(g.first_ts) : NaN;
435
+ const last = typeof g?.last_ts === 'string' ? Date.parse(g.last_ts) : NaN;
436
+ if (Number.isNaN(first) || Number.isNaN(last)) continue;
437
+ if (!(first <= hi && last >= lo)) continue;
438
+ if (g.kind !== 'missing-archive') return { defer: true, lostFields: [] };
439
+ lost.add('session_type');
440
+ lost.add('session_profile'); // same latest-wins `shape_resolved` record
441
+ if (last > hi) lost.add('completed_at');
442
+ }
443
+ return { defer: false, lostFields: [...lost] };
444
+ }
445
+
446
+ /**
447
+ * {@link collectSessionEvents} over the ACTIVE events.jsonl, and — when that
448
+ * holds no `session.started` for the candidate — over the whole rotated ledger.
449
+ *
450
+ * #1498: `planSessions` reads every archive since #1414, but this core read only
451
+ * the active file, so a candidate whose events had rotated away gathered
452
+ * nothing: no `lastEventMs`, hence never dead-by-age and skipped behind a live
453
+ * foreign lock on every run — or, without a lock, written with `started_at` and
454
+ * `completed_at` set to NOW. The active read stays first and cheap; the scan
455
+ * runs only for such a candidate, and once its record is written the CLI's
456
+ * sessions.jsonl pre-filter skips it on every later run.
457
+ *
458
+ * Cost (#1498 review): ≈0.55-0.8 ms per MB of ledger per such candidate —
459
+ * measured 2026-10-02: 25 candidates over a synthetic 58 MB ledger of six
460
+ * sources took 1.17-1.20 s, active reads included, at load average ~20; the
461
+ * review measured +0.80-0.87 s for the scans alone at 60 MB and load ~7. That
462
+ * does not fit the 5 s SessionStart hook unbounded, so a caller may pass
463
+ * `deadlineMs`: ONE epoch-ms deadline shared by every candidate of a run. A
464
+ * scan the deadline skipped or cut returns `historyUnread: true` instead of a
465
+ * partial answer, and the caller defers the candidate — never the active-file
466
+ * answer, which for such a candidate is a record dated NOW (see above) that the
467
+ * dedupe would then keep forever.
468
+ *
469
+ * One filtered scan keeps the order exact: a native UUID appears on every event
470
+ * of its session. Without one, the semantic label is scanned for the UUID
471
+ * bridge, then that UUID's events. Named ceiling (BV-004): a label bridging to
472
+ * SEVERAL UUIDs would need several scans merged without a shared order, so it
473
+ * keeps the active-file answer — revisit if a backfill record shows
474
+ * `started_at` among its incomplete fields while the archive holds it.
475
+ *
476
+ * @param {Function} readFileSync
477
+ * @param {string} eventsPath
478
+ * @param {{ sessionId: string|null, semanticSessionId: string|null }} ids
479
+ * @param {number|null} [deadlineMs=null] — shared scan deadline (epoch ms); `null` = unbounded
480
+ * @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true, historyLostFields?: string[] }}
481
+ */
482
+ function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
483
+ const gathered = collectSessionEvents(readJsonlSafe(readFileSync, eventsPath), ids);
484
+ if (gathered.startedAt !== null) return gathered;
485
+ try {
486
+ let uuid = isUuid(ids.sessionId) ? ids.sessionId : null;
487
+ let bridgeLost = [];
488
+ if (uuid === null) {
489
+ const label = ids.semanticSessionId || ids.sessionId;
490
+ if (!label) return gathered;
491
+ const bridge = recordsMentioning(eventsPath, label, deadlineMs);
492
+ if (bridge === null) return { ...gathered, historyUnread: true };
493
+ bridgeLost = bridge.lostFields;
494
+ const bridged = collectSessionEvents(bridge.records, ids).uuids;
495
+ if (bridged.size !== 1) return gathered;
496
+ [uuid] = bridged;
497
+ }
498
+ const history = recordsMentioning(eventsPath, uuid, deadlineMs);
499
+ if (history === null) return { ...gathered, historyUnread: true };
500
+ // Empty only when the scan found no source at all — e.g. a reader seam the
501
+ // filesystem does not back; the active answer stands.
502
+ if (history.records.length === 0) return gathered;
503
+ const collected = collectSessionEvents(history.records, ids);
504
+ // #1512: a permanently pruned archive inside the window — written, with
505
+ // what it may have held named instead of deferring forever.
506
+ const lostFields = [...new Set([...bridgeLost, ...history.lostFields])];
507
+ return lostFields.length > 0 ? { ...collected, historyLostFields: lostFields } : collected;
508
+ } catch {
509
+ return gathered;
510
+ }
511
+ }
512
+
329
513
  /**
330
514
  * Determine whether a candidate should be treated as dead-by-age DESPITE a
331
515
  * live FOREIGN session.lock (#731 — the historical migration CLI blocks
@@ -445,6 +629,9 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
445
629
  // no terminal event was found, so mark it incomplete so downstream duration
446
630
  // consumers can tell an events-attested end apart from an estimate (#914 R1).
447
631
  if (completedEstimated) incomplete.push('completed_at');
632
+ // #1512: fields a permanently pruned archive inside the session window may
633
+ // have held (`windowGapVerdict`), named rather than deferring forever.
634
+ for (const f of gathered.historyLostFields ?? []) if (!incomplete.includes(f)) incomplete.push(f);
448
635
 
449
636
  const record = {
450
637
  session_id: recordId,
@@ -724,6 +911,11 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
724
911
  * `lastEventMs` strictly predates this value bypasses a foreign live lock
725
912
  * regardless of `relaxDeadByAge`. Corresponds to the CLI's
726
913
  * `--assume-dead-before <ISO>` flag.
914
+ * @param {number|null} [args.archiveScanDeadlineMs=null]
915
+ * #1498 — epoch ms by which a scan of the ROTATED ledger must end; one value
916
+ * shared by every candidate of a run (see `gatherSessionEvents`). A candidate
917
+ * whose history the deadline left unread returns `skipped-history-unread`
918
+ * and is retried by the next run. `null` (CLI, SessionEnd) = unbounded.
727
919
  * @param {object} [args.deps] DI overrides (fs, appendJsonl, readLock, …)
728
920
  * @returns {Promise<object>}
729
921
  */
@@ -736,6 +928,7 @@ export async function backfillAbandonedSession({
736
928
  relaxDeadByAge = false,
737
929
  ownSessionIsEnding = false,
738
930
  assumeDeadBeforeMs = null,
931
+ archiveScanDeadlineMs = null,
739
932
  deps = {},
740
933
  } = {}) {
741
934
  const {
@@ -795,8 +988,14 @@ export async function backfillAbandonedSession({
795
988
  }
796
989
 
797
990
  // -- Read events (needed to synthesize, and to bridge UUID→semantic) ----
798
- const events = readJsonlSafe(readFileSync, eventsPath);
799
- const gathered = collectSessionEvents(events, { sessionId, semanticSessionId });
991
+ const gathered = gatherSessionEvents(
992
+ readFileSync, eventsPath, { sessionId, semanticSessionId }, archiveScanDeadlineMs,
993
+ );
994
+ // Deferred, never answered from the active file alone: for this candidate
995
+ // that answer is a record dated NOW, which the dedupe keeps forever.
996
+ if (gathered.historyUnread) {
997
+ return { action: 'skipped-history-unread', sessionId: recordId ?? sessionId };
998
+ }
800
999
 
801
1000
  // -- Resolve a deferred id from the lock bridge or a synthetic mint ------
802
1001
  if (recordId === null) {
@@ -1153,8 +1352,7 @@ export async function backfillCompletedFromStateMd({
1153
1352
 
1154
1353
  // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
1155
1354
  const eventsPath = path.join(repoRoot, ...EVENTS_REL);
1156
- const events = readJsonlSafe(readFileSync, eventsPath);
1157
- const gathered = collectSessionEvents(events, { sessionId: nativeId, semanticSessionId });
1355
+ const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
1158
1356
 
1159
1357
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
1160
1358
  const record = synthesizeRecord({
@@ -45,6 +45,8 @@
45
45
  * @property {string} [error] - error message, `reason === 'error'` only.
46
46
  * @property {number} [scanned] - entries read from the active store (ran only).
47
47
  * @property {number} [archived] - entries moved to the archive sidecar (ran only).
48
+ * @property {number} [malformed] - unparseable store lines kept verbatim, never
49
+ * archived (ran only; a measured `0` when none).
48
50
  * @property {string} [archivePath] - the archive sidecar written to (ran only).
49
51
  */
50
52
 
@@ -88,10 +90,10 @@ function findPhaseDecision(plan, phase) {
88
90
  * happens to resolve to (#941 — that mistake once landed a test record in the
89
91
  * operator's real fleet ledger).
90
92
  *
91
- * @param {{repoRoot: string, scanned: number, archived: number}} ctx
93
+ * @param {{repoRoot: string, scanned: number, archived: number, malformed: number}} ctx
92
94
  * @returns {Promise<void>}
93
95
  */
94
- async function emitSweepApplied({ repoRoot, scanned, archived }) {
96
+ async function emitSweepApplied({ repoRoot, scanned, archived, malformed }) {
95
97
  if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
96
98
  process.stderr.write(
97
99
  `tail-runner: skipped ${SWEEP_EVENT} — no repoRoot given; ` +
@@ -103,7 +105,7 @@ async function emitSweepApplied({ repoRoot, scanned, archived }) {
103
105
  const { emitEvent, sessionAttribution } = await import('../events.mjs');
104
106
  await emitEvent(
105
107
  SWEEP_EVENT,
106
- { scanned, archived, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
108
+ { scanned, archived, malformed, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
107
109
  { repoRoot },
108
110
  );
109
111
  } catch {
@@ -151,9 +153,12 @@ export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = t
151
153
 
152
154
  const scanned = res?.scanned ?? 0;
153
155
  const archived = res?.archived ?? 0;
154
- if (emit) await emitSweepApplied({ repoRoot, scanned, archived });
156
+ // The sweep result omits `malformed` when the read found none, so absent IS
157
+ // a measured 0 here — carried as a number like its two siblings (HR-105).
158
+ const malformed = res?.malformed ?? 0;
159
+ if (emit) await emitSweepApplied({ repoRoot, scanned, archived, malformed });
155
160
 
156
- return { ran: true, scanned, archived, archivePath: res?.archivePath ?? archivePath };
161
+ return { ran: true, scanned, archived, malformed, archivePath: res?.archivePath ?? archivePath };
157
162
  } catch (err) {
158
163
  // Fail CLOSED (see the module header): a maintenance sweep must never be
159
164
  // able to block a session close.
@@ -25,9 +25,12 @@
25
25
  * @param {unknown} obj — a value already parsed from JSON.
26
26
  * @returns {boolean} true when `obj` carries all six required lock fields with
27
27
  * the right primitive types. `semantic_session_id` and `last_heartbeat` are
28
- * deliberately NOT required — both are optional by schema (v1 locks predate
29
- * `last_heartbeat`, and `semantic_session_id` is absent on harnesses that
30
- * resolve no semantic id).
28
+ * deliberately NOT required. `semantic_session_id` is absent on harnesses
29
+ * that resolve no semantic id. A lock without `last_heartbeat` must stay
30
+ * shape-valid so it remains VISIBLE: since the #595 sunset (2026-10-02)
31
+ * `isLockLive()` calls it not live and `acquire()` classifies it
32
+ * `stale-heartbeat`, so the bootstrap reclaims it. Requiring the field here
33
+ * would hide it from every `readLock()` caller instead.
31
34
  */
32
35
  export function isLockShape(obj) {
33
36
  return (