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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- 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
|
|
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
|
|
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(
|
|
286
|
+
if (isGlobScopeEntry(spelled)) {
|
|
216
287
|
// GLOB entry: verbatim presence OR literal-prefix coverage.
|
|
217
|
-
const prefix = literalScopePrefix(
|
|
218
|
-
covered = allowedPaths.some((p) => 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(
|
|
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
|
|
333
|
-
* a naive 1:1 mirror (`scripts/lib/X.mjs →
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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
|
|
407
|
-
if (typeof
|
|
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
|
-
|
|
999
|
-
seen.
|
|
1000
|
-
|
|
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,43 @@ 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
|
|
1270
|
+
const declared = Array.isArray(raw.files)
|
|
1135
1271
|
? raw.files.filter((f) => typeof f === 'string' && f.length > 0)
|
|
1136
1272
|
: [];
|
|
1137
|
-
|
|
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
|
+
* @param {string} entry - a non-empty scope entry
|
|
1299
|
+
* @returns {string}
|
|
1300
|
+
*/
|
|
1301
|
+
function scopeEntrySpelling(entry) {
|
|
1302
|
+
const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
|
|
1303
|
+
// `./` alone would spell as '' — keep the entry rather than invent a meaning.
|
|
1304
|
+
return spelled.length > 0 ? spelled : entry;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1142
1307
|
/**
|
|
1143
1308
|
* Classify a SINGLE cross-agent entry pair, in the binding three-stage order.
|
|
1144
1309
|
* Returns the collision `kind`, or `null` when the two entries are disjoint.
|
|
@@ -1316,17 +1481,19 @@ export function findScopeCollisions(agentScopes, opts = {}) {
|
|
|
1316
1481
|
const b = agents[j];
|
|
1317
1482
|
if (a.id === b.id) continue; // duplicate-id record: reported separately
|
|
1318
1483
|
const buckets = new Map();
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1484
|
+
// Classify on the comparison spelling; report the entry AS DECLARED, so
|
|
1485
|
+
// the operator can find it in the plan (#1026.3).
|
|
1486
|
+
for (let xi = 0; xi < a.files.length; xi++) {
|
|
1487
|
+
for (let yi = 0; yi < b.files.length; yi++) {
|
|
1488
|
+
const kind = classifyEntryCollision(a.files[xi], b.files[yi], expand);
|
|
1322
1489
|
if (kind === null) continue;
|
|
1323
1490
|
let evidence = buckets.get(kind);
|
|
1324
1491
|
if (evidence === undefined) {
|
|
1325
1492
|
evidence = new Set();
|
|
1326
1493
|
buckets.set(kind, evidence);
|
|
1327
1494
|
}
|
|
1328
|
-
evidence.add(
|
|
1329
|
-
evidence.add(
|
|
1495
|
+
evidence.add(a.declared[xi]);
|
|
1496
|
+
evidence.add(b.declared[yi]);
|
|
1330
1497
|
}
|
|
1331
1498
|
}
|
|
1332
1499
|
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,117 @@ 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 {Array<object>|null} `null` when the deadline had passed before the
|
|
352
|
+
* scan or cut it short — a partial history is never handed on as a whole one.
|
|
353
|
+
*/
|
|
354
|
+
function recordsMentioning(eventsPath, id, deadlineMs) {
|
|
355
|
+
let budgetMs;
|
|
356
|
+
if (deadlineMs !== null) {
|
|
357
|
+
budgetMs = deadlineMs - Date.now();
|
|
358
|
+
if (!(budgetMs > 0)) return null;
|
|
359
|
+
}
|
|
360
|
+
const newestFirst = [];
|
|
361
|
+
const { truncated, unreadable } = scanEventsBackwards({
|
|
362
|
+
filePath: eventsPath,
|
|
363
|
+
filter: id,
|
|
364
|
+
budgetMs,
|
|
365
|
+
onRecord: (record) => {
|
|
366
|
+
newestFirst.push(record);
|
|
367
|
+
return false;
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
// #1505.5b: a source the walk could not read cut the history exactly like a
|
|
371
|
+
// deadline does — its records are simply absent — so it defers the candidate
|
|
372
|
+
// the same way. Ceiling (BV-004): a PERMANENTLY unreadable archive defers its
|
|
373
|
+
// candidates on every run (`skipped-history-unread`, visible, never a record
|
|
374
|
+
// dated NOW). `gaps` (pruned/unindexed archives) are deliberately NOT treated
|
|
375
|
+
// so: the walk reports every gap of the whole ledger, mostly far older than
|
|
376
|
+
// the candidate, and deferring on any of them would skip the candidate
|
|
377
|
+
// forever — telling a gap inside the session's window apart needs its
|
|
378
|
+
// first_ts/last_ts against the session's range, a follow-up.
|
|
379
|
+
const cut = truncated || (Array.isArray(unreadable) && unreadable.length > 0);
|
|
380
|
+
return cut ? null : newestFirst.reverse();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* {@link collectSessionEvents} over the ACTIVE events.jsonl, and — when that
|
|
385
|
+
* holds no `session.started` for the candidate — over the whole rotated ledger.
|
|
386
|
+
*
|
|
387
|
+
* #1498: `planSessions` reads every archive since #1414, but this core read only
|
|
388
|
+
* the active file, so a candidate whose events had rotated away gathered
|
|
389
|
+
* nothing: no `lastEventMs`, hence never dead-by-age and skipped behind a live
|
|
390
|
+
* foreign lock on every run — or, without a lock, written with `started_at` and
|
|
391
|
+
* `completed_at` set to NOW. The active read stays first and cheap; the scan
|
|
392
|
+
* runs only for such a candidate, and once its record is written the CLI's
|
|
393
|
+
* sessions.jsonl pre-filter skips it on every later run.
|
|
394
|
+
*
|
|
395
|
+
* Cost (#1498 review): ≈0.55-0.8 ms per MB of ledger per such candidate —
|
|
396
|
+
* measured 2026-10-02: 25 candidates over a synthetic 58 MB ledger of six
|
|
397
|
+
* sources took 1.17-1.20 s, active reads included, at load average ~20; the
|
|
398
|
+
* review measured +0.80-0.87 s for the scans alone at 60 MB and load ~7. That
|
|
399
|
+
* does not fit the 5 s SessionStart hook unbounded, so a caller may pass
|
|
400
|
+
* `deadlineMs`: ONE epoch-ms deadline shared by every candidate of a run. A
|
|
401
|
+
* scan the deadline skipped or cut returns `historyUnread: true` instead of a
|
|
402
|
+
* partial answer, and the caller defers the candidate — never the active-file
|
|
403
|
+
* answer, which for such a candidate is a record dated NOW (see above) that the
|
|
404
|
+
* dedupe would then keep forever.
|
|
405
|
+
*
|
|
406
|
+
* One filtered scan keeps the order exact: a native UUID appears on every event
|
|
407
|
+
* of its session. Without one, the semantic label is scanned for the UUID
|
|
408
|
+
* bridge, then that UUID's events. Named ceiling (BV-004): a label bridging to
|
|
409
|
+
* SEVERAL UUIDs would need several scans merged without a shared order, so it
|
|
410
|
+
* keeps the active-file answer — revisit if a backfill record shows
|
|
411
|
+
* `started_at` among its incomplete fields while the archive holds it.
|
|
412
|
+
*
|
|
413
|
+
* @param {Function} readFileSync
|
|
414
|
+
* @param {string} eventsPath
|
|
415
|
+
* @param {{ sessionId: string|null, semanticSessionId: string|null }} ids
|
|
416
|
+
* @param {number|null} [deadlineMs=null] — shared scan deadline (epoch ms); `null` = unbounded
|
|
417
|
+
* @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true }}
|
|
418
|
+
*/
|
|
419
|
+
function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
|
|
420
|
+
const gathered = collectSessionEvents(readJsonlSafe(readFileSync, eventsPath), ids);
|
|
421
|
+
if (gathered.startedAt !== null) return gathered;
|
|
422
|
+
try {
|
|
423
|
+
let uuid = isUuid(ids.sessionId) ? ids.sessionId : null;
|
|
424
|
+
if (uuid === null) {
|
|
425
|
+
const label = ids.semanticSessionId || ids.sessionId;
|
|
426
|
+
if (!label) return gathered;
|
|
427
|
+
const bridge = recordsMentioning(eventsPath, label, deadlineMs);
|
|
428
|
+
if (bridge === null) return { ...gathered, historyUnread: true };
|
|
429
|
+
const bridged = collectSessionEvents(bridge, ids).uuids;
|
|
430
|
+
if (bridged.size !== 1) return gathered;
|
|
431
|
+
[uuid] = bridged;
|
|
432
|
+
}
|
|
433
|
+
const history = recordsMentioning(eventsPath, uuid, deadlineMs);
|
|
434
|
+
if (history === null) return { ...gathered, historyUnread: true };
|
|
435
|
+
// Empty only when the scan found no source at all — e.g. a reader seam the
|
|
436
|
+
// filesystem does not back; the active answer stands.
|
|
437
|
+
return history.length > 0 ? collectSessionEvents(history, ids) : gathered;
|
|
438
|
+
} catch {
|
|
439
|
+
return gathered;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
329
443
|
/**
|
|
330
444
|
* Determine whether a candidate should be treated as dead-by-age DESPITE a
|
|
331
445
|
* live FOREIGN session.lock (#731 — the historical migration CLI blocks
|
|
@@ -724,6 +838,11 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
|
|
|
724
838
|
* `lastEventMs` strictly predates this value bypasses a foreign live lock
|
|
725
839
|
* regardless of `relaxDeadByAge`. Corresponds to the CLI's
|
|
726
840
|
* `--assume-dead-before <ISO>` flag.
|
|
841
|
+
* @param {number|null} [args.archiveScanDeadlineMs=null]
|
|
842
|
+
* #1498 — epoch ms by which a scan of the ROTATED ledger must end; one value
|
|
843
|
+
* shared by every candidate of a run (see `gatherSessionEvents`). A candidate
|
|
844
|
+
* whose history the deadline left unread returns `skipped-history-unread`
|
|
845
|
+
* and is retried by the next run. `null` (CLI, SessionEnd) = unbounded.
|
|
727
846
|
* @param {object} [args.deps] DI overrides (fs, appendJsonl, readLock, …)
|
|
728
847
|
* @returns {Promise<object>}
|
|
729
848
|
*/
|
|
@@ -736,6 +855,7 @@ export async function backfillAbandonedSession({
|
|
|
736
855
|
relaxDeadByAge = false,
|
|
737
856
|
ownSessionIsEnding = false,
|
|
738
857
|
assumeDeadBeforeMs = null,
|
|
858
|
+
archiveScanDeadlineMs = null,
|
|
739
859
|
deps = {},
|
|
740
860
|
} = {}) {
|
|
741
861
|
const {
|
|
@@ -795,8 +915,14 @@ export async function backfillAbandonedSession({
|
|
|
795
915
|
}
|
|
796
916
|
|
|
797
917
|
// -- Read events (needed to synthesize, and to bridge UUID→semantic) ----
|
|
798
|
-
const
|
|
799
|
-
|
|
918
|
+
const gathered = gatherSessionEvents(
|
|
919
|
+
readFileSync, eventsPath, { sessionId, semanticSessionId }, archiveScanDeadlineMs,
|
|
920
|
+
);
|
|
921
|
+
// Deferred, never answered from the active file alone: for this candidate
|
|
922
|
+
// that answer is a record dated NOW, which the dedupe keeps forever.
|
|
923
|
+
if (gathered.historyUnread) {
|
|
924
|
+
return { action: 'skipped-history-unread', sessionId: recordId ?? sessionId };
|
|
925
|
+
}
|
|
800
926
|
|
|
801
927
|
// -- Resolve a deferred id from the lock bridge or a synthetic mint ------
|
|
802
928
|
if (recordId === null) {
|
|
@@ -1153,8 +1279,7 @@ export async function backfillCompletedFromStateMd({
|
|
|
1153
1279
|
|
|
1154
1280
|
// -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
|
|
1155
1281
|
const eventsPath = path.join(repoRoot, ...EVENTS_REL);
|
|
1156
|
-
const
|
|
1157
|
-
const gathered = collectSessionEvents(events, { sessionId: nativeId, semanticSessionId });
|
|
1282
|
+
const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
|
|
1158
1283
|
|
|
1159
1284
|
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
|
|
1160
1285
|
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
|
-
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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 (
|