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.
- 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 +115 -3
- package/README.md +30 -15
- 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 +19 -15
- 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 +77 -171
- package/hooks/post-bash-write-verify.mjs +219 -24
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +41 -156
- package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +603 -241
- package/hooks/wave-scope-commit-guard.mjs +235 -73
- package/package.json +9 -8
- 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 +201 -27
- package/scripts/lib/session-close-backfill.mjs +202 -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 +60 -6
- 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/parallel-aware-auq.md +1 -1
- 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 +14 -6
- 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,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
|
|
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
|
+
* 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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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(
|
|
1329
|
-
evidence.add(
|
|
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
|
|
799
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|