session-orchestrator 4.1.0 → 5.0.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/session-plan/SKILL.md +1 -1
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +8 -4
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-end/tail-runner.mjs — the APPLY half of the Phase 3.6.x tail.
|
|
3
|
+
*
|
|
4
|
+
* `phase-skip.mjs` decides WHICH tail phases should run and is side-effect-free
|
|
5
|
+
* by contract. Until now nothing was the other half for 3.6.4: the
|
|
6
|
+
* Expired-Learnings Sweep was coordinator PROSE, so the dry-run decision was
|
|
7
|
+
* computed on every close and the apply path was never mechanically reached.
|
|
8
|
+
* Census (2026-09-09, `rg -n sweepExpiredLearnings scripts/ hooks/ skills/
|
|
9
|
+
* tests/`): the only call sites were the definition itself, the `dryRun: true`
|
|
10
|
+
* probe in `phase-skip.mjs`, the standalone CLI, and tests — no session-end
|
|
11
|
+
* caller applied anything. Measured consequence across three consumer repos:
|
|
12
|
+
* 0 sweeps ever applied, 628 learnings resident in the active stores.
|
|
13
|
+
*
|
|
14
|
+
* This module closes that gap and nothing else. It is deliberately NOT a
|
|
15
|
+
* general tail executor:
|
|
16
|
+
*
|
|
17
|
+
* - **3.6.3 Memory-Proposals** — AUQ-gated (the operator approves each
|
|
18
|
+
* proposal before it is written); `.claude/rules/ask-via-tool.md` AUQ-004
|
|
19
|
+
* puts the tool out of reach of anything but the coordinator thread.
|
|
20
|
+
* - **3.6.5 Auto-Dream** and **3.6.7 Auto-Dialectic** — both NUDGES that end
|
|
21
|
+
* in a subagent dispatch (memory-cleanup, `dialectic-deriver`); a library
|
|
22
|
+
* function cannot dispatch an agent.
|
|
23
|
+
* - **3.6.6 Skill-Judge** — needs a live LLM dispatch for the judgement.
|
|
24
|
+
* - **3.6.8 Reconcile** — the write is operator-approved per proposal (AUQ),
|
|
25
|
+
* which is the whole never-always-on firewall of Epic #693.
|
|
26
|
+
*
|
|
27
|
+
* So {@link runTailPhases} exists as the SEAM (one dispatch table, one result
|
|
28
|
+
* shape) rather than as an abstraction over six phases that will never all be
|
|
29
|
+
* mechanical. A future phase that becomes mechanical is added here; the five
|
|
30
|
+
* above stay coordinator-executed by design, not by omission.
|
|
31
|
+
*
|
|
32
|
+
* Never-throws contract, and its DIRECTION is the opposite of `phase-skip.mjs`.
|
|
33
|
+
* The planner fails OPEN (probe error → run the phase, because losing a phase
|
|
34
|
+
* silently is worse than running it needlessly). A runner that writes to disk
|
|
35
|
+
* must fail CLOSED: any error yields `{ran: false, reason: 'error'}` and the
|
|
36
|
+
* close proceeds. Session close is the one thing that must never be blocked by
|
|
37
|
+
* a best-effort maintenance sweep — a store that stays unswept for one more
|
|
38
|
+
* session costs nothing; a close that aborts loses the session record.
|
|
39
|
+
*
|
|
40
|
+
* Plain Node ESM, no external deps.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {Object} SweepResult
|
|
43
|
+
* @property {boolean} ran - true only when the store was actually rewritten.
|
|
44
|
+
* @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' | 'error').
|
|
45
|
+
* @property {string} [error] - error message, `reason === 'error'` only.
|
|
46
|
+
* @property {number} [scanned] - entries read from the active store (ran only).
|
|
47
|
+
* @property {number} [archived] - entries moved to the archive sidecar (ran only).
|
|
48
|
+
* @property {string} [archivePath] - the archive sidecar written to (ran only).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
52
|
+
import { resolveLearningsPaths } from './phase-skip.mjs';
|
|
53
|
+
|
|
54
|
+
/** Event name — a plain string literal so the events-schema census can see it. */
|
|
55
|
+
const SWEEP_EVENT = 'orchestrator.learnings.sweep_applied';
|
|
56
|
+
|
|
57
|
+
/** Payload marker for the producer, so the ledger separates it from the CLI. */
|
|
58
|
+
const SWEEP_SOURCE = 'session-end-3.6.4';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pull the decision for one phase id out of a `planTailPhases()` result.
|
|
62
|
+
*
|
|
63
|
+
* Accepts three shapes so callers need no adapter: the full
|
|
64
|
+
* `{plan, skippedReport}` envelope, a bare `PhaseDecision[]`, or a single
|
|
65
|
+
* `PhaseDecision` object. Anything else → `null` (treated as "no plan").
|
|
66
|
+
*
|
|
67
|
+
* @param {object|Array|undefined} plan
|
|
68
|
+
* @param {string} phase
|
|
69
|
+
* @returns {object|null}
|
|
70
|
+
*/
|
|
71
|
+
function findPhaseDecision(plan, phase) {
|
|
72
|
+
if (!plan) return null;
|
|
73
|
+
const list = Array.isArray(plan) ? plan : Array.isArray(plan.plan) ? plan.plan : null;
|
|
74
|
+
if (list) return list.find((d) => d && d.phase === phase) ?? null;
|
|
75
|
+
return plan.phase === phase ? plan : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Emit the one sweep record. Best-effort by construction: a telemetry failure
|
|
80
|
+
* never changes the caller's return value.
|
|
81
|
+
*
|
|
82
|
+
* The shape (lazy `events.mjs` import, explicit `repoRoot`, silent catch) is
|
|
83
|
+
* copied from `_emitEvaluated` in `scripts/lib/express-path.mjs`. Both halves
|
|
84
|
+
* are load-bearing: the lazy import keeps `platform.mjs`'s load-time filesystem
|
|
85
|
+
* walk-up out of the static graph of a module the planner imports, and the
|
|
86
|
+
* refusal to emit WITHOUT an explicit `repoRoot` prevents the `SO_PROJECT_DIR`
|
|
87
|
+
* fallback from writing a synthetic record into whatever tree the ambient env
|
|
88
|
+
* happens to resolve to (#941 — that mistake once landed a test record in the
|
|
89
|
+
* operator's real fleet ledger).
|
|
90
|
+
*
|
|
91
|
+
* @param {{repoRoot: string, scanned: number, archived: number}} ctx
|
|
92
|
+
* @returns {Promise<void>}
|
|
93
|
+
*/
|
|
94
|
+
async function emitSweepApplied({ repoRoot, scanned, archived }) {
|
|
95
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
96
|
+
process.stderr.write(
|
|
97
|
+
`tail-runner: skipped ${SWEEP_EVENT} — no repoRoot given; ` +
|
|
98
|
+
'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const { emitEvent, sessionAttribution } = await import('../events.mjs');
|
|
104
|
+
await emitEvent(
|
|
105
|
+
SWEEP_EVENT,
|
|
106
|
+
{ scanned, archived, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
|
|
107
|
+
{ repoRoot },
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort telemetry — the sweep already happened and its result is
|
|
111
|
+
// authoritative whether or not the ledger accepted the record.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Phase 3.6.4 — apply the Expired-Learnings Sweep the planner decided on.
|
|
117
|
+
*
|
|
118
|
+
* Runs ONLY when the plan's 3.6.4 decision says `run === true`. Any other
|
|
119
|
+
* shape (skip decision, phase absent, no plan at all) returns without touching
|
|
120
|
+
* disk — the planner owns the decision, this function owns the write, and a
|
|
121
|
+
* runner that re-derives the decision would be free to disagree with it.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} args
|
|
124
|
+
* @param {string} args.repoRoot Absolute repo root.
|
|
125
|
+
* @param {object|Array} [args.plan] `planTailPhases()` result, its `plan`
|
|
126
|
+
* array, or the bare 3.6.4 decision.
|
|
127
|
+
* @param {Date|number} [args.now] Injectable clock (grace-window maths).
|
|
128
|
+
* @param {number} [args.graceDays] Override the 14-day grace window.
|
|
129
|
+
* @param {boolean} [args.emit=true] Set false to suppress the ledger record.
|
|
130
|
+
* @returns {Promise<SweepResult>} Never throws.
|
|
131
|
+
*/
|
|
132
|
+
export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = true } = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const decision = findPhaseDecision(plan, '3.6.4');
|
|
135
|
+
if (!decision) return { ran: false, reason: 'no-plan' };
|
|
136
|
+
if (decision.run !== true) {
|
|
137
|
+
return { ran: false, reason: 'plan-skip', planReason: decision.reason };
|
|
138
|
+
}
|
|
139
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
140
|
+
return { ran: false, reason: 'error', error: 'runExpiredSweep: repoRoot is required' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { filePath, archivePath } = resolveLearningsPaths(repoRoot);
|
|
144
|
+
const res = await sweepExpiredLearnings({
|
|
145
|
+
filePath,
|
|
146
|
+
archivePath,
|
|
147
|
+
now,
|
|
148
|
+
dryRun: false,
|
|
149
|
+
...(graceDays === undefined ? {} : { graceDays }),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const scanned = res?.scanned ?? 0;
|
|
153
|
+
const archived = res?.archived ?? 0;
|
|
154
|
+
if (emit) await emitSweepApplied({ repoRoot, scanned, archived });
|
|
155
|
+
|
|
156
|
+
return { ran: true, scanned, archived, archivePath: res?.archivePath ?? archivePath };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
// Fail CLOSED (see the module header): a maintenance sweep must never be
|
|
159
|
+
// able to block a session close.
|
|
160
|
+
return { ran: false, reason: 'error', error: err && err.message ? err.message : String(err) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run every MECHANICAL phase of the session-end tail.
|
|
166
|
+
*
|
|
167
|
+
* Today that is exactly one — 3.6.4. See the module header for why 3.6.3 and
|
|
168
|
+
* 3.6.5–3.6.8 stay coordinator-executed (AUQ-gated per
|
|
169
|
+
* `.claude/rules/ask-via-tool.md` AUQ-004, or requiring a subagent dispatch a
|
|
170
|
+
* library function cannot make). The keyed return shape is the seam: a caller
|
|
171
|
+
* reads `result['3.6.4']` today and keeps compiling when a second phase lands.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} args — forwarded verbatim to {@link runExpiredSweep}.
|
|
174
|
+
* @returns {Promise<Record<string, SweepResult>>} Never throws.
|
|
175
|
+
*/
|
|
176
|
+
export async function runTailPhases({ repoRoot, plan, now, graceDays, emit } = {}) {
|
|
177
|
+
return { '3.6.4': await runExpiredSweep({ repoRoot, plan, now, graceDays, emit }) };
|
|
178
|
+
}
|
|
@@ -492,6 +492,11 @@ export function readLockDetailed(opts = {}) {
|
|
|
492
492
|
* recorded pids dead, INCLUDING the currently heartbeating session's
|
|
493
493
|
* own lock), so `stale-pid-alive` was unreachable same-host and every
|
|
494
494
|
* stale lock rendered as "confirmed dead" in the recovery AUQ.
|
|
495
|
+
* { ok: false, reason: 'missing-session-id' }
|
|
496
|
+
* — no usable `sessionId` given; NOTHING was written. Same reason string
|
|
497
|
+
* and same predicate as forceAcquire() (see its docblock for why an
|
|
498
|
+
* ownerless lock is worse than no lock). No `exclusivityClass`: the
|
|
499
|
+
* call never reached classification.
|
|
495
500
|
* { ok: false, reason: 'fs-error', error, exclusivityClass? }
|
|
496
501
|
* — filesystem failure
|
|
497
502
|
* { ok: false, reason: 'active-incompatible-exclusive', allActiveSessions, blockingSession, exclusivityClass }
|
|
@@ -509,6 +514,28 @@ export function readLockDetailed(opts = {}) {
|
|
|
509
514
|
* obtaining user consent.
|
|
510
515
|
*/
|
|
511
516
|
export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, activeSessions, semanticSessionId, quiet = false } = {}) {
|
|
517
|
+
// -------------------------------------------------------------------------
|
|
518
|
+
// Ownerless-lock guard. Measured 2026-09-11 in a tmp repoRoot:
|
|
519
|
+
// acquire({sessionId: undefined}) -> {ok:true}, lockfile written with NO
|
|
520
|
+
// `session_id` key at all; acquire({sessionId: ''}) -> {ok:true}, lockfile
|
|
521
|
+
// written with `"session_id": ""`. Both are the exact "present but owned by
|
|
522
|
+
// nobody" state forceAcquire()'s docblock below describes: every reader
|
|
523
|
+
// compares such a lock against its own id, finds no match, classifies it
|
|
524
|
+
// FOREIGN — and foreign is the classification that SKIPS enforcement.
|
|
525
|
+
// The guard lived only on forceAcquire(), the rare path (explicit
|
|
526
|
+
// stale-lock takeover); acquire() is the primary one. The production
|
|
527
|
+
// caller hooks/_lib/lock-bootstrap.mjs gates empty ids upstream, but the
|
|
528
|
+
// prose path skills/session-start/references/phase-1-2-session-lock.md
|
|
529
|
+
// calls acquire() straight from the coordinator LLM with no gate at all.
|
|
530
|
+
// Placed FIRST, before classifyMode and before any fs work: nothing is
|
|
531
|
+
// written. `exclusivityClass` is deliberately absent from this shape — the
|
|
532
|
+
// caller's class is a property of a call that got far enough to be
|
|
533
|
+
// classified, and this one did not.
|
|
534
|
+
// -------------------------------------------------------------------------
|
|
535
|
+
if (!hasUsableSessionId(sessionId)) {
|
|
536
|
+
return { ok: false, reason: 'missing-session-id' };
|
|
537
|
+
}
|
|
538
|
+
|
|
512
539
|
const lockFile = lockPathFor(repoRoot);
|
|
513
540
|
|
|
514
541
|
// -------------------------------------------------------------------------
|
|
@@ -671,12 +698,45 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
671
698
|
* Call only after the user has explicitly authorised stale-lock takeover.
|
|
672
699
|
*
|
|
673
700
|
* Returns:
|
|
674
|
-
* { ok: true, lock, replacedLock? }
|
|
675
|
-
* { ok: false, reason: '
|
|
701
|
+
* { ok: true, lock, replacedLock? } — lock written (replacedLock present if one was overwritten)
|
|
702
|
+
* { ok: false, reason: 'missing-session-id' } — no usable `sessionId` given; NOTHING was written
|
|
703
|
+
* { ok: false, reason: 'fs-error', ... } — filesystem failure
|
|
704
|
+
*
|
|
705
|
+
* The `missing-session-id` guard is not cosmetic. `buildLock` assigns
|
|
706
|
+
* `session_id: sessionId` verbatim, and `JSON.stringify` DROPS an `undefined`
|
|
707
|
+
* value — so a `forceAcquire()` without a sessionId used to write a lock file
|
|
708
|
+
* carrying no `session_id` key at all. Such a lock is present but owned by
|
|
709
|
+
* nobody: every reader compares it against its own id, finds no match, and
|
|
710
|
+
* classifies it as FOREIGN — and foreign is precisely the classification that
|
|
711
|
+
* skips enforcement. The same failure class is why the wave scope manifest
|
|
712
|
+
* forbids `"session_id": ""` and requires the key be omitted instead.
|
|
713
|
+
*
|
|
714
|
+
* The result SHAPE (not a throw) is deliberate and matches the callers: the
|
|
715
|
+
* only production call site, `hooks/_lib/lock-bootstrap.mjs`, branches on
|
|
716
|
+
* `acquireResult.ok !== true` and bails non-blocking; a throw there would be
|
|
717
|
+
* swallowed by its `catch` and reported as the same `null`, i.e. it would add
|
|
718
|
+
* no information while breaking the direct `forceAcquire()` calls documented in
|
|
719
|
+
* `skills/session-start/references/phase-1-2-session-lock.md`.
|
|
676
720
|
*
|
|
677
721
|
* @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string }} args
|
|
678
722
|
*/
|
|
723
|
+
/**
|
|
724
|
+
* A `sessionId` is usable only when it is a non-blank string. ONE predicate,
|
|
725
|
+
* shared by acquire() and forceAcquire(), so the two entry points cannot drift
|
|
726
|
+
* apart again — they did: the guard existed on forceAcquire() alone until
|
|
727
|
+
* 2026-09-11, leaving the primary path able to write an ownerless lock.
|
|
728
|
+
*
|
|
729
|
+
* @param {unknown} sessionId
|
|
730
|
+
* @returns {boolean}
|
|
731
|
+
*/
|
|
732
|
+
function hasUsableSessionId(sessionId) {
|
|
733
|
+
return typeof sessionId === 'string' && sessionId.trim().length > 0;
|
|
734
|
+
}
|
|
735
|
+
|
|
679
736
|
export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId } = {}) {
|
|
737
|
+
if (!hasUsableSessionId(sessionId)) {
|
|
738
|
+
return { ok: false, reason: 'missing-session-id' };
|
|
739
|
+
}
|
|
680
740
|
try {
|
|
681
741
|
const replacedLock = readLock({ repoRoot });
|
|
682
742
|
const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
|
|
@@ -42,6 +42,14 @@
|
|
|
42
42
|
* had defaulted, which is what lets a downstream consumer tell a measured zero
|
|
43
43
|
* apart from a repaired-to-zero.
|
|
44
44
|
*
|
|
45
|
+
* It also never DISCARDS what it replaces: any field whose present value is
|
|
46
|
+
* overwritten by a default is first copied to a `_<field>_raw` sidecar
|
|
47
|
+
* (`preserveRaw`, same convention and same non-clobber guard as
|
|
48
|
+
* `session-schema/normalizer.mjs`'s `_express_path_detail`). Without it the
|
|
49
|
+
* repair was itself a data-loss event — 7 of 40 records in the
|
|
50
|
+
* `projects-baseline` ledger (S119, 2026-09-10) lost narrative `agent_summary`
|
|
51
|
+
* strings and `total_files_changed` path lists to `{...0}` / `0`.
|
|
52
|
+
*
|
|
45
53
|
* Plain Node ESM. Named exports. DI-friendly via `deps`.
|
|
46
54
|
*
|
|
47
55
|
* Cross-references:
|
|
@@ -112,6 +120,70 @@ function orderIncompleteFields(fields) {
|
|
|
112
120
|
return [...fields].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Preserve the pre-repair value of `key` under a `_<key>_raw` sidecar BEFORE a
|
|
125
|
+
* default overwrites it.
|
|
126
|
+
*
|
|
127
|
+
* Convention lifted from `session-schema/normalizer.mjs`
|
|
128
|
+
* (`_express_path_detail`, `if (!('_express_path_detail' in next))`) — but its
|
|
129
|
+
* non-clobber guard is keyed on WHO WROTE the sidecar, not on its mere presence.
|
|
130
|
+
* That distinction is the fix for a measured data-loss bug (2026-09-11): the
|
|
131
|
+
* presence guard read `out`, and `out = { ...record }` carries the INPUT's key
|
|
132
|
+
* space, so a record arriving with its own `_agent_summary_raw` kept that
|
|
133
|
+
* unverifiable value and dropped the real `agent_summary` the repair was about
|
|
134
|
+
* to overwrite:
|
|
135
|
+
*
|
|
136
|
+
* in: agent_summary: 'ECHTE NARRATIVE ZUSAMMENFASSUNG',
|
|
137
|
+
* _agent_summary_raw: 'ANGREIFER-WERT'
|
|
138
|
+
* out: _agent_summary_raw = 'ANGREIFER-WERT' ← the real value was gone
|
|
139
|
+
*
|
|
140
|
+
* That is the mirror image of the promise this module's docblock makes, in the
|
|
141
|
+
* one case where the promise matters. So: `rescued` — a per-`repairRecord()`-call
|
|
142
|
+
* Set of sidecar keys THIS pass has written — replaces `sidecar in out`. A value
|
|
143
|
+
* that is provably real RIGHT NOW always outranks a sidecar of unknown origin.
|
|
144
|
+
*
|
|
145
|
+
* The multi-pass concern the old comment named is not what kept the sidecar
|
|
146
|
+
* safe, and was measured unreachable: after run 1 the field holds a VALID
|
|
147
|
+
* default, so run 2 records no defect for it and never calls preserveRaw at all
|
|
148
|
+
* (measured: `repairRecord(repairRecord(x).record).changed === false`, defects
|
|
149
|
+
* `[]`, sidecar intact). Within ONE pass the Set still gives first-write-wins,
|
|
150
|
+
* which is what the two branches per field (waves, agent_summary) rely on.
|
|
151
|
+
*
|
|
152
|
+
* NAMED CEILING (BV-004): if a record is RE-CORRUPTED with a different real
|
|
153
|
+
* value for the same key BETWEEN two repair runs, run 2's original wins and run
|
|
154
|
+
* 1's rescue is dropped. Reaching it requires corruption after a repair; the
|
|
155
|
+
* alternative — trusting an input-supplied sidecar over a value that is
|
|
156
|
+
* demonstrably real — is the bug above. Revisit if a ledger is ever repaired
|
|
157
|
+
* in a loop that can re-break the same key.
|
|
158
|
+
*
|
|
159
|
+
* Motivation is measured, not hypothetical: 7 of 40 repaired records in the
|
|
160
|
+
* `projects-baseline` ledger (S119, 2026-09-10) carried narrative strings in
|
|
161
|
+
* `agent_summary` and path lists in `total_files_changed`; both were replaced
|
|
162
|
+
* by `{...0}` / `0` and had to be restored by hand. This module's own docblock
|
|
163
|
+
* promises "nothing is invented" — discarding an unreadable original is the
|
|
164
|
+
* mirror-image violation of that promise.
|
|
165
|
+
*
|
|
166
|
+
* `undefined` and `null` are NOT preserved: an absent field has nothing to
|
|
167
|
+
* lose (the defect classes are named `*_absent` / `*_missing` for exactly that
|
|
168
|
+
* reason), and `JSON.stringify` drops an `undefined` sidecar anyway.
|
|
169
|
+
*
|
|
170
|
+
* The sidecars are schema-safe: `validateSession` has no unknown-key rejection
|
|
171
|
+
* (see `validator.mjs` — every `_validate*` helper checks named fields only),
|
|
172
|
+
* and `serializeSessionLineChecked` round-trips extra keys untouched.
|
|
173
|
+
*
|
|
174
|
+
* @param {Record<string, any>} out — the mutable repaired copy
|
|
175
|
+
* @param {string} key — the field about to be defaulted
|
|
176
|
+
* @param {any} rawValue — its ORIGINAL value (read from the untouched input)
|
|
177
|
+
* @param {Set<string>} rescued — sidecar keys already written by THIS call
|
|
178
|
+
*/
|
|
179
|
+
function preserveRaw(out, key, rawValue, rescued) {
|
|
180
|
+
if (rawValue === undefined || rawValue === null) return;
|
|
181
|
+
const sidecar = `_${key}_raw`;
|
|
182
|
+
if (rescued.has(sidecar)) return;
|
|
183
|
+
out[sidecar] = rawValue;
|
|
184
|
+
rescued.add(sidecar);
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
/** Compact ISO stamp for backup filenames: `20260805T091500Z`. */
|
|
116
188
|
export function backupStamp(date = new Date()) {
|
|
117
189
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
@@ -143,6 +215,9 @@ export function repairRecord(record) {
|
|
|
143
215
|
const out = { ...record };
|
|
144
216
|
const defects = [];
|
|
145
217
|
const incomplete = new Set();
|
|
218
|
+
// Sidecar keys written by THIS pass — the provenance the non-clobber guard
|
|
219
|
+
// needs. `out`'s own key space cannot serve: it is the INPUT's. See preserveRaw.
|
|
220
|
+
const rescued = new Set();
|
|
146
221
|
|
|
147
222
|
// -- waves ----------------------------------------------------------------
|
|
148
223
|
// A NUMBER here is not garbage: it IS the wave count, written by an older
|
|
@@ -158,6 +233,7 @@ export function repairRecord(record) {
|
|
|
158
233
|
} else {
|
|
159
234
|
defects.push('waves_not_array');
|
|
160
235
|
}
|
|
236
|
+
preserveRaw(out, 'waves', record.waves, rescued);
|
|
161
237
|
out.waves = [];
|
|
162
238
|
incomplete.add('waves');
|
|
163
239
|
}
|
|
@@ -173,6 +249,11 @@ export function repairRecord(record) {
|
|
|
173
249
|
if (out.waves.length > 0 && out.waves.every(isPlainObject)) {
|
|
174
250
|
const needsRenumber = out.waves.some((w) => !isCount(w.wave) || w.wave < 1);
|
|
175
251
|
if (needsRenumber) {
|
|
252
|
+
// The ORIGINAL ordinals are not recoverable from the renumbered output
|
|
253
|
+
// (the input may mix valid, absent and 0-based `wave` values), so the
|
|
254
|
+
// pre-renumber array is preserved whole — same sidecar as the
|
|
255
|
+
// not-an-array branch above, which cannot have fired on this path.
|
|
256
|
+
preserveRaw(out, 'waves', record.waves, rescued);
|
|
176
257
|
out.waves = out.waves.map((w, i) => ({ ...w, wave: i + 1 }));
|
|
177
258
|
defects.push('wave_index_invalid');
|
|
178
259
|
incomplete.add('waves[].wave');
|
|
@@ -181,6 +262,7 @@ export function repairRecord(record) {
|
|
|
181
262
|
|
|
182
263
|
// -- total_waves ----------------------------------------------------------
|
|
183
264
|
if (!isCount(out.total_waves)) {
|
|
265
|
+
preserveRaw(out, 'total_waves', record.total_waves, rescued);
|
|
184
266
|
out.total_waves = wavesNumber !== null ? wavesNumber : out.waves.length;
|
|
185
267
|
defects.push('total_waves_missing');
|
|
186
268
|
incomplete.add('total_waves');
|
|
@@ -188,12 +270,18 @@ export function repairRecord(record) {
|
|
|
188
270
|
|
|
189
271
|
// -- agent_summary --------------------------------------------------------
|
|
190
272
|
if (!isPlainObject(out.agent_summary)) {
|
|
273
|
+
preserveRaw(out, 'agent_summary', record.agent_summary, rescued);
|
|
191
274
|
out.agent_summary = { complete: 0, partial: 0, failed: 0, spiral: 0 };
|
|
192
275
|
defects.push('agent_summary_absent');
|
|
193
276
|
incomplete.add('agent_summary');
|
|
194
277
|
} else {
|
|
195
278
|
const missing = AGENT_SUMMARY_FIELDS.filter((f) => !isCount(out.agent_summary[f]));
|
|
196
279
|
if (missing.length > 0) {
|
|
280
|
+
// Preserve the WHOLE original summary object rather than one sidecar per
|
|
281
|
+
// defaulted counter: it carries every original field value at once and
|
|
282
|
+
// keeps the sidecar namespace flat (`_agent_summary.spiral_raw` would be
|
|
283
|
+
// a second, uglier convention for the same job).
|
|
284
|
+
preserveRaw(out, 'agent_summary', record.agent_summary, rescued);
|
|
197
285
|
out.agent_summary = { ...out.agent_summary };
|
|
198
286
|
for (const f of missing) {
|
|
199
287
|
out.agent_summary[f] = 0;
|
|
@@ -209,6 +297,7 @@ export function repairRecord(record) {
|
|
|
209
297
|
|
|
210
298
|
// -- total_agents ---------------------------------------------------------
|
|
211
299
|
if (!isCount(out.total_agents)) {
|
|
300
|
+
preserveRaw(out, 'total_agents', record.total_agents, rescued);
|
|
212
301
|
// Prefer the record's own evidence: an agent_summary PRESENT in the
|
|
213
302
|
// original sums to the real agent count (live line 71 sums to 30 where
|
|
214
303
|
// waves.length is 5 — W2/A4 review finding). Fall back to waves.length
|
|
@@ -226,6 +315,7 @@ export function repairRecord(record) {
|
|
|
226
315
|
|
|
227
316
|
// -- total_files_changed --------------------------------------------------
|
|
228
317
|
if (!isCount(out.total_files_changed)) {
|
|
318
|
+
preserveRaw(out, 'total_files_changed', record.total_files_changed, rescued);
|
|
229
319
|
out.total_files_changed = 0;
|
|
230
320
|
defects.push('total_files_changed_missing');
|
|
231
321
|
incomplete.add('total_files_changed');
|
|
@@ -239,6 +329,7 @@ export function repairRecord(record) {
|
|
|
239
329
|
// i.e. "unknown", and flagged as such. NEVER the next record's timestamp:
|
|
240
330
|
// the ledger is not chronologically ordered (see the module docblock).
|
|
241
331
|
if (typeof out.completed_at !== 'string' && typeof out.started_at === 'string') {
|
|
332
|
+
preserveRaw(out, 'completed_at', record.completed_at, rescued);
|
|
242
333
|
const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
|
|
243
334
|
const startedMs = Date.parse(out.started_at);
|
|
244
335
|
out.completed_at =
|
|
@@ -161,6 +161,12 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
161
161
|
'total_token_input',
|
|
162
162
|
'total_token_output',
|
|
163
163
|
'subagents_with_tokens',
|
|
164
|
+
// #1244 — cache buckets + cost estimate (additive, optional).
|
|
165
|
+
'total_token_input_uncached',
|
|
166
|
+
'total_token_cache_read',
|
|
167
|
+
'total_token_cache_creation',
|
|
168
|
+
'total_cost_usd',
|
|
169
|
+
'_token_schema',
|
|
164
170
|
// Epic #724 C1 — SessionEnd close-through backfill provenance fields.
|
|
165
171
|
// `status` distinguishes normally-closed ('completed') from hook-backfilled
|
|
166
172
|
// ('abandoned') records. The `_backfill_*` markers record how a stub was
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* schema migrations and dedup checks all legitimately need the phantoms. Use
|
|
34
34
|
* these helpers only where the window is meant to represent REAL WORK.
|
|
35
35
|
*
|
|
36
|
-
* Exports: isRealSession, filterRealSessions, tailRealSessions
|
|
36
|
+
* Exports: isRealSession, filterRealSessions, tailRealSessions,
|
|
37
|
+
* isCoordinatorDirectHousekeeping
|
|
37
38
|
*/
|
|
38
39
|
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -86,3 +87,27 @@ export function tailRealSessions(records, n) {
|
|
|
86
87
|
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return [];
|
|
87
88
|
return real.slice(-Math.floor(n));
|
|
88
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when a record's waves are ALL coordinator-direct `Housekeeping` waves —
|
|
93
|
+
* the shape the session-end writer rule (#1321) records for a session that ran
|
|
94
|
+
* no dispatched waves. The single definition of that shape: consumers treat such
|
|
95
|
+
* a record as "no waves ran" (eval gate-health) and exclude its 0-agent waves
|
|
96
|
+
* from agents-per-wave ratios.
|
|
97
|
+
*
|
|
98
|
+
* Keys on the wave SHAPE only, never on `session_type`: a housekeeping session
|
|
99
|
+
* that ran real waves is not this shape. Nor on `coordinator_direct` alone —
|
|
100
|
+
* whole multi-wave feature sessions carry it too.
|
|
101
|
+
*
|
|
102
|
+
* @param {unknown} record — a parsed sessions.jsonl entry
|
|
103
|
+
* @returns {boolean} — false for empty/absent `waves`
|
|
104
|
+
*/
|
|
105
|
+
export function isCoordinatorDirectHousekeeping(record) {
|
|
106
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) return false;
|
|
107
|
+
const { waves } = record;
|
|
108
|
+
return (
|
|
109
|
+
Array.isArray(waves) &&
|
|
110
|
+
waves.length > 0 &&
|
|
111
|
+
waves.every((w) => w?.role === 'Housekeeping' && w?.coordinator_direct === true)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -384,6 +384,26 @@ function _validateOptionalFields(entry) {
|
|
|
384
384
|
);
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
|
+
// #1244 — cache buckets, cost estimate and the token-contract marker.
|
|
388
|
+
// Same non-negative-finite-or-null contract as the two totals above;
|
|
389
|
+
// total_cost_usd is fractional and null means "unknown model", never 0.
|
|
390
|
+
for (const field of [
|
|
391
|
+
'total_token_input_uncached',
|
|
392
|
+
'total_token_cache_read',
|
|
393
|
+
'total_token_cache_creation',
|
|
394
|
+
'total_cost_usd',
|
|
395
|
+
'_token_schema',
|
|
396
|
+
]) {
|
|
397
|
+
const value = entry[field];
|
|
398
|
+
if (value !== undefined && value !== null) {
|
|
399
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
400
|
+
throw new ValidationError(
|
|
401
|
+
`${field} must be a non-negative finite number or null, got: ${value}`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
387
407
|
// subagents_with_tokens: non-negative integer (never null — it is always a count, defaulting to 0).
|
|
388
408
|
if (entry.subagents_with_tokens !== undefined && entry.subagents_with_tokens !== null) {
|
|
389
409
|
if (
|