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
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
* - File-absent or all-null-token sessions return a sentinel shape with null
|
|
13
13
|
* totals (not 0) so callers can distinguish "session had no token data" from
|
|
14
14
|
* "session was genuinely free / cost $0".
|
|
15
|
+
* - `match_status` and `ledger_records` distinguish why totals are absent
|
|
16
|
+
* without interpreting the session key's shape (#1027 Nachtrag 7).
|
|
15
17
|
* - Malformed JSONL lines are silently skipped (resilience over strictness).
|
|
16
18
|
* - `subagents_with_tokens` counts distinct agent_ids that have at least one
|
|
17
|
-
* TOKEN-BEARING record (coverage metric)
|
|
19
|
+
* TOKEN-BEARING record (coverage metric); every total counts each agent once
|
|
20
|
+
* (§ One record per agent).
|
|
18
21
|
*
|
|
19
22
|
* ## Token provenance — why a bare Σ over token_input is wrong (#949)
|
|
20
23
|
*
|
|
@@ -64,10 +67,54 @@
|
|
|
64
67
|
* `total_token_*` and reports the excluded ones as `legacy_v1_records`: the
|
|
65
68
|
* boundary is DECLARED, never silent.
|
|
66
69
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
70
|
+
* ## One record per agent — a stop record carries a RUNNING total
|
|
71
|
+
*
|
|
72
|
+
* `hooks/subagent-telemetry.mjs` re-reads the agent's WHOLE transcript at every
|
|
73
|
+
* stop, so an agent that stops twice (resumed via SendMessage, for one) writes
|
|
74
|
+
* its running total twice, and summing both counts the first part again. Every
|
|
75
|
+
* total below therefore takes ONE v2 record per `agent_id`: its tokens come from
|
|
76
|
+
* the agent's last stop that found its transcript and carried tokens, its cost
|
|
77
|
+
* candidacy from its last stop that found its transcript ("last" = latest
|
|
78
|
+
* `timestamp`, ties and unparseable timestamps by file order). Those two differ
|
|
79
|
+
* only when the last read yielded no tokens — an oversized transcript: the
|
|
80
|
+
* earlier running total is then a lower bound for the tokens and the cost is
|
|
81
|
+
* unknown. Measured 2026-10-01 over the 31 local fleet ledgers (1,002 distinct
|
|
82
|
+
* session/agent pairs with two or more found stops, 407 sessions): no v2 token
|
|
83
|
+
* bucket ever decreased from one found stop to the next, so the last record is
|
|
84
|
+
* also the largest; 2 pairs end in a token-less stop, both transcripts now past
|
|
85
|
+
* the hook's 50 MiB read limit. FORWARD-ONLY like the #949 cut: totals already
|
|
86
|
+
* in `sessions.jsonl` are not recomputed.
|
|
87
|
+
*
|
|
88
|
+
* `total_cost_usd` is computed per agent via `costUsd()` and is null whenever
|
|
89
|
+
* `cost_records_priced < cost_records_total` (#1475) — a partial cost is worse
|
|
90
|
+
* than no cost, because it reads as a complete one. An agent is unpriced when the
|
|
91
|
+
* model of its last found record is unknown to the price table, when it has no model (a token-bearing turn
|
|
92
|
+
* named none), when it has no tokens at all (transcript found but oversized,
|
|
93
|
+
* without usage turns, or unreadable, #1474), or when the agent has a start
|
|
94
|
+
* record but its stop found no transcript of its own and no other record of the
|
|
95
|
+
* same agent did (`start_record_found: true`, `subagent_transcript_found: false`
|
|
96
|
+
* — mostly `workflow-subagent`), or when its only own-transcript records are
|
|
97
|
+
* schema_version 1 (#1487 Pkt 1 — v1 has no priceable buckets). A phantom stop
|
|
98
|
+
* (#939, no start record) is no agent and no candidate. A record whose four token buckets
|
|
99
|
+
* are all 0 is priced at $0 whatever its model, and never turns a null total into
|
|
100
|
+
* a number on its own. A record whose turns span several models carries
|
|
101
|
+
* `models_usage` (#1470) and is priced part by part. `cost_records_priced` /
|
|
102
|
+
* `cost_records_total` say how much of the session the estimate covers; with
|
|
103
|
+
* nothing to price (total 0) the cost is null as well.
|
|
104
|
+
*
|
|
105
|
+
* Blind spots the ledger cannot close: an agent whose start record lies outside
|
|
106
|
+
* the hook's tail window reads `start_record_found: false` and is
|
|
107
|
+
* indistinguishable from a phantom, and an agent that left neither a start nor a
|
|
108
|
+
* stop record is invisible. "Never a partial sum" holds over the agents the
|
|
109
|
+
* ledger can see. Until #1487 Pkt 1 (2026-10-02) a session spanning the
|
|
110
|
+
* 2026-09-09 schema boundary was priced over its v2 records only — its v1
|
|
111
|
+
* agents were never cost candidates, so the v2 half's sum read as the whole
|
|
112
|
+
* bill (4 distinct fleet sessions, 31 ledgers, 2026-10-01). A v1 agent is now an
|
|
113
|
+
* unpriced candidate: such a session reports `total_cost_usd: null` with
|
|
114
|
+
* `cost_records_priced < cost_records_total`. Re-measured 2026-10-02 (28
|
|
115
|
+
* ledgers, 1,887 ledger/session pairs): the same 4 sessions turn null, and 498
|
|
116
|
+
* pure-v1 pairs change only `cost_records_total` (0/0 → 0/N, cost null before
|
|
117
|
+
* and after). FORWARD-ONLY like the #949 cut.
|
|
71
118
|
*
|
|
72
119
|
* @module session-token-rollup
|
|
73
120
|
*/
|
|
@@ -102,6 +149,26 @@ function isTokenBearing(record) {
|
|
|
102
149
|
return record?.subagent_transcript_found === true;
|
|
103
150
|
}
|
|
104
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Did a REAL agent run whose transcript the hook could not find? (#1475 follow-up)
|
|
154
|
+
*
|
|
155
|
+
* A stop record with a start record (`start_record_found: true`) but no own
|
|
156
|
+
* transcript (`subagent_transcript_found: false`) is not a phantom: the agent
|
|
157
|
+
* existed, it just left nothing to price — 813 of the 877 such records in the
|
|
158
|
+
* 33 fleet ledgers are `workflow-subagent` (2026-10-01). Its cost is unknown, so it must reach
|
|
159
|
+
* `cost_records_total`. A phantom stop (#939) has `start_record_found` false and
|
|
160
|
+
* stays out; a record written before either flag existed matches neither.
|
|
161
|
+
* @param {object} record
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
function isStartedWithoutTranscript(record) {
|
|
165
|
+
return (
|
|
166
|
+
record?.event === 'stop' &&
|
|
167
|
+
record.start_record_found === true &&
|
|
168
|
+
record.subagent_transcript_found === false
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
105
172
|
/**
|
|
106
173
|
* Is this record inside the schema_version 2 token contract? (#1244)
|
|
107
174
|
* @param {object} record
|
|
@@ -111,19 +178,76 @@ function isV2(record) {
|
|
|
111
178
|
return typeof record?.schema_version === 'number' && record.schema_version >= 2;
|
|
112
179
|
}
|
|
113
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Does this record carry a token count on either side?
|
|
183
|
+
* @param {object} record
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function hasTokens(record) {
|
|
187
|
+
const { token_input: inp, token_output: out } = record;
|
|
188
|
+
return (typeof inp === 'number' && inp >= 0) || (typeof out === 'number' && out >= 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Was `candidate` written at a later stop than `current`? Ordered by
|
|
193
|
+
* `timestamp`; a tie or an unparseable timestamp falls back to file order, and
|
|
194
|
+
* the caller reads in file order, so the candidate then wins.
|
|
195
|
+
* @param {object} candidate — read after `current` in the ledger
|
|
196
|
+
* @param {object} current
|
|
197
|
+
* @returns {boolean}
|
|
198
|
+
*/
|
|
199
|
+
function isLaterStop(candidate, current) {
|
|
200
|
+
const a = Date.parse(candidate.timestamp);
|
|
201
|
+
const b = Date.parse(current.timestamp);
|
|
202
|
+
if (Number.isNaN(a) || Number.isNaN(b) || a === b) return true;
|
|
203
|
+
return a > b;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* USD cost of one v2 record, or null when it cannot be priced. A record whose
|
|
208
|
+
* token-bearing turns span several models carries `models_usage` (#1470): each
|
|
209
|
+
* part is priced at its own model's rates, and one unknown part leaves the whole
|
|
210
|
+
* record unpriced. Without that field the record's single `model` prices all
|
|
211
|
+
* four buckets.
|
|
212
|
+
* @param {object} record
|
|
213
|
+
* @returns {number|null}
|
|
214
|
+
*/
|
|
215
|
+
function recordCostUsd(record) {
|
|
216
|
+
const parts =
|
|
217
|
+
Array.isArray(record.models_usage) && record.models_usage.length > 0
|
|
218
|
+
? record.models_usage
|
|
219
|
+
: [record];
|
|
220
|
+
let total = 0;
|
|
221
|
+
for (const part of parts) {
|
|
222
|
+
const cost = costUsd({
|
|
223
|
+
model: part?.model,
|
|
224
|
+
tokenInputUncached: part?.token_input_uncached,
|
|
225
|
+
tokenCacheRead: part?.token_cache_read,
|
|
226
|
+
tokenCacheCreation: part?.token_cache_creation,
|
|
227
|
+
tokenOutput: part?.token_output,
|
|
228
|
+
});
|
|
229
|
+
if (cost === null) return null;
|
|
230
|
+
total += cost;
|
|
231
|
+
}
|
|
232
|
+
return total;
|
|
233
|
+
}
|
|
234
|
+
|
|
114
235
|
/**
|
|
115
236
|
* @typedef {Object} TokenRollupResult
|
|
116
|
-
* @property {
|
|
117
|
-
* @property {number|null}
|
|
237
|
+
* @property {'invalid-key'|'ledger-absent'|'ledger-empty'|'unmatched'|'matched'} match_status - Distinguishes absent telemetry from an unmatched key (#1027 Nachtrag 7).
|
|
238
|
+
* @property {number|null} ledger_records - Count of parsed non-null, non-array objects; null when the ledger was not read (#1027 Nachtrag 7).
|
|
239
|
+
* @property {number|null} total_token_input - Sum of token_input over one v2 token-bearing record per agent (its last stop that carried tokens — § One record per agent); null when none had a non-null value.
|
|
240
|
+
* @property {number|null} total_token_output - Sum of token_output over the same one record per agent; null when none had a non-null value.
|
|
241
|
+
* @property {number|null} total_tokens - total_token_input + total_token_output (#1436); a null side counts as absent, null only when BOTH are null.
|
|
118
242
|
* @property {number} subagents_with_tokens - Count of distinct agent_ids with at least one token-bearing record. This is the numerator of the honest coverage ratio.
|
|
119
243
|
* @property {number} matched_records - Total count of JSONL records matched by parentSessionId. Counts start records, phantom stops and pre-#949 records alike, so it is NOT the denominator for a token-coverage ratio — dividing by it is what made healthy sessions read as 12% covered.
|
|
120
|
-
* @property {number|null} total_token_input_uncached - Sum of token_input_uncached
|
|
121
|
-
* @property {number|null} total_token_cache_read - Sum of token_cache_read
|
|
122
|
-
* @property {number|null} total_token_cache_creation - Sum of token_cache_creation
|
|
123
|
-
* @property {number|null} total_cost_usd - Σ
|
|
124
|
-
* @property {number} cost_records_priced - How many
|
|
125
|
-
* @property {number} cost_records_total - How many
|
|
126
|
-
* @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every total above because their schema_version < 2 (their token_input is a different quantity).
|
|
244
|
+
* @property {number|null} total_token_input_uncached - Sum of token_input_uncached over the same one record per agent.
|
|
245
|
+
* @property {number|null} total_token_cache_read - Sum of token_cache_read over the same one record per agent.
|
|
246
|
+
* @property {number|null} total_token_cache_creation - Sum of token_cache_creation over the same one record per agent.
|
|
247
|
+
* @property {number|null} total_cost_usd - Σ cost over the agents counted in cost_records_priced; null when cost_records_priced < cost_records_total (#1475 — any unpriced candidate: unknown model, no model, no tokens, a started agent without a transcript, or a v1 agent, #1487 Pkt 1), null when there is nothing to price (cost_records_total 0), and null when no record with a non-zero bucket was priced (all-zero records alone never yield a fabricated $0). Never 0 for unknown — see telemetry/pricing.mjs.
|
|
248
|
+
* @property {number} cost_records_priced - How many agents of cost_records_total carry a known cost on their last found v2 record: priced by the table (per model part when the record carries models_usage, #1470), or all four token buckets 0 (priced at $0 whatever the model, #1474).
|
|
249
|
+
* @property {number} cost_records_total - How many agents were candidates for pricing, each counted once however often it stopped: every agent with a v2 record whose own transcript was found — priced on its LAST such record, so one whose last read yielded no tokens (transcript oversized, without usage turns, or unreadable, #1474) is unpriced — plus every started agent whose stops found no transcript of its own (`start_record_found: true`, `subagent_transcript_found: false`), plus every agent with a schema_version 1 own-transcript record (#1487 Pkt 1) — each of these two only when the agent has no v2 own-transcript record in the session. All three latter kinds have an unknown cost. Phantom stops (no start record, #939) are never candidates. priced < total nulls total_cost_usd.
|
|
250
|
+
* @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every token total above because their schema_version < 2 (their token_input is a different quantity). Their agents are unpriced cost candidates (cost_records_total, #1487 Pkt 1).
|
|
127
251
|
* @property {2} _token_schema - The token contract these totals were computed under.
|
|
128
252
|
*/
|
|
129
253
|
|
|
@@ -142,8 +266,11 @@ export function rollupSessionTokens({
|
|
|
142
266
|
}) {
|
|
143
267
|
/** @type {TokenRollupResult} */
|
|
144
268
|
const ZERO = {
|
|
269
|
+
match_status: 'invalid-key',
|
|
270
|
+
ledger_records: null,
|
|
145
271
|
total_token_input: null,
|
|
146
272
|
total_token_output: null,
|
|
273
|
+
total_tokens: null,
|
|
147
274
|
subagents_with_tokens: 0,
|
|
148
275
|
matched_records: 0,
|
|
149
276
|
total_token_input_uncached: null,
|
|
@@ -169,7 +296,7 @@ export function rollupSessionTokens({
|
|
|
169
296
|
raw = readFileSync(resolvedPath, 'utf8');
|
|
170
297
|
} catch (err) {
|
|
171
298
|
if (err.code === 'ENOENT') {
|
|
172
|
-
return { ...ZERO };
|
|
299
|
+
return { ...ZERO, match_status: 'ledger-absent' };
|
|
173
300
|
}
|
|
174
301
|
throw err;
|
|
175
302
|
}
|
|
@@ -177,6 +304,7 @@ export function rollupSessionTokens({
|
|
|
177
304
|
// Parse JSONL — skip malformed lines, filter to parentSessionId.
|
|
178
305
|
const lines = raw.split('\n');
|
|
179
306
|
const matched = [];
|
|
307
|
+
let ledgerRecords = 0;
|
|
180
308
|
for (const line of lines) {
|
|
181
309
|
const trimmed = line.trim();
|
|
182
310
|
if (!trimmed) continue;
|
|
@@ -187,13 +315,19 @@ export function rollupSessionTokens({
|
|
|
187
315
|
// Malformed line — skip silently.
|
|
188
316
|
continue;
|
|
189
317
|
}
|
|
190
|
-
if (record
|
|
318
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) continue;
|
|
319
|
+
ledgerRecords += 1;
|
|
320
|
+
if (record.parent_session_id === parentSessionId) {
|
|
191
321
|
matched.push(record);
|
|
192
322
|
}
|
|
193
323
|
}
|
|
194
324
|
|
|
195
325
|
if (matched.length === 0) {
|
|
196
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
...ZERO,
|
|
328
|
+
match_status: ledgerRecords === 0 ? 'ledger-empty' : 'unmatched',
|
|
329
|
+
ledger_records: ledgerRecords,
|
|
330
|
+
};
|
|
197
331
|
}
|
|
198
332
|
|
|
199
333
|
// Aggregate — skip null/undefined token values.
|
|
@@ -205,7 +339,6 @@ export function rollupSessionTokens({
|
|
|
205
339
|
let sumCost = null;
|
|
206
340
|
let costPriced = 0;
|
|
207
341
|
let costTotal = 0;
|
|
208
|
-
let costUnknownModel = false;
|
|
209
342
|
let legacyV1 = 0;
|
|
210
343
|
|
|
211
344
|
// Track distinct agent_ids that contributed at least one non-null token.
|
|
@@ -214,68 +347,133 @@ export function rollupSessionTokens({
|
|
|
214
347
|
const addNonNegative = (acc, value) =>
|
|
215
348
|
typeof value === 'number' && value >= 0 ? (acc ?? 0) + value : acc;
|
|
216
349
|
|
|
350
|
+
// Real agents that may have no v2 record of their own transcript — each an
|
|
351
|
+
// unpriced candidate, counted once: a started agent whose stop found no
|
|
352
|
+
// transcript (#1475), and an agent seen in a v1 record (#1487 Pkt 1 — v1
|
|
353
|
+
// carries no priceable buckets). Resolved against `agents` only AFTER the
|
|
354
|
+
// pass: a later v2 stop of the same agent that found its transcript prices it
|
|
355
|
+
// (measured 2026-10-01: 4 agents in one fleet session, `workflow-subagent`
|
|
356
|
+
// found:false first, then found:true) and must not null a known cost. A record
|
|
357
|
+
// without an agent_id cannot be joined to another and stands alone.
|
|
358
|
+
const maybeUnpriced = new Set();
|
|
359
|
+
let unkeyedUnpriced = 0;
|
|
360
|
+
const addMaybeUnpriced = (record) => {
|
|
361
|
+
if (record.agent_id === undefined || record.agent_id === null) unkeyedUnpriced += 1;
|
|
362
|
+
else maybeUnpriced.add(record.agent_id);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// One agent, one count (see module header § One record per agent). Per agent:
|
|
366
|
+
// `last` is its last stop that found its transcript — the cost candidate —
|
|
367
|
+
// and `lastWithTokens` its last such stop that carried tokens — the token
|
|
368
|
+
// contribution. They differ only when the last read yielded no tokens (an
|
|
369
|
+
// oversized transcript): the earlier running total is then a known lower bound
|
|
370
|
+
// for the tokens, while the cost stays unknown. A record without an agent_id
|
|
371
|
+
// cannot be joined to another and stands alone.
|
|
372
|
+
/** @type {Map<unknown, {last: object, lastWithTokens: object|null}>} */
|
|
373
|
+
const agents = new Map();
|
|
374
|
+
|
|
217
375
|
for (const record of matched) {
|
|
218
376
|
// Provenance gate (#949) — a record whose tokens describe the PARENT
|
|
219
|
-
// transcript, or no transcript at all, contributes
|
|
377
|
+
// transcript, or no transcript at all, contributes no TOKENS. Skipping it
|
|
220
378
|
// entirely (rather than treating its values as 0) preserves the null
|
|
221
379
|
// sentinel: a session of only untrustworthy records reports "no data",
|
|
222
380
|
// which is true, instead of a fabricated 0.
|
|
223
|
-
if (!isTokenBearing(record))
|
|
381
|
+
if (!isTokenBearing(record)) {
|
|
382
|
+
// …but an agent that really ran without a readable transcript has an
|
|
383
|
+
// unknown cost: an unpriced candidate (total, not priced), so priced <
|
|
384
|
+
// total nulls `total_cost_usd` (#1475). Phantoms never reach this branch.
|
|
385
|
+
if (isStartedWithoutTranscript(record)) addMaybeUnpriced(record);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
224
388
|
|
|
225
389
|
// Schema gate (#1244) — a v1 record's token_input is raw uncached input,
|
|
226
390
|
// a different quantity from a v2 record's billable prompt volume. Count it
|
|
227
|
-
// so the boundary is visible, never sum it.
|
|
391
|
+
// so the boundary is visible, never sum it. Its agent ran, though, and v1
|
|
392
|
+
// cannot be priced: it is an unpriced cost candidate (#1487 Pkt 1), so a
|
|
393
|
+
// session spanning the 2026-09-09 boundary reports a null cost instead of
|
|
394
|
+
// the v2 half's partial sum.
|
|
228
395
|
if (!isV2(record)) {
|
|
229
396
|
legacyV1 += 1;
|
|
397
|
+
addMaybeUnpriced(record);
|
|
230
398
|
continue;
|
|
231
399
|
}
|
|
232
400
|
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
401
|
+
const withTokens = hasTokens(record) ? record : null;
|
|
402
|
+
const key = record.agent_id ?? Symbol('unkeyed');
|
|
403
|
+
const seen = agents.get(key);
|
|
404
|
+
if (!seen) {
|
|
405
|
+
agents.set(key, { last: record, lastWithTokens: withTokens });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// A token-bearing `last` is always `lastWithTokens` too — set together, so
|
|
409
|
+
// an unparseable timestamp (no transitive order) cannot split them.
|
|
410
|
+
if (isLaterStop(record, seen.last)) {
|
|
411
|
+
seen.last = record;
|
|
412
|
+
if (withTokens) seen.lastWithTokens = withTokens;
|
|
413
|
+
} else if (withTokens && (!seen.lastWithTokens || isLaterStop(withTokens, seen.lastWithTokens))) {
|
|
414
|
+
seen.lastWithTokens = withTokens;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
241
417
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
418
|
+
for (const [key, { last, lastWithTokens }] of agents) {
|
|
419
|
+
if (lastWithTokens) {
|
|
420
|
+
sumInput = addNonNegative(sumInput, lastWithTokens.token_input);
|
|
421
|
+
sumOutput = addNonNegative(sumOutput, lastWithTokens.token_output);
|
|
422
|
+
sumUncached = addNonNegative(sumUncached, lastWithTokens.token_input_uncached);
|
|
423
|
+
sumCacheRead = addNonNegative(sumCacheRead, lastWithTokens.token_cache_read);
|
|
424
|
+
sumCacheCreation = addNonNegative(sumCacheCreation, lastWithTokens.token_cache_creation);
|
|
425
|
+
if (typeof key !== 'symbol') agentsWithTokens.add(key);
|
|
426
|
+
}
|
|
249
427
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
428
|
+
// Cost: every agent with a found transcript is ONE pricing candidate. One
|
|
429
|
+
// unpriced agent poisons the SESSION total — a cost covering some of the
|
|
430
|
+
// agents reads as covering all of them.
|
|
431
|
+
costTotal += 1;
|
|
432
|
+
if (last !== lastWithTokens) {
|
|
433
|
+
// #1474 — the agent's last read of its own transcript yielded no tokens:
|
|
434
|
+
// it exceeded the hook's MAX_TRANSCRIPT_BYTES and was not read, it held no
|
|
435
|
+
// usage turns, or it was unreadable. Either way the agent ran and its cost
|
|
436
|
+
// is unknown, so it counts as an unpriced candidate (total, not priced),
|
|
437
|
+
// and priced < total nulls `total_cost_usd` (#1475).
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// #1474 — all four buckets 0 costs $0 whatever the model says. The hook
|
|
441
|
+
// names such a record by its last turn, typically `<synthetic>`, which
|
|
442
|
+
// the price table does not know; pricing it would null the session total
|
|
443
|
+
// for a record that added nothing to it.
|
|
444
|
+
const allZero = [
|
|
445
|
+
last.token_input_uncached,
|
|
446
|
+
last.token_cache_read,
|
|
447
|
+
last.token_cache_creation,
|
|
448
|
+
last.token_output,
|
|
449
|
+
].every((v) => v === 0);
|
|
450
|
+
const cost = allZero ? 0 : recordCostUsd(last);
|
|
451
|
+
if (cost !== null) {
|
|
452
|
+
costPriced += 1;
|
|
453
|
+
// An all-zero record adds nothing, so it must not be what turns
|
|
454
|
+
// `sumCost` from null into a number: next to a token-less record it
|
|
455
|
+
// would persist a fabricated $0 for a session whose real cost is unknown.
|
|
456
|
+
if (!allZero) sumCost = (sumCost ?? 0) + cost;
|
|
267
457
|
}
|
|
268
458
|
}
|
|
459
|
+
for (const id of maybeUnpriced) if (!agents.has(id)) costTotal += 1;
|
|
460
|
+
costTotal += unkeyedUnpriced;
|
|
269
461
|
|
|
270
462
|
return {
|
|
463
|
+
match_status: 'matched',
|
|
464
|
+
ledger_records: ledgerRecords,
|
|
271
465
|
total_token_input: sumInput,
|
|
272
466
|
total_token_output: sumOutput,
|
|
467
|
+
// #1436 — the one figure a session-level budget compares against. Null only
|
|
468
|
+
// when neither side has data; never a fabricated 0.
|
|
469
|
+
total_tokens: sumInput === null && sumOutput === null ? null : (sumInput ?? 0) + (sumOutput ?? 0),
|
|
273
470
|
subagents_with_tokens: agentsWithTokens.size,
|
|
274
471
|
matched_records: matched.length,
|
|
275
472
|
total_token_input_uncached: sumUncached,
|
|
276
473
|
total_token_cache_read: sumCacheRead,
|
|
277
474
|
total_token_cache_creation: sumCacheCreation,
|
|
278
|
-
|
|
475
|
+
// #1475 — a cost that leaves out any candidate record is not persisted.
|
|
476
|
+
total_cost_usd: costPriced < costTotal ? null : sumCost,
|
|
279
477
|
cost_records_priced: costPriced,
|
|
280
478
|
cost_records_total: costTotal,
|
|
281
479
|
legacy_v1_records: legacyV1,
|
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
* (2026-05-10) carries a byte-identical duplicate LINE — an older
|
|
21
21
|
* collision class than (3), and rule (1) alone resolves it.
|
|
22
22
|
*
|
|
23
|
+
* EXCEPT: a backfill stub never shadows a record that is not one (#1443).
|
|
24
|
+
* A stub (a synthesised `_backfill_source` — repair provenance is not one,
|
|
25
|
+
* see `isBackfillStub()` — and no wave, agent or changed file, whatever the
|
|
26
|
+
* `status` or `_repair_source`) appended AFTER the real record for the
|
|
27
|
+
* same id hid that record from every reader — the writers were closed by
|
|
28
|
+
* #1368 and the key-occupancy guard, this reader was the remaining gap.
|
|
29
|
+
* Among non-stubs, and among stubs, the newest still wins. The same guard
|
|
30
|
+
* joins ACROSS ids: a stub sharing a non-empty `raw_session_id` with a
|
|
31
|
+
* non-stub under a DIFFERENT `session_id` is dropped for the non-stub. That
|
|
32
|
+
* join runs LAST, so it can neither orphan a synthetic twin of (2) nor
|
|
33
|
+
* change what (3) reports.
|
|
34
|
+
*
|
|
23
35
|
* (2) NARROW COLLAPSE OF THE SYSTEMIC DOUBLE-STUB CLASS.
|
|
24
36
|
* Two `abandoned` records with an EXACT `started_at` + `completed_at`
|
|
25
37
|
* tuple match are one physical session recorded twice by the two backfill
|
|
@@ -70,8 +82,8 @@
|
|
|
70
82
|
* refused marker keeps both records and is reported (never logged)
|
|
71
83
|
* via `canonicalizeSessionsDetailed().ignoredSupersedes`.
|
|
72
84
|
*
|
|
73
|
-
* RULE ORDER: (1) → (2) → (3). The double-stub collapse
|
|
74
|
-
* supersede removal: with the reverse order a `supersedes` append deleted the
|
|
85
|
+
* RULE ORDER: (1) → (2) → (3) → (1)'s raw-id join. The double-stub collapse
|
|
86
|
+
* must run BEFORE supersede removal: with the reverse order a `supersedes` append deleted the
|
|
75
87
|
* authentic stub first, shrank the tuple group to a single member, and the
|
|
76
88
|
* synthetic phantom then survived the very session that refuted it.
|
|
77
89
|
*
|
|
@@ -158,6 +170,80 @@ function supersedeRejectReason(superseder, target) {
|
|
|
158
170
|
return 'no-shared-join-key';
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
/**
|
|
174
|
+
* `_backfill_source` prefix that `session-record-repair.mjs` stamps
|
|
175
|
+
* (`REPAIR_SOURCE`, `'repair-invalid-sessions/1004'`) on a record it repaired
|
|
176
|
+
* in place that had NO origin of its own — i.e. a REAL record. A record that
|
|
177
|
+
* already carried a synthesised origin keeps it, and the repair goes to the
|
|
178
|
+
* separate `_repair_source` field, which this module deliberately ignores
|
|
179
|
+
* (#1443 F2). Kept as a literal so this module stays a stdlib-only leaf.
|
|
180
|
+
*/
|
|
181
|
+
const REPAIR_PROVENANCE_PREFIX = 'repair-invalid-sessions/';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* True when a record is a BACKFILL STUB for rule (1)'s #1443 guard: it carries
|
|
185
|
+
* SYNTHESISED provenance (`_backfill_source`, but not repair provenance) AND
|
|
186
|
+
* measured no work (0 waves, agents and files) — whatever its `status`. A
|
|
187
|
+
* missing `status` is the norm on real records, so status cannot decide it.
|
|
188
|
+
*
|
|
189
|
+
* Repair provenance (`repair-invalid-sessions/…`) in `_backfill_source` is
|
|
190
|
+
* never a stub: the repair tool puts it there only on a record without an
|
|
191
|
+
* origin, i.e. a real record it fixed in place, not one it invented (#1443 F1 —
|
|
192
|
+
* a repaired 4-wave record lost to an older 2-wave one). A repaired STUB keeps
|
|
193
|
+
* its synthesised `_backfill_source` (`state-md-completed`, `events-jsonl`,
|
|
194
|
+
* `abandoned`) and is judged on that and on its work, whatever its
|
|
195
|
+
* `_repair_source` says (#1443 F2 — a repaired 0-wave stub shadowed a 4-wave
|
|
196
|
+
* record once the repair overwrote its origin). Legacy records repaired before
|
|
197
|
+
* F2 lost that origin irrecoverably and read as real.
|
|
198
|
+
*
|
|
199
|
+
* Exported for `scripts/autopilot.mjs`, which must not read a stub as a
|
|
200
|
+
* healthy 0-token iteration (#1457 F2). Not shared with
|
|
201
|
+
* `session-close-backfill.mjs::isSupersedableStub()` on purpose: that asks a
|
|
202
|
+
* narrower WRITER question ("may this be overwritten?" — `abandoned` only), and
|
|
203
|
+
* that module imports this one, so reusing it here would be a cycle.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} rec
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
export function isBackfillStub(rec) {
|
|
209
|
+
if (!isNonEmptyString(rec._backfill_source)) return false;
|
|
210
|
+
if (rec._backfill_source.startsWith(REPAIR_PROVENANCE_PREFIX)) return false;
|
|
211
|
+
const hasWork = (Array.isArray(rec.waves) && rec.waves.length > 0)
|
|
212
|
+
|| rec.total_waves > 0 || rec.total_agents > 0 || rec.total_files_changed > 0;
|
|
213
|
+
return !hasWork;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Rule (1), cross-id half (#1443) — drop every backfill stub that shares a
|
|
218
|
+
* non-empty `raw_session_id` with at least one non-stub. A group of stubs only
|
|
219
|
+
* (or of non-stubs only) is left intact: with no non-stub to prefer there is no
|
|
220
|
+
* evidence which record is the artefact. Mutates `byId`.
|
|
221
|
+
*
|
|
222
|
+
* LIMIT (#1457 point 9): the join assumes one raw_session_id = one physical
|
|
223
|
+
* session. A raw id can also span a CLOSED session and a later, separate
|
|
224
|
+
* abandoned tail (measured: a stub starting 4 minutes after its partner's
|
|
225
|
+
* `completed_at`, LeadPipeDACH `main-2026-09-03-session-11`) — that stub is
|
|
226
|
+
* dropped too, so the tail is invisible to every reader. No time-window check
|
|
227
|
+
* guards against it; adding one is a separate decision.
|
|
228
|
+
* @param {Map<string, object>} byId
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
function collapseStubsByRawSessionId(byId) {
|
|
232
|
+
const byRaw = new Map();
|
|
233
|
+
for (const rec of byId.values()) {
|
|
234
|
+
if (!isNonEmptyString(rec.raw_session_id)) continue;
|
|
235
|
+
const group = byRaw.get(rec.raw_session_id);
|
|
236
|
+
if (group) group.push(rec);
|
|
237
|
+
else byRaw.set(rec.raw_session_id, [rec]);
|
|
238
|
+
}
|
|
239
|
+
for (const group of byRaw.values()) {
|
|
240
|
+
if (group.length < 2 || group.every(isBackfillStub)) continue;
|
|
241
|
+
for (const rec of group) {
|
|
242
|
+
if (isBackfillStub(rec)) byId.delete(rec.session_id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
161
247
|
/**
|
|
162
248
|
* Rule (3) — collapse the systemic two-writer double stub. Mutates `byId` by
|
|
163
249
|
* deleting the synthetic twin of each qualifying pair.
|
|
@@ -285,10 +371,13 @@ function resolveSupersedes(byId, ignored) {
|
|
|
285
371
|
*
|
|
286
372
|
* Rules, applied in this order (see the module header for the measured
|
|
287
373
|
* justification of each):
|
|
288
|
-
* 1. newest-wins per `session_id` (file order is chronological)
|
|
374
|
+
* 1. newest-wins per `session_id` (file order is chronological), except
|
|
375
|
+
* that a later backfill stub never shadows an earlier non-stub (#1443);
|
|
289
376
|
* 2. two `abandoned` records with an exact, both-present
|
|
290
377
|
* `started_at` + `completed_at` tuple collapse to the non-synthetic one;
|
|
291
|
-
* 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X
|
|
378
|
+
* 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X`;
|
|
379
|
+
* 4. a backfill stub sharing a non-empty `raw_session_id` with a surviving
|
|
380
|
+
* non-stub under another `session_id` is dropped (#1443).
|
|
292
381
|
*
|
|
293
382
|
* The double-stub collapse runs BEFORE supersede removal so that a stub which
|
|
294
383
|
* is itself about to be superseded still shadows its synthetic twin — with the
|
|
@@ -325,6 +414,9 @@ export function canonicalizeSessionsDetailed(records, { keepUnidentified = false
|
|
|
325
414
|
if (keepUnidentified) unidentified.push(rec);
|
|
326
415
|
continue;
|
|
327
416
|
}
|
|
417
|
+
// #1443 — a later backfill stub never shadows an earlier non-stub.
|
|
418
|
+
const prev = byId.get(rec.session_id);
|
|
419
|
+
if (prev && isBackfillStub(rec) && !isBackfillStub(prev)) continue;
|
|
328
420
|
byId.set(rec.session_id, rec);
|
|
329
421
|
}
|
|
330
422
|
|
|
@@ -335,6 +427,9 @@ export function canonicalizeSessionsDetailed(records, { keepUnidentified = false
|
|
|
335
427
|
const ignoredSupersedes = [];
|
|
336
428
|
resolveSupersedes(byId, ignoredSupersedes);
|
|
337
429
|
|
|
430
|
+
// -- (1b) stub vs non-stub across ids, joined by raw_session_id (#1443) ----
|
|
431
|
+
collapseStubsByRawSessionId(byId);
|
|
432
|
+
|
|
338
433
|
return { records: [...byId.values(), ...unidentified], ignoredSupersedes };
|
|
339
434
|
}
|
|
340
435
|
|
|
@@ -399,7 +494,7 @@ export function countSessionsInJsonl(raw) {
|
|
|
399
494
|
* is skipped rather than aborting the whole read (same posture as the readers
|
|
400
495
|
* in `session-close-backfill.mjs` and `backfill-abandoned-sessions.mjs`).
|
|
401
496
|
*
|
|
402
|
-
* NOT EVERY RAW READ OF `sessions.jsonl` IS A GAP TO CLOSE (#1221 P2).
|
|
497
|
+
* NOT EVERY RAW READ OF `sessions.jsonl` IS A GAP TO CLOSE (#1221 P2). Four
|
|
403
498
|
* more readers bypass this module's collapse on purpose — verified via
|
|
404
499
|
* `rg -n "sessions.jsonl|SESSIONS"` against each file — and none of them
|
|
405
500
|
* should be "migrated" to `readCanonicalSessions`:
|
|
@@ -417,10 +512,6 @@ export function countSessionsInJsonl(raw) {
|
|
|
417
512
|
* window; a canonical read could drop a duplicate/superseded stub that
|
|
418
513
|
* still names a worktree, making a live worktree look unreferenced and
|
|
419
514
|
* eligible for garbage collection.
|
|
420
|
-
* - `readTailSession()` in `scripts/autopilot.mjs` parses only the LAST raw
|
|
421
|
-
* line to project the record this run's own session just appended,
|
|
422
|
-
* verbatim; canonicalizing could return a different record than the tail
|
|
423
|
-
* line if a collapse rule folded it away.
|
|
424
515
|
*
|
|
425
516
|
* @param {object} [args]
|
|
426
517
|
* @param {string} [args.repoRoot] project root; the ledger is resolved as
|
|
@@ -192,7 +192,9 @@ const CLOSING_DIAGNOSTIC_EVENTS = new Map([
|
|
|
192
192
|
// scripts/lib/autopilot/worktree-pipeline.mjs (release_failed/released only).
|
|
193
193
|
['orchestrator.session.lock.released', null],
|
|
194
194
|
['orchestrator.session.lock.release_failed', null],
|
|
195
|
-
//
|
|
195
|
+
// hooks/on-session-end.mjs — reading the lock during teardown. Since #1494 also
|
|
196
|
+
// hooks/_lib/lock-bootstrap.mjs at SessionStart (`unreadable` only); skipping that
|
|
197
|
+
// one too loses nothing, because the same start emits orchestrator.session.started.
|
|
196
198
|
['orchestrator.session.lock.read_anomaly', null],
|
|
197
199
|
// Wave lifecycle: hooks/post-tool-batch-wave-signal.mjs emits wave.completed MID-session
|
|
198
200
|
// (real activity, still counted); only the SessionEnd final-wave stamp is a closing
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sh-quote.mjs — the one POSIX shell-quoting helper (#1438).
|
|
3
|
+
*
|
|
4
|
+
* Four byte-identical private copies (cursor-install, tmux-layout/layouts,
|
|
5
|
+
* tmux-layout/tmux-shell, run-quality-gate) and one inline copy
|
|
6
|
+
* (vault-integration-watcher) were folded into this module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Quote one value as a single POSIX shell word.
|
|
11
|
+
*
|
|
12
|
+
* Wraps the value in single quotes, inside which POSIX `sh` treats EVERY byte
|
|
13
|
+
* literally — no `$` expansion, no backtick substitution, no word splitting, a
|
|
14
|
+
* newline included. The one byte that cannot appear inside single quotes is
|
|
15
|
+
* the single quote itself, and it cannot be backslash-escaped there either
|
|
16
|
+
* (`\'` inside quotes is a literal backslash that then ENDS the string); so
|
|
17
|
+
* each `'` closes the quoted run, emits an escaped `\'` outside it, and
|
|
18
|
+
* reopens: `'` → `'\''`.
|
|
19
|
+
*
|
|
20
|
+
* The value is coerced with `String(value)`, as all four former copies did —
|
|
21
|
+
* their call sites pass paths, command strings and pane commands, and a
|
|
22
|
+
* number or other primitive must quote to its string form rather than throw.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} value The value to quote; coerced via `String()`.
|
|
25
|
+
* @returns {string} e.g. `'it'\''s'` for `it's`, `''` for the empty string.
|
|
26
|
+
*/
|
|
27
|
+
export function shellQuote(value) {
|
|
28
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
29
|
+
}
|