session-orchestrator 5.3.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- 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-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- 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 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- 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 +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- 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-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- 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 +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- 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 +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- 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 +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -36,11 +36,17 @@
|
|
|
36
36
|
* no key-order churn.
|
|
37
37
|
*
|
|
38
38
|
* ── PROVENANCE ───────────────────────────────────────────────────────────────
|
|
39
|
-
* Every repaired record carries `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
39
|
+
* Every repaired record carries `_repair_source` (`REPAIR_SOURCE`) +
|
|
40
|
+
* `_backfill_incomplete_fields`; `_backfill_source` is set to `REPAIR_SOURCE`
|
|
41
|
+
* only when the record had no origin of its own. An existing origin
|
|
42
|
+
* (`state-md-completed`, `events-jsonl`, `abandoned`) is KEPT, because
|
|
43
|
+
* `sessions-canonical.mjs`'s stub guard reads it: overwriting it promoted a
|
|
44
|
+
* repaired zero-work stub to a real record that shadowed the real one (#1443
|
|
45
|
+
* F2). `_backfill_*` is schema-accepted by `_validateOptionalFields`, and
|
|
46
|
+
* `validateSession` ignores undeclared keys (as it does the `_<field>_raw`
|
|
47
|
+
* sidecars), so no schema bump is needed. The incomplete-fields list is the
|
|
48
|
+
* EXACT set of fields this record had defaulted, which is what lets a
|
|
49
|
+
* downstream consumer tell a measured zero apart from a repaired-to-zero.
|
|
44
50
|
*
|
|
45
51
|
* It also never DISCARDS what it replaces: any field whose present value is
|
|
46
52
|
* overwritten by a default is first copied to a `_<field>_raw` sidecar
|
|
@@ -70,7 +76,10 @@ import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-inte
|
|
|
70
76
|
// Constants
|
|
71
77
|
// ---------------------------------------------------------------------------
|
|
72
78
|
|
|
73
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Value written to `_repair_source` on every record this module repairs, and to
|
|
81
|
+
* `_backfill_source` only on a record that had no origin there (#1443 F2).
|
|
82
|
+
*/
|
|
74
83
|
export const REPAIR_SOURCE = 'repair-invalid-sessions/1004';
|
|
75
84
|
|
|
76
85
|
/** Ledger path (relative to repoRoot) that `checkSessionsIntegrity` inspects. */
|
|
@@ -98,6 +107,28 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
|
|
|
98
107
|
'total_agents',
|
|
99
108
|
'total_files_changed',
|
|
100
109
|
]);
|
|
110
|
+
// `timestamp_precision` (#1442) deliberately adds NOTHING to the list above: a
|
|
111
|
+
// truncated timestamp is still a measurement (exact to the millisecond), not a
|
|
112
|
+
// default. Listing it would make consumers discard a real value —
|
|
113
|
+
// `sessions-staleness-banner.mjs` returns null for any timestamp named in
|
|
114
|
+
// `_backfill_incomplete_fields`, `session-close-backfill.mjs` keys on
|
|
115
|
+
// `includes('started_at')`. The sub-millisecond original survives in the
|
|
116
|
+
// `_<field>_raw` sidecar instead.
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Timestamp fields `validateSession` holds to `ISO_8601_UTC_MS_RE`
|
|
120
|
+
* (`session-schema/validator.mjs`, not exported): exactly three fractional
|
|
121
|
+
* digits or none.
|
|
122
|
+
*/
|
|
123
|
+
const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* ISO-8601 UTC with MORE than three fractional digits (`.994600Z`) — the one
|
|
127
|
+
* rejected shape that has an unambiguous canonical form. Group 1 is the value
|
|
128
|
+
* cut at the millisecond. Offsets, `.3Z` and non-ISO strings do not match and
|
|
129
|
+
* stay for the validator to reject: there is no defensible default for them.
|
|
130
|
+
*/
|
|
131
|
+
const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+Z$/;
|
|
101
132
|
|
|
102
133
|
// ---------------------------------------------------------------------------
|
|
103
134
|
// Helpers
|
|
@@ -112,6 +143,20 @@ function isCount(v) {
|
|
|
112
143
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
113
144
|
}
|
|
114
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Cut a sub-millisecond ISO-8601 UTC timestamp to milliseconds. TRUNCATES,
|
|
148
|
+
* never rounds: `.9999Z` must not roll into the next second, and truncation is
|
|
149
|
+
* monotone, so `completed_at >= started_at` survives on every pair it held on.
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} value
|
|
152
|
+
* @returns {string|null} the truncated value, or null when there is nothing to cut
|
|
153
|
+
*/
|
|
154
|
+
function truncateToMs(value) {
|
|
155
|
+
if (typeof value !== 'string') return null;
|
|
156
|
+
const m = ISO_UTC_SUB_MS_RE.exec(value);
|
|
157
|
+
return m ? `${m[1]}Z` : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
115
160
|
function orderIncompleteFields(fields) {
|
|
116
161
|
const rank = (f) => {
|
|
117
162
|
const i = INCOMPLETE_FIELD_ORDER.indexOf(f);
|
|
@@ -321,6 +366,19 @@ export function repairRecord(record) {
|
|
|
321
366
|
incomplete.add('total_files_changed');
|
|
322
367
|
}
|
|
323
368
|
|
|
369
|
+
// -- timestamp precision (#1442) -------------------------------------------
|
|
370
|
+
// Runs BEFORE the completed_at default below, because that default copies
|
|
371
|
+
// `started_at` — a microsecond `started_at` would otherwise be copied into
|
|
372
|
+
// `completed_at` and spread the defect instead of fixing it.
|
|
373
|
+
let precisionCut = false;
|
|
374
|
+
for (const key of MS_PRECISION_FIELDS) {
|
|
375
|
+
const truncated = truncateToMs(out[key]);
|
|
376
|
+
if (truncated === null) continue;
|
|
377
|
+
preserveRaw(out, key, record[key], rescued);
|
|
378
|
+
out[key] = truncated;
|
|
379
|
+
precisionCut = true;
|
|
380
|
+
}
|
|
381
|
+
|
|
324
382
|
// -- completed_at ---------------------------------------------------------
|
|
325
383
|
// Legacy `ended_at` is the same fact under its old name (emit-session.mjs
|
|
326
384
|
// aliasLegacyEndedAt precedent) — prefer it when parseable and monotonic,
|
|
@@ -332,13 +390,19 @@ export function repairRecord(record) {
|
|
|
332
390
|
preserveRaw(out, 'completed_at', record.completed_at, rescued);
|
|
333
391
|
const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
|
|
334
392
|
const startedMs = Date.parse(out.started_at);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
393
|
+
if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
|
|
394
|
+
// The COPY is cut to milliseconds; `ended_at` itself is not a validated
|
|
395
|
+
// field and keeps its original bytes, so nothing needs a sidecar here.
|
|
396
|
+
const endedTruncated = truncateToMs(out.ended_at);
|
|
397
|
+
if (endedTruncated !== null) precisionCut = true;
|
|
398
|
+
out.completed_at = endedTruncated ?? out.ended_at;
|
|
399
|
+
} else {
|
|
400
|
+
out.completed_at = out.started_at;
|
|
401
|
+
}
|
|
339
402
|
defects.push('completed_at_missing');
|
|
340
403
|
incomplete.add('completed_at');
|
|
341
404
|
}
|
|
405
|
+
if (precisionCut) defects.push('timestamp_precision');
|
|
342
406
|
|
|
343
407
|
if (defects.length === 0) {
|
|
344
408
|
return { record, defects: [], incompleteFields: [], changed: false };
|
|
@@ -349,7 +413,14 @@ export function repairRecord(record) {
|
|
|
349
413
|
? record._backfill_incomplete_fields.filter((f) => typeof f === 'string')
|
|
350
414
|
: [];
|
|
351
415
|
const merged = orderIncompleteFields(incomplete);
|
|
352
|
-
|
|
416
|
+
// The repair is recorded in its own field; `_backfill_source` keeps the
|
|
417
|
+
// record's ORIGIN. Overwriting a synthesised origin ('state-md-completed',
|
|
418
|
+
// 'events-jsonl', 'abandoned') made a repaired zero-work stub read as a real
|
|
419
|
+
// record, and it shadowed the real one for its id (#1443 F2).
|
|
420
|
+
out._repair_source = REPAIR_SOURCE;
|
|
421
|
+
if (typeof record._backfill_source !== 'string' || record._backfill_source.length === 0) {
|
|
422
|
+
out._backfill_source = REPAIR_SOURCE;
|
|
423
|
+
}
|
|
353
424
|
out._backfill_incomplete_fields = [...new Set([...prior, ...merged])];
|
|
354
425
|
|
|
355
426
|
return { record: out, defects, incompleteFields: merged, changed: true };
|
|
@@ -487,6 +558,11 @@ export function repairText(raw, deps = {}) {
|
|
|
487
558
|
repaired,
|
|
488
559
|
// Projected: every line that was invalid and did NOT get repaired.
|
|
489
560
|
invalid_after: invalidBefore - repaired,
|
|
561
|
+
// Lines with no defensible repair (`repairLine` status `error`). They are
|
|
562
|
+
// emitted byte-identically and are EXPECTED to stay invalid after a
|
|
563
|
+
// write — `verifyWritten` tolerates exactly these line numbers (#1442).
|
|
564
|
+
// Same population as `errors`, which carries the per-line detail.
|
|
565
|
+
unrepairable: errors.length,
|
|
490
566
|
duplicate_ids_observed: duplicates,
|
|
491
567
|
defects_by_class: defectsByClass,
|
|
492
568
|
errors,
|
|
@@ -507,9 +583,32 @@ export function repairText(raw, deps = {}) {
|
|
|
507
583
|
* any other target it is reported as skipped rather than quietly measuring the
|
|
508
584
|
* wrong file.
|
|
509
585
|
*
|
|
510
|
-
*
|
|
586
|
+
* ── WHAT "OK" MEANS (#1442) ──────────────────────────────────────────────────
|
|
587
|
+
* Not "zero invalid lines". A ledger that carries lines this module has no
|
|
588
|
+
* defensible repair for could never pass that bar, so one unrepairable record
|
|
589
|
+
* rolled back every repair beside it (all-or-nothing). `ok` now means NO
|
|
590
|
+
* WORSENING and ALL REPAIRABLE VALID:
|
|
591
|
+
* - every invalid line in the written file is one of `expected.unrepairableLines`
|
|
592
|
+
* (so every repaired AND every already-valid line validates), and
|
|
593
|
+
* - `invalid_after <= expected.invalidBefore`, and
|
|
594
|
+
* - the integrity probe flags no line it did not already flag in
|
|
595
|
+
* `expected.integrityBefore` (compared by line number — the repair never
|
|
596
|
+
* adds or removes lines, so positions are stable across the write).
|
|
597
|
+
* With no `expected`, the defaults reproduce the old strict bar: no invalid
|
|
598
|
+
* line tolerated, and a clean pre-write integrity baseline.
|
|
599
|
+
*
|
|
600
|
+
* NAMED CEILING (BV-004): integrity is compared per line, not per reason — a
|
|
601
|
+
* line flagged before AND after for a DIFFERENT reason is not "worse". The
|
|
602
|
+
* per-line `validateSession` pass above still catches it for the schema half.
|
|
603
|
+
*
|
|
604
|
+
* @param {object} args
|
|
605
|
+
* @param {object} [args.expected]
|
|
606
|
+
* @param {number[]} [args.expected.unrepairableLines] 1-based lines allowed to stay invalid
|
|
607
|
+
* @param {number} [args.expected.invalidBefore] invalid count before the write
|
|
608
|
+
* @param {object|null} [args.expected.integrityBefore] pre-write `checkIntegrity` banner (null = clean)
|
|
609
|
+
* @returns {{ok: boolean, invalid_after: number, invalid_lines: object[], unexpected_invalid: object[], integrity: string|object, integrity_worsened: boolean}}
|
|
511
610
|
*/
|
|
512
|
-
export function verifyWritten({ file, repoRoot, deps = {} }) {
|
|
611
|
+
export function verifyWritten({ file, repoRoot, deps = {}, expected = {} }) {
|
|
513
612
|
const {
|
|
514
613
|
readFileSync = fs.readFileSync,
|
|
515
614
|
validateSession = defaultValidateSession,
|
|
@@ -539,17 +638,50 @@ export function verifyWritten({ file, repoRoot, deps = {} }) {
|
|
|
539
638
|
}
|
|
540
639
|
}
|
|
541
640
|
|
|
641
|
+
const { unrepairableLines = [], invalidBefore = 0, integrityBefore = null } = expected;
|
|
642
|
+
const tolerated = new Set(unrepairableLines);
|
|
643
|
+
const unexpectedInvalid = invalidLines.filter((l) => !tolerated.has(l.line));
|
|
644
|
+
|
|
542
645
|
let integrity = 'skipped-not-canonical-path';
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
646
|
+
let worsened = false;
|
|
647
|
+
if (isCanonicalLedger(file, repoRoot)) {
|
|
648
|
+
const banner = checkIntegrity({ repoRoot });
|
|
649
|
+
integrity = banner === null ? 'clean' : banner;
|
|
650
|
+
worsened = integrityWorsened(integrityBefore, banner);
|
|
549
651
|
}
|
|
550
652
|
|
|
551
|
-
const ok =
|
|
552
|
-
return {
|
|
653
|
+
const ok = unexpectedInvalid.length === 0 && invalidLines.length <= invalidBefore && !worsened;
|
|
654
|
+
return {
|
|
655
|
+
ok,
|
|
656
|
+
invalid_after: invalidLines.length,
|
|
657
|
+
invalid_lines: invalidLines,
|
|
658
|
+
unexpected_invalid: unexpectedInvalid,
|
|
659
|
+
integrity,
|
|
660
|
+
integrity_worsened: worsened,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** True when `file` IS `<repoRoot>/.orchestrator/metrics/sessions.jsonl` — the only file `checkIntegrity` reads. */
|
|
665
|
+
function isCanonicalLedger(file, repoRoot) {
|
|
666
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return false;
|
|
667
|
+
return path.resolve(file) === path.resolve(path.join(repoRoot, CANONICAL_LEDGER_REL));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Did the integrity probe flag a line after the write that it did not flag
|
|
672
|
+
* before? Fail-safe on an unreadable banner shape: a non-null banner without
|
|
673
|
+
* its `schemaInvalid` / `mirrorSkipped` arrays counts as worse.
|
|
674
|
+
*/
|
|
675
|
+
function integrityWorsened(before, after) {
|
|
676
|
+
if (after === null || after === undefined) return false;
|
|
677
|
+
for (const key of ['schemaInvalid', 'mirrorSkipped']) {
|
|
678
|
+
const now = after && Array.isArray(after[key]) ? after[key] : null;
|
|
679
|
+
if (now === null) return true;
|
|
680
|
+
const prior = before && Array.isArray(before[key]) ? before[key] : [];
|
|
681
|
+
const priorLines = new Set(prior.map((e) => e?.line));
|
|
682
|
+
if (now.some((e) => !priorLines.has(e?.line))) return true;
|
|
683
|
+
}
|
|
684
|
+
return false;
|
|
553
685
|
}
|
|
554
686
|
|
|
555
687
|
/**
|
|
@@ -560,7 +692,10 @@ export function verifyWritten({ file, repoRoot, deps = {} }) {
|
|
|
560
692
|
* `<file>.tmp-<pid>` in the same directory and renames it over the target
|
|
561
693
|
* (atomic within a filesystem), then re-verifies the written file. A failed
|
|
562
694
|
* verification restores the backup byte-identically and reports `ok: false` —
|
|
563
|
-
* the caller maps that to exit 3.
|
|
695
|
+
* the caller maps that to exit 3. Lines with no defensible repair are kept
|
|
696
|
+
* byte-identical and counted in `summary.unrepairable`; they are NOT a
|
|
697
|
+
* verification failure (see `verifyWritten` § WHAT "OK" MEANS) — the caller
|
|
698
|
+
* maps an ok run with an unrepairable remainder to exit 4.
|
|
564
699
|
*
|
|
565
700
|
* Never throws for a defective RECORD (those land in `summary.errors`); a
|
|
566
701
|
* genuine I/O failure DOES throw and is the caller's exit-2 case.
|
|
@@ -584,6 +719,8 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
584
719
|
existsSync = fs.existsSync,
|
|
585
720
|
} = deps;
|
|
586
721
|
|
|
722
|
+
const { checkIntegrity = defaultCheckIntegrity } = deps;
|
|
723
|
+
|
|
587
724
|
const raw = readFileSync(file, 'utf8');
|
|
588
725
|
const { text, summary: base } = repairText(raw, deps);
|
|
589
726
|
|
|
@@ -594,10 +731,17 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
594
731
|
backup_path: null,
|
|
595
732
|
post_verify: null,
|
|
596
733
|
ok: true,
|
|
734
|
+
restored: false,
|
|
597
735
|
};
|
|
598
736
|
|
|
599
737
|
if (!apply) return summary;
|
|
600
738
|
|
|
739
|
+
// -- integrity baseline (#1442) --------------------------------------------
|
|
740
|
+
// Measured on the ORIGINAL bytes, before anything is written, so the
|
|
741
|
+
// post-write probe can be judged "not worse" instead of "perfectly clean" —
|
|
742
|
+
// unrepairable lines keep the banner non-null on both sides by construction.
|
|
743
|
+
const integrityBefore = isCanonicalLedger(file, repoRoot) ? checkIntegrity({ repoRoot }) : null;
|
|
744
|
+
|
|
601
745
|
// -- backup FIRST (unconditional unless explicitly opted out) --------------
|
|
602
746
|
let backupPath = null;
|
|
603
747
|
if (backup) {
|
|
@@ -621,14 +765,27 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
621
765
|
}
|
|
622
766
|
|
|
623
767
|
// -- post-verification ----------------------------------------------------
|
|
624
|
-
const verdict = verifyWritten({
|
|
768
|
+
const verdict = verifyWritten({
|
|
769
|
+
file,
|
|
770
|
+
repoRoot,
|
|
771
|
+
deps,
|
|
772
|
+
expected: {
|
|
773
|
+
unrepairableLines: base.errors.map((e) => e.line),
|
|
774
|
+
invalidBefore: base.invalid_before,
|
|
775
|
+
integrityBefore,
|
|
776
|
+
},
|
|
777
|
+
});
|
|
625
778
|
summary.post_verify = verdict;
|
|
626
779
|
summary.invalid_after = verdict.invalid_after;
|
|
627
780
|
|
|
628
781
|
if (!verdict.ok) {
|
|
629
|
-
// Restore byte-identically
|
|
630
|
-
//
|
|
631
|
-
//
|
|
782
|
+
// Restore byte-identically — ONLY for a repaired (or previously valid) line
|
|
783
|
+
// that fails verification, or an integrity probe that got worse. An
|
|
784
|
+
// unrepairable remainder alone is not a failure (#1442): those lines were
|
|
785
|
+
// emitted byte-identically and keep the ledger exactly as broken as it was.
|
|
786
|
+
// The backup is preferred (it is the on-disk artefact an operator can
|
|
787
|
+
// inspect); `raw` is the in-memory fallback for `--no-backup`, and both are
|
|
788
|
+
// the same bytes.
|
|
632
789
|
if (backupPath) {
|
|
633
790
|
copyFileSync(backupPath, file);
|
|
634
791
|
} else {
|
|
@@ -17,16 +17,20 @@
|
|
|
17
17
|
* {
|
|
18
18
|
* session_id, pid, platform, repo_path_hash, repo_name, branch,
|
|
19
19
|
* started_at, last_heartbeat, status ('active'|'wave'|'idle'),
|
|
20
|
-
* current_wave, host_class, mode (Epic #583 W2-I3)
|
|
20
|
+
* current_wave, host_class, mode (Epic #583 W2-I3),
|
|
21
|
+
* role ('navigator'|null, optional, #1462 — display only, never ownership;
|
|
22
|
+
* set by heartbeat(patch.role), NOT by registerSelf)
|
|
21
23
|
* }
|
|
22
24
|
*
|
|
23
25
|
* Schema v2 (Epic #583, W2-I3): `mode` field added so the exclusivity-matrix
|
|
24
26
|
* can classify cross-repo registry entries correctly. Without it, every
|
|
25
27
|
* registry-sourced peer was bucketed as `mode='session'` → classifyMode threw
|
|
26
28
|
* → fell back to `parallel-ok`, silently bypassing the exclusivity matrix
|
|
27
|
-
* for cross-repo entries (D5 from Epic #583 audit).
|
|
28
|
-
*
|
|
29
|
-
*
|
|
29
|
+
* for cross-repo entries (D5 from Epic #583 audit). `registerSelf()` writes
|
|
30
|
+
* `mode: null` when the caller passes none; on read an entry WITHOUT the key
|
|
31
|
+
* is still accepted — a deliberate fail-open for peer visibility (see
|
|
32
|
+
* `_validEntry`), not v1 back-compat, which ended with the #595 sunset
|
|
33
|
+
* (2026-10-02). See `skills/_shared/state-ownership.md` § Schema v1 Sunset.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
import path from 'node:path';
|
|
@@ -38,6 +42,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
38
42
|
|
|
39
43
|
import { utcTimestamp, appendJsonl } from './common.mjs';
|
|
40
44
|
import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
|
|
45
|
+
import { FUTURE_HEARTBEAT_TOLERANCE_MS } from './session-lock.mjs';
|
|
41
46
|
|
|
42
47
|
// ---------------------------------------------------------------------------
|
|
43
48
|
// Paths
|
|
@@ -129,26 +134,53 @@ function _validEntry(obj) {
|
|
|
129
134
|
|| typeof obj.started_at !== 'string') {
|
|
130
135
|
return false;
|
|
131
136
|
}
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
137
|
+
// `mode`: when present it MUST be null or a string (no number / object /
|
|
138
|
+
// array smuggling). When ABSENT the entry is still accepted — a deliberate
|
|
139
|
+
// fail-open for peer visibility, not schema-v1 compat. No writer omits the
|
|
140
|
+
// key (`registerSelf()` writes `mode: null` at worst), so a mode-less entry
|
|
141
|
+
// is foreign or damaged; but it still carries a heartbeat, and rejecting it
|
|
142
|
+
// would drop a possibly LIVE peer from `readRegistry()` / `detectPeers()`,
|
|
143
|
+
// hiding it from parallel-session detection. Accepting it costs nothing:
|
|
144
|
+
// `acquire()` already buckets an unclassifiable peer mode as `parallel-ok`.
|
|
145
|
+
// Decided at the #595 sunset (2026-10-02), see
|
|
141
146
|
// `skills/_shared/state-ownership.md` § Schema v1 Sunset.
|
|
142
147
|
if ('mode' in obj && obj.mode !== null && typeof obj.mode !== 'string') {
|
|
143
148
|
return false;
|
|
144
149
|
}
|
|
150
|
+
// #1462: `role` is optional and a DISPLAY field only — the registry shows who
|
|
151
|
+
// claims to be navigator, it never decides it. The authoritative claim is the
|
|
152
|
+
// navigator lease under `~/.config/navigator/leases/navigator.json`, and
|
|
153
|
+
// classifyMode ignores `role`. When present it MUST be null or a string (no
|
|
154
|
+
// number / object smuggling); absent is accepted, like an absent `mode`.
|
|
155
|
+
if ('role' in obj && obj.role !== null && typeof obj.role !== 'string') {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
145
158
|
return true;
|
|
146
159
|
}
|
|
147
160
|
|
|
161
|
+
/**
|
|
162
|
+
* True when `isoTimestamp` lies further in the future than the lock module's
|
|
163
|
+
* {@link FUTURE_HEARTBEAT_TOLERANCE_MS} (#1505.1). Such a heartbeat was not
|
|
164
|
+
* written by a live writer on this clock — a hand edit or a clock stepped
|
|
165
|
+
* backwards — so it measures nothing.
|
|
166
|
+
*/
|
|
167
|
+
function _isFutureStamp(isoTimestamp, now) {
|
|
168
|
+
const t = Date.parse(isoTimestamp);
|
|
169
|
+
return !Number.isNaN(t) && t > now + FUTURE_HEARTBEAT_TOLERANCE_MS;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Minutes since `isoTimestamp`. `Infinity` — never fresh — when it is
|
|
174
|
+
* unparseable or stamped beyond the future tolerance (#1505.1: `now - t` went
|
|
175
|
+
* negative, stayed under every freshness window, and kept the entry "fresh"
|
|
176
|
+
* forever, exactly the flaw `isLockLive()` closed in #1494). Within the
|
|
177
|
+
* tolerance the age clamps to 0 instead of going negative.
|
|
178
|
+
*/
|
|
148
179
|
function _ageMinutes(isoTimestamp, now = Date.now()) {
|
|
149
180
|
const t = Date.parse(isoTimestamp);
|
|
150
181
|
if (Number.isNaN(t)) return Infinity;
|
|
151
|
-
|
|
182
|
+
if (_isFutureStamp(isoTimestamp, now)) return Infinity;
|
|
183
|
+
return Math.max(0, now - t) / 60_000;
|
|
152
184
|
}
|
|
153
185
|
|
|
154
186
|
// ---------------------------------------------------------------------------
|
|
@@ -196,7 +228,7 @@ export function logSweepEvent({ event, session_id, error }) {
|
|
|
196
228
|
* @param {string} [opts.mode] — session mode (e.g., 'deep', 'feature', 'housekeeping').
|
|
197
229
|
* Schema v2 (Epic #583, W2-I3): when present, propagated into the entry so
|
|
198
230
|
* discovery + exclusivity-matrix classification work for cross-repo peers.
|
|
199
|
-
* Default: `null` (
|
|
231
|
+
* Default: `null` (mode unknown — classified `parallel-ok` by `acquire()`).
|
|
200
232
|
* @returns {Promise<object>} the written entry
|
|
201
233
|
*/
|
|
202
234
|
export async function registerSelf(opts) {
|
|
@@ -236,11 +268,25 @@ export async function registerSelf(opts) {
|
|
|
236
268
|
return entry;
|
|
237
269
|
}
|
|
238
270
|
|
|
271
|
+
/** Roles `heartbeat(id, { role })` accepts besides `null` (#1462). */
|
|
272
|
+
export const REGISTRY_ROLES = Object.freeze(['navigator']);
|
|
273
|
+
|
|
239
274
|
/**
|
|
240
275
|
* Refresh last_heartbeat on the existing entry (and optionally status /
|
|
241
|
-
* current_wave). No-op if the entry is missing.
|
|
276
|
+
* current_wave / role). No-op if the entry is missing.
|
|
277
|
+
*
|
|
278
|
+
* `patch.role`: `undefined` keeps the stored role, `null` clears it, a member
|
|
279
|
+
* of `REGISTRY_ROLES` sets it. Anything else throws before the file is touched.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} sessionId
|
|
282
|
+
* @param {{ status?: string, currentWave?: number, role?: string|null }} [patch]
|
|
283
|
+
* @returns {Promise<object|null>} the updated entry, or null if none exists.
|
|
284
|
+
* @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES member.
|
|
242
285
|
*/
|
|
243
286
|
export async function heartbeat(sessionId, patch = {}) {
|
|
287
|
+
if (patch.role !== undefined && patch.role !== null && !REGISTRY_ROLES.includes(patch.role)) {
|
|
288
|
+
throw new TypeError('heartbeat: role must be "navigator" or null');
|
|
289
|
+
}
|
|
244
290
|
const file = entryPath(sessionId);
|
|
245
291
|
const existing = await _readJsonSafe(file);
|
|
246
292
|
if (!_validEntry(existing)) return null;
|
|
@@ -249,6 +295,7 @@ export async function heartbeat(sessionId, patch = {}) {
|
|
|
249
295
|
last_heartbeat: utcTimestamp(),
|
|
250
296
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
251
297
|
...(patch.currentWave !== undefined ? { current_wave: patch.currentWave } : {}),
|
|
298
|
+
...(patch.role !== undefined ? { role: patch.role } : {}),
|
|
252
299
|
};
|
|
253
300
|
await _writeJsonAtomic(file, updated);
|
|
254
301
|
return updated;
|
|
@@ -328,12 +375,15 @@ export async function sweepZombies({ thresholdMin = 60, now = Date.now() } = {})
|
|
|
328
375
|
const parsed = await _readJsonSafe(full);
|
|
329
376
|
const valid = _validEntry(parsed);
|
|
330
377
|
let age;
|
|
331
|
-
if (valid) {
|
|
378
|
+
if (valid && !_isFutureStamp(parsed.last_heartbeat, now)) {
|
|
332
379
|
age = _ageMinutes(parsed.last_heartbeat, now);
|
|
333
380
|
} else {
|
|
334
381
|
// Malformed files can be fresh semantic-session claim files. Use mtime so
|
|
335
382
|
// a concurrent SessionStart claim is not swept before registerSelf()
|
|
336
383
|
// overwrites it with the real heartbeat entry.
|
|
384
|
+
// #1505.1: a future-stamped heartbeat measures no age either — `now - t`
|
|
385
|
+
// was negative, never over the threshold, so it was never swept. The
|
|
386
|
+
// mtime is when it was last written: a live heartbeat() keeps it young.
|
|
337
387
|
try {
|
|
338
388
|
const info = await fs.stat(full);
|
|
339
389
|
age = (now - info.mtimeMs) / 60_000;
|
|
@@ -219,11 +219,37 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
219
219
|
// #1339 P8 — HEAD sha at session start. Gives `/evolve analyze` an
|
|
220
220
|
// attributable commit range instead of a time window that also catches
|
|
221
221
|
// parallel sessions' commits. Absent = not measured. NOT guaranteed to be a
|
|
222
|
-
// full sha: scripts/emit-session.mjs
|
|
223
|
-
//
|
|
224
|
-
// record
|
|
225
|
-
// derivation was hand-composed — 19 with a 40-char and 9 with a 7-char
|
|
222
|
+
// full sha: scripts/emit-session.mjs now writes only a full 40/64-hex sha or
|
|
223
|
+
// an explicit `null` (a derived ref is adopted only in full form, and an
|
|
224
|
+
// explicit short ref on the record is dropped with a WARN), but every record
|
|
225
|
+
// before that derivation was hand-composed — 19 with a 40-char and 9 with a 7-char
|
|
226
226
|
// value (measured 2026-09-19 @ 8f6ac022, jq over sessions.jsonl).
|
|
227
227
|
// Consumers must accept a short sha or `null`, never assume a full one.
|
|
228
228
|
'session_start_ref',
|
|
229
|
+
// #1457 point 7 — four fields that were written and (two of them) shape-
|
|
230
|
+
// checked while appearing in NEITHER list, the same "status only inferable
|
|
231
|
+
// from an `if`" class as `effectiveness` above (#964). Each one has a live
|
|
232
|
+
// writer:
|
|
233
|
+
// `total_tokens`, `matched_records` — merged from the #1436 subagents.jsonl
|
|
234
|
+
// rollup by scripts/emit-session.mjs (ROLLUP_KEYS) and produced by
|
|
235
|
+
// scripts/lib/session-token-rollup.mjs; validator.mjs checks both as
|
|
236
|
+
// non-negative integers.
|
|
237
|
+
// `raw_session_id` — the native harness uuid beside the semantic
|
|
238
|
+
// `session_id`, written by scripts/emit-session.mjs and by the SessionEnd
|
|
239
|
+
// backfill in scripts/lib/session-close-backfill.mjs.
|
|
240
|
+
// `_repair_source` — provenance marker every record repaired by
|
|
241
|
+
// scripts/lib/session-record-repair.mjs carries (#1443 F2), distinct from
|
|
242
|
+
// `_backfill_source`, which keeps the record's origin.
|
|
243
|
+
'total_tokens',
|
|
244
|
+
'matched_records',
|
|
245
|
+
'raw_session_id',
|
|
246
|
+
'_repair_source',
|
|
247
|
+
// #1475 — how many of the session's cost-relevant subagent records were
|
|
248
|
+
// priced, out of how many. Merged from the rollup by scripts/emit-session.mjs;
|
|
249
|
+
// non-negative integers, priced ≤ total. `total_cost_usd` is omitted when
|
|
250
|
+
// priced < total (some agent's cost is unknown) AND when total is 0 (nothing
|
|
251
|
+
// to price — 1223 of 2093 ledger×session pairs, 31 fleet ledgers,
|
|
252
|
+
// 2026-10-01), so the counters say which of the two it is.
|
|
253
|
+
'cost_records_priced',
|
|
254
|
+
'cost_records_total',
|
|
229
255
|
]);
|
|
@@ -386,7 +386,9 @@ function _validateOptionalFields(entry) {
|
|
|
386
386
|
}
|
|
387
387
|
// #1244 — cache buckets, cost estimate and the token-contract marker.
|
|
388
388
|
// Same non-negative-finite-or-null contract as the two totals above;
|
|
389
|
-
// total_cost_usd is fractional
|
|
389
|
+
// total_cost_usd is fractional; null means "cost unknown" — at least one
|
|
390
|
+
// subagent record could not be priced (#1475: unknown model, no model, or no
|
|
391
|
+
// tokens; see cost_records_* below) — never 0.
|
|
390
392
|
for (const field of [
|
|
391
393
|
'total_token_input_uncached',
|
|
392
394
|
'total_token_cache_read',
|
|
@@ -417,6 +419,31 @@ function _validateOptionalFields(entry) {
|
|
|
417
419
|
}
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
// #1436 — the session-level token total and the rollup's record count, merged
|
|
423
|
+
// by emit-session from subagents.jsonl. Same contract as subagents_with_tokens:
|
|
424
|
+
// a non-negative integer, null/absent = not measured. Before this, neither was
|
|
425
|
+
// checked and `total_tokens: "abc"` validated — the field the autopilot token
|
|
426
|
+
// budget reads. #1475 adds the rollup's cost-coverage counters under the same
|
|
427
|
+
// contract, plus the one relation between them: priced never exceeds total.
|
|
428
|
+
for (const field of ['total_tokens', 'matched_records', 'cost_records_priced', 'cost_records_total']) {
|
|
429
|
+
const value = entry[field];
|
|
430
|
+
if (value !== undefined && value !== null) {
|
|
431
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
432
|
+
throw new ValidationError(`${field} must be a non-negative integer, got: ${value}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
Number.isInteger(entry.cost_records_priced) &&
|
|
438
|
+
Number.isInteger(entry.cost_records_total) &&
|
|
439
|
+
entry.cost_records_priced > entry.cost_records_total
|
|
440
|
+
) {
|
|
441
|
+
throw new ValidationError(
|
|
442
|
+
`cost_records_priced must not exceed cost_records_total, got: ` +
|
|
443
|
+
`${entry.cost_records_priced} > ${entry.cost_records_total}`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
420
447
|
// Epic #724 C1 — SessionEnd close-through backfill provenance fields.
|
|
421
448
|
// All additive-optional; null/absent passes without further checks so that
|
|
422
449
|
// pre-#724 records (and every normally-closed record) validate cleanly.
|