session-orchestrator 5.3.0 → 5.4.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/navigator/SKILL.md +22 -0
- package/.agents/skills/navigator/agents/openai.yaml +5 -0
- 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/navigator/SKILL.md +22 -0
- package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/navigator.md +15 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +27 -12
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/navigator/SKILL.md +13 -0
- 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 +70 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +57 -4
- package/docs/components.md +12 -12
- package/docs/events-schema.md +11 -8
- package/docs/persona-panel.md +5 -4
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +26 -4
- package/docs/session-config-reference.md +55 -6
- package/docs/session-config-template.md +11 -2
- package/hooks/_lib/hook-import-set.json +27 -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 +53 -0
- package/hooks/on-stop.mjs +3 -1
- package/hooks/post-tool-batch-wave-signal.mjs +3 -1
- package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +559 -72
- package/hooks/subagent-telemetry.mjs +96 -19
- package/package.json +3 -3
- package/pi/prompts/navigator.md +12 -0
- 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 +492 -0
- package/scripts/archive-closed-prds.mjs +2 -1
- package/scripts/autopilot.mjs +49 -53
- package/scripts/backfill-abandoned-sessions.mjs +1 -1
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/cursor-install.mjs +1 -4
- package/scripts/emit-session.mjs +296 -2
- package/scripts/export-hw-learnings.mjs +22 -3
- package/scripts/fleet-checkin.mjs +147 -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 +10 -6
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- 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-schema.mjs +13 -0
- package/scripts/lib/config.mjs +71 -10
- package/scripts/lib/events.mjs +21 -2
- package/scripts/lib/fleet-protocol.mjs +270 -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/instruction-budget-guard.mjs +69 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
- package/scripts/lib/learnings/io.mjs +154 -26
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- 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 +104 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
- package/scripts/lib/orphan-reaper/scan.mjs +506 -0
- package/scripts/lib/orphan-reaper.mjs +43 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/process-group.mjs +62 -11
- 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 +3 -4
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +27 -2
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-token-rollup.mjs +245 -52
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/telemetry/pricing.mjs +118 -71
- package/scripts/lib/tests-src-ratio.mjs +140 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- 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 +87 -14
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
- package/scripts/lib/vault-status/board-writer.mjs +48 -11
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- 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/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +10 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
- package/scripts/run-quality-gate.mjs +323 -302
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +56 -5
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +72 -46
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +131 -0
- package/skills/_shared/state-ownership.md +5 -0
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +1 -1
- package/skills/bootstrap/_shared-template.md +1 -1
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/evolve/SKILL.md +2 -2
- package/skills/evolve/references/evolve-analyze-mode.md +10 -6
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/navigator/SKILL.md +193 -0
- package/skills/navigator/references/account-check.sh +87 -0
- package/skills/navigator/references/fleet-decisions.mjs +231 -0
- package/skills/navigator/references/slot-check.sh +114 -0
- 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 +22 -12
- package/skills/session-end/metrics-collection.md +5 -6
- package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
- 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 +22 -60
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +6 -0
- package/skills/session-start/references/operations-contract.md +4 -0
- 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 +2 -2
- 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/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
- package/skills/wave-executor/references/wave-loop-review.md +3 -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,7 +17,9 @@
|
|
|
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
|
|
@@ -142,6 +144,14 @@ function _validEntry(obj) {
|
|
|
142
144
|
if ('mode' in obj && obj.mode !== null && typeof obj.mode !== 'string') {
|
|
143
145
|
return false;
|
|
144
146
|
}
|
|
147
|
+
// #1462: `role` is optional and a DISPLAY field only — the registry shows who
|
|
148
|
+
// claims to be navigator, it never decides it. The authoritative claim is the
|
|
149
|
+
// navigator lease under `~/.config/navigator/leases/navigator.json`, and
|
|
150
|
+
// classifyMode ignores `role`. When present it MUST be null or a string (no
|
|
151
|
+
// number / object smuggling); absent is accepted like a v1 `mode`.
|
|
152
|
+
if ('role' in obj && obj.role !== null && typeof obj.role !== 'string') {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
145
155
|
return true;
|
|
146
156
|
}
|
|
147
157
|
|
|
@@ -236,11 +246,25 @@ export async function registerSelf(opts) {
|
|
|
236
246
|
return entry;
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
/** Roles `heartbeat(id, { role })` accepts besides `null` (#1462). */
|
|
250
|
+
export const REGISTRY_ROLES = Object.freeze(['navigator']);
|
|
251
|
+
|
|
239
252
|
/**
|
|
240
253
|
* Refresh last_heartbeat on the existing entry (and optionally status /
|
|
241
|
-
* current_wave). No-op if the entry is missing.
|
|
254
|
+
* current_wave / role). No-op if the entry is missing.
|
|
255
|
+
*
|
|
256
|
+
* `patch.role`: `undefined` keeps the stored role, `null` clears it, a member
|
|
257
|
+
* of `REGISTRY_ROLES` sets it. Anything else throws before the file is touched.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} sessionId
|
|
260
|
+
* @param {{ status?: string, currentWave?: number, role?: string|null }} [patch]
|
|
261
|
+
* @returns {Promise<object|null>} the updated entry, or null if none exists.
|
|
262
|
+
* @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES member.
|
|
242
263
|
*/
|
|
243
264
|
export async function heartbeat(sessionId, patch = {}) {
|
|
265
|
+
if (patch.role !== undefined && patch.role !== null && !REGISTRY_ROLES.includes(patch.role)) {
|
|
266
|
+
throw new TypeError('heartbeat: role must be "navigator" or null');
|
|
267
|
+
}
|
|
244
268
|
const file = entryPath(sessionId);
|
|
245
269
|
const existing = await _readJsonSafe(file);
|
|
246
270
|
if (!_validEntry(existing)) return null;
|
|
@@ -249,6 +273,7 @@ export async function heartbeat(sessionId, patch = {}) {
|
|
|
249
273
|
last_heartbeat: utcTimestamp(),
|
|
250
274
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
251
275
|
...(patch.currentWave !== undefined ? { current_wave: patch.currentWave } : {}),
|
|
276
|
+
...(patch.role !== undefined ? { role: patch.role } : {}),
|
|
252
277
|
};
|
|
253
278
|
await _writeJsonAtomic(file, updated);
|
|
254
279
|
return updated;
|
|
@@ -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.
|