session-orchestrator 3.17.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reads `.orchestrator/metrics/subagents.jsonl` (or a caller-supplied path),
|
|
5
5
|
* filters to a given `parent_session_id`, and sums `token_input` /
|
|
6
|
-
* `token_output` across
|
|
6
|
+
* `token_output` across the records whose token fields are TRUSTWORTHY — see
|
|
7
|
+
* § Token provenance below, which is the whole reason this module is not a
|
|
8
|
+
* two-line sum.
|
|
7
9
|
*
|
|
8
10
|
* Design notes:
|
|
9
11
|
* - Pure function — no top-level side effects, no writes.
|
|
@@ -12,7 +14,44 @@
|
|
|
12
14
|
* "session was genuinely free / cost $0".
|
|
13
15
|
* - Malformed JSONL lines are silently skipped (resilience over strictness).
|
|
14
16
|
* - `subagents_with_tokens` counts distinct agent_ids that have at least one
|
|
15
|
-
*
|
|
17
|
+
* TOKEN-BEARING record (coverage metric).
|
|
18
|
+
*
|
|
19
|
+
* ## Token provenance — why a bare Σ over token_input is wrong (#949)
|
|
20
|
+
*
|
|
21
|
+
* Two record classes in this ledger carry a `token_input` that must NEVER be
|
|
22
|
+
* summed, and both look identical to a naive reader:
|
|
23
|
+
*
|
|
24
|
+
* 1. **Pre-#949 records** (written before 2026-07-31). The producer read the
|
|
25
|
+
* PARENT session transcript instead of the subagent's own, so every stop
|
|
26
|
+
* record carries the parent's running totals. Summing them counts the parent
|
|
27
|
+
* once per subagent. `hooks/subagent-telemetry.mjs` § TOKEN-DATA PROVENANCE
|
|
28
|
+
* states the consumer obligation outright: "Consumers MUST discard token_* on
|
|
29
|
+
* every stop record written before this fix landed."
|
|
30
|
+
* 2. **Phantom stops** (#939). The harness fires `SubagentStop` for an ephemeral
|
|
31
|
+
* agent class that never fires `SubagentStart` and for which no subagent ever
|
|
32
|
+
* existed. These carry null tokens today — harmless to sum, but they inflate
|
|
33
|
+
* any coverage ratio computed against `matched_records`.
|
|
34
|
+
*
|
|
35
|
+
* `subagent_transcript_found === true` settles both at once and is the flag the
|
|
36
|
+
* producer writes for exactly this purpose. It is a sufficient cutoff on its own:
|
|
37
|
+
* the field did not exist before the #949 fix, so `=== true` excludes every
|
|
38
|
+
* pre-fix record without needing a date comparison.
|
|
39
|
+
*
|
|
40
|
+
* Measured over this repo's ledger on 2026-08-11 (3,981 records / 116 sessions):
|
|
41
|
+
* 73 sessions summed to 96,148,781 tokens that no agent ever spent — every one of
|
|
42
|
+
* them a pre-#949 parent total. Under this filter those sessions correctly report
|
|
43
|
+
* null ("no token data") instead.
|
|
44
|
+
*
|
|
45
|
+
* jq -r 'select(.event=="stop" and .subagent_transcript_found==true and .token_input==null)' \
|
|
46
|
+
* .orchestrator/metrics/subagents.jsonl | wc -l # → 0
|
|
47
|
+
*
|
|
48
|
+
* i.e. the flag never excludes a record that genuinely had tokens.
|
|
49
|
+
*
|
|
50
|
+
* FORWARD-ONLY. Session totals already written into `sessions.jsonl` by the
|
|
51
|
+
* unfiltered recipe are NOT recomputed — that ledger is append-only and the
|
|
52
|
+
* transcripts that produced the oldest records have aged out, so a rewrite would
|
|
53
|
+
* be reconstruction, not correction. Consumers comparing token totals across the
|
|
54
|
+
* 2026-08-11 boundary must treat it as a series break.
|
|
16
55
|
*
|
|
17
56
|
* @module session-token-rollup
|
|
18
57
|
*/
|
|
@@ -30,12 +69,28 @@ const DEFAULT_SUBAGENTS_PATH = '.orchestrator/metrics/subagents.jsonl';
|
|
|
30
69
|
// Public API
|
|
31
70
|
// ---------------------------------------------------------------------------
|
|
32
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Is this record's token data trustworthy enough to sum? (#949)
|
|
74
|
+
*
|
|
75
|
+
* The producer sets `subagent_transcript_found: true` only when it located and
|
|
76
|
+
* read the subagent's OWN transcript. Every other shape — a phantom stop, a
|
|
77
|
+
* start record, or any record written before the flag existed — is excluded.
|
|
78
|
+
* See the module header § Token provenance for why this single flag is a
|
|
79
|
+
* sufficient cutoff and what it costs to omit it.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} record — a parsed subagents.jsonl record
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function isTokenBearing(record) {
|
|
85
|
+
return record?.subagent_transcript_found === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
33
88
|
/**
|
|
34
89
|
* @typedef {Object} TokenRollupResult
|
|
35
|
-
* @property {number|null} total_token_input - Sum of token_input across matched records; null when
|
|
36
|
-
* @property {number|null} total_token_output - Sum of token_output across matched records; null when
|
|
37
|
-
* @property {number} subagents_with_tokens - Count of distinct agent_ids
|
|
38
|
-
* @property {number} matched_records - Total count of JSONL records matched by parentSessionId
|
|
90
|
+
* @property {number|null} total_token_input - Sum of token_input across TOKEN-BEARING matched records; null when none had a non-null value.
|
|
91
|
+
* @property {number|null} total_token_output - Sum of token_output across TOKEN-BEARING matched records; null when none had a non-null value.
|
|
92
|
+
* @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.
|
|
93
|
+
* @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.
|
|
39
94
|
*/
|
|
40
95
|
|
|
41
96
|
/**
|
|
@@ -107,6 +162,13 @@ export function rollupSessionTokens({
|
|
|
107
162
|
const agentsWithTokens = new Set();
|
|
108
163
|
|
|
109
164
|
for (const record of matched) {
|
|
165
|
+
// Provenance gate (#949) — a record whose tokens describe the PARENT
|
|
166
|
+
// transcript, or no transcript at all, contributes nothing. Skipping it
|
|
167
|
+
// entirely (rather than treating its values as 0) preserves the null
|
|
168
|
+
// sentinel: a session of only untrustworthy records reports "no data",
|
|
169
|
+
// which is true, instead of a fabricated 0.
|
|
170
|
+
if (!isTokenBearing(record)) continue;
|
|
171
|
+
|
|
110
172
|
const inp = record.token_input;
|
|
111
173
|
const out = record.token_output;
|
|
112
174
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions-integrity-banner.mjs — GitLab #958 finding 3 (visibility half).
|
|
3
|
+
*
|
|
4
|
+
* Deterministic session-start nudge for the "silently invalid ledger record"
|
|
5
|
+
* gap: `.orchestrator/metrics/sessions.jsonl` accumulates records that fail
|
|
6
|
+
* the repo's OWN `validateSession()`, and nobody is told.
|
|
7
|
+
*
|
|
8
|
+
* Why the loss is silent (W1/D4, 2026-07-31, HEAD 1f7b449): the ledger record
|
|
9
|
+
* is composed by the coordinator from a Markdown template
|
|
10
|
+
* (`skills/session-end/metrics-collection.md`) and appended directly,
|
|
11
|
+
* bypassing `scripts/emit-session.mjs` — which validates and would have
|
|
12
|
+
* exited 1. Downstream, `scripts/vault-mirror.mjs` logs the casualty as
|
|
13
|
+
* `{"action":"skipped-invalid"}` on stdout and still exits 0, so the affected
|
|
14
|
+
* sessions simply have no vault note. A prose prohibition already exists in
|
|
15
|
+
* `skills/session-end/session-metrics-write.md` and did not stop it; this
|
|
16
|
+
* banner is the instrument that makes the residue visible at session-start.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the contract used by the sibling Phase 4 banners
|
|
19
|
+
* (`scripts/lib/sessions-staleness-banner.mjs`,
|
|
20
|
+
* `scripts/lib/reconcile-nudge-banner.mjs`,
|
|
21
|
+
* `scripts/lib/vault-staleness-banner.mjs`): a single `checkXxx({repoRoot})`
|
|
22
|
+
* entry point that is COMPLETELY try/catch-wrapped (never throws) and returns
|
|
23
|
+
* either `null` (silent no-op) or `{severity:'warn'|'alert', message, ...extra}`.
|
|
24
|
+
*
|
|
25
|
+
* TWO VALIDATORS OVER ONE STORE — why this banner reports both populations.
|
|
26
|
+
* The repo has two independent notions of "a valid session record", and
|
|
27
|
+
* neither one contains the other:
|
|
28
|
+
*
|
|
29
|
+
* - `validateSession()` (`scripts/lib/session-schema/validator.mjs`) — the
|
|
30
|
+
* canonical write-path schema, enforced by `scripts/emit-session.mjs`.
|
|
31
|
+
* It treats `effectiveness` as OPTIONAL/nullable — since #964 declared
|
|
32
|
+
* explicitly in `OPTIONAL_FIELDS` (`session-schema/constants.mjs`) rather
|
|
33
|
+
* than inferable only from an `if` — so a record vault-mirror refuses to
|
|
34
|
+
* render can pass it cleanly.
|
|
35
|
+
* - vault-mirror's render path — its own, differently-shaped requirement
|
|
36
|
+
* set (`RENDERABLE_SESSION_FIELDS_V1` / `_V2` / `_V3`, exported from
|
|
37
|
+
* `scripts/lib/vault-mirror/render-sessions.mjs` and consumed by the
|
|
38
|
+
* matching `generateSessionNote*` generator, routed by
|
|
39
|
+
* `detectSessionSchema`), which REQUIRES `effectiveness` to be present.
|
|
40
|
+
* A record can therefore be schema-valid and still get no vault note.
|
|
41
|
+
* Since #964 those lists are module-scope and pinned to the write-path
|
|
42
|
+
* schema by a mechanical superset test (v1 ⊇ `REQUIRED_FIELDS`, with
|
|
43
|
+
* measured carve-outs for the write-unreachable v2/v3) — so "renderable"
|
|
44
|
+
* is now a stated strengthening of "schema-valid" rather than an
|
|
45
|
+
* undeclared second opinion. That relationship is exactly why BOTH
|
|
46
|
+
* populations still have to be reported: a superset can still exclude.
|
|
47
|
+
*
|
|
48
|
+
* Reporting one population would hide the other, so the banner names both and
|
|
49
|
+
* attributes each to its own consequence.
|
|
50
|
+
*
|
|
51
|
+
* Live ledger at HEAD 730ee9d (measured 2026-08-03; 205 parseable records,
|
|
52
|
+
* 146 of them `status: 'abandoned'`, all 205 routing to the v1 generator):
|
|
53
|
+
* `validateSession` fails 0, vault-mirror render fails 10 — and all 10 of
|
|
54
|
+
* those are abandoned, i.e. discarded by the #909 filter BEFORE the render
|
|
55
|
+
* path (see `mirrorSkipReason` below), so the banner is correctly silent
|
|
56
|
+
* here. The counts move as the ledger grows; treat them as a dated
|
|
57
|
+
* measurement, never as an invariant — re-run the probe before quoting them.
|
|
58
|
+
*
|
|
59
|
+
* The vault-mirror population is measured by CALLING THE REAL RENDER PATH in
|
|
60
|
+
* a try/catch — never by re-deriving its required-field list here. A third
|
|
61
|
+
* copy of that list would reproduce the very defect this banner reports
|
|
62
|
+
* (`.claude/rules/testing.md` § Unfaithful Double: ask the production code
|
|
63
|
+
* what it would do, do not model it). The same rule is why the #909 abandoned
|
|
64
|
+
* filter is imported from `session-schema/filters.mjs` and not re-typed.
|
|
65
|
+
*
|
|
66
|
+
* Severity is grounded in consequence, not in an arbitrary count threshold:
|
|
67
|
+
* - `warn` — records are schema-invalid, but every one of them still
|
|
68
|
+
* mirrors: the ledger is corrupt, nothing is lost yet.
|
|
69
|
+
* - `alert` — at least one record is DROPPED by vault-mirror: those
|
|
70
|
+
* sessions have no vault note right now.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately un-gated (no Session Config key, like `project-hygiene`) — a
|
|
73
|
+
* check nobody enables finds nothing.
|
|
74
|
+
*
|
|
75
|
+
* Plain-JS — no Zod dependency. Never throws. Never mutates input. No
|
|
76
|
+
* `console.*` calls (repo ESLint `no-console` rule).
|
|
77
|
+
*
|
|
78
|
+
* Cross-references:
|
|
79
|
+
* - `scripts/lib/session-schema/validator.mjs` — `validateSession`.
|
|
80
|
+
* - `scripts/lib/session-schema/filters.mjs` — `isRealSession` (#909).
|
|
81
|
+
* - `scripts/lib/vault-mirror/process.mjs` — the routing this probe mirrors.
|
|
82
|
+
* - `scripts/lib/vault-mirror/render-sessions.mjs` — the real render path,
|
|
83
|
+
* and the `RENDERABLE_SESSION_FIELDS_V{1,2,3}` lists it gates on (#964).
|
|
84
|
+
* - `scripts/lib/session-schema/constants.mjs` — `REQUIRED_FIELDS` /
|
|
85
|
+
* `OPTIONAL_FIELDS`, the write-path half of the superset relationship.
|
|
86
|
+
* - `scripts/emit-session.mjs` — the validating writer this banner points at.
|
|
87
|
+
* - `hooks/pre-bash-sessions-ledger-guard.mjs` — the write-guard half of #958.
|
|
88
|
+
* - `skills/session-start/SKILL.md` Phase 4 — banner render site.
|
|
89
|
+
* - `.claude/rules/verification-before-completion.md` — evidence-before-claims.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
93
|
+
import path from 'node:path';
|
|
94
|
+
|
|
95
|
+
import { isRealSession } from './session-schema/filters.mjs';
|
|
96
|
+
import { validateSession } from './session-schema/validator.mjs';
|
|
97
|
+
import {
|
|
98
|
+
detectSessionSchema,
|
|
99
|
+
normalizeSessionEntry,
|
|
100
|
+
generateSessionNote,
|
|
101
|
+
generateSessionNoteV2,
|
|
102
|
+
generateSessionNoteV3,
|
|
103
|
+
} from './vault-mirror/render-sessions.mjs';
|
|
104
|
+
|
|
105
|
+
/** Repo-relative path to the session ledger (one record per closed session). */
|
|
106
|
+
const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
|
|
107
|
+
|
|
108
|
+
/** Max session_ids listed inline before the message collapses to "+N more". */
|
|
109
|
+
export const MAX_LISTED_IDS = 5;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read the ledger's non-empty lines. Returns `null` when the file is absent
|
|
113
|
+
* or unreadable, `[]` when it exists but has no non-empty lines. Never throws.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} filePath
|
|
116
|
+
* @returns {string[]|null}
|
|
117
|
+
*/
|
|
118
|
+
function readJsonlLines(filePath) {
|
|
119
|
+
if (!existsSync(filePath)) return null;
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = readFileSync(filePath, 'utf8');
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return raw.split('\n').filter((line) => line.length > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Ask the REAL vault-mirror render path whether it would skip this entry AS
|
|
131
|
+
* `skipped-invalid`. Reproduces `scripts/lib/vault-mirror/process.mjs`
|
|
132
|
+
* `processSession()` routing in its production ORDER: normalize aliases (#635)
|
|
133
|
+
* → detect schema → select that schema's generator → **#909 abandoned filter**
|
|
134
|
+
* → invoke the generator. A throw from the generator is precisely what
|
|
135
|
+
* `scripts/vault-mirror.mjs` catches and reports as
|
|
136
|
+
* `{"action":"skipped-invalid"}`.
|
|
137
|
+
*
|
|
138
|
+
* The abandoned filter is load-bearing, not decoration. `process.mjs:485`
|
|
139
|
+
* returns `skipped-abandoned` for `!isRealSession(entry)` BEFORE the generator
|
|
140
|
+
* is ever called, so an abandoned record can never reach the render path and
|
|
141
|
+
* can never become `skipped-invalid`. Omitting it made this probe measure a
|
|
142
|
+
* population production never reaches: on this repo's ledger (measured
|
|
143
|
+
* 2026-08-03, HEAD 730ee9d, 205 records) **all 10** reported "dropped" records
|
|
144
|
+
* were `status: 'abandoned'` — a 100 %-false 🚨 on every session start, with a
|
|
145
|
+
* prescribed remedy (re-emit) that produces no vault note for such a record.
|
|
146
|
+
* The predicate is IMPORTED from `session-schema/filters.mjs` rather than
|
|
147
|
+
* re-derived: that rule already has exactly two homes (the filter module and
|
|
148
|
+
* `process.mjs`'s call site), and a hand-copied `status !== 'abandoned'` here
|
|
149
|
+
* would make a third — the divergence class this banner exists to report.
|
|
150
|
+
*
|
|
151
|
+
* The generator is invoked with one argument where production passes
|
|
152
|
+
* `generator(entry, { repoNs })` (`process.mjs:516` — the SESSION render whose
|
|
153
|
+
* throw becomes `skipped-invalid`; the 3-argument `generator(entry, slug,
|
|
154
|
+
* generatorOpts)` calls elsewhere in that file belong to the separate
|
|
155
|
+
* LEARNINGS path and are not this probe's population). All three generators
|
|
156
|
+
* declare `options = {}` and use `repoNs` only for a frontmatter field, never
|
|
157
|
+
* to decide whether to throw — so the omission cannot change the render/skip
|
|
158
|
+
* verdict this probe reads. Resolving a real `repoNs` here would cost a git
|
|
159
|
+
* subprocess per record for a value the verdict ignores.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} record
|
|
162
|
+
* @returns {string|null} the skip reason, or `null` when production would
|
|
163
|
+
* either render it or discard it for a reason OTHER than invalidity.
|
|
164
|
+
*/
|
|
165
|
+
function mirrorSkipReason(record) {
|
|
166
|
+
try {
|
|
167
|
+
const entry = normalizeSessionEntry(record);
|
|
168
|
+
const schema = detectSessionSchema(entry);
|
|
169
|
+
const generator =
|
|
170
|
+
schema === 'v3' ? generateSessionNoteV3 : schema === 'v2' ? generateSessionNoteV2 : generateSessionNote;
|
|
171
|
+
// #909 filter — production returns `skipped-abandoned` here and never
|
|
172
|
+
// reaches the generator. Not a render failure; not this banner's signal.
|
|
173
|
+
if (!isRealSession(entry)) return null;
|
|
174
|
+
generator(entry);
|
|
175
|
+
return null;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
return err && typeof err.message === 'string' ? err.message : 'unknown render failure';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Render `<id>, <id>, … (+N more)` for a bounded id list.
|
|
183
|
+
*
|
|
184
|
+
* @param {{sessionId: string}[]} rows
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
function formatIds(rows) {
|
|
188
|
+
const ids = rows.map((r) => r.sessionId);
|
|
189
|
+
const shown = ids.slice(0, MAX_LISTED_IDS).join(', ');
|
|
190
|
+
const rest = ids.length - MAX_LISTED_IDS;
|
|
191
|
+
return rest > 0 ? `${shown} (+${rest} more)` : shown;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check sessions-ledger schema integrity and produce a session-start banner.
|
|
196
|
+
*
|
|
197
|
+
* Silent (`null`) when: `sessions.jsonl` is missing, empty, unreadable, holds
|
|
198
|
+
* no parseable JSON line at all, or every parseable record satisfies BOTH
|
|
199
|
+
* `validateSession()` and the vault-mirror render path. An `abandoned` record
|
|
200
|
+
* satisfies the render half vacuously — production discards it as
|
|
201
|
+
* `skipped-abandoned` before rendering, so it can never be `skipped-invalid`
|
|
202
|
+
* and is never counted in `mirrorSkipped`. Unparseable lines are
|
|
203
|
+
* SKIPPED, not reported — this probe judges schema integrity, not file
|
|
204
|
+
* corruption (matching the sibling banners' malformed-line handling). Never
|
|
205
|
+
* throws.
|
|
206
|
+
*
|
|
207
|
+
* @param {{repoRoot: string}} opts
|
|
208
|
+
* - `repoRoot`: REQUIRED absolute path to the repo root.
|
|
209
|
+
* @returns {null | {
|
|
210
|
+
* severity: 'warn'|'alert',
|
|
211
|
+
* message: string,
|
|
212
|
+
* total: number,
|
|
213
|
+
* schemaInvalid: {line: number, sessionId: string, error: string}[],
|
|
214
|
+
* mirrorSkipped: {line: number, sessionId: string, error: string}[],
|
|
215
|
+
* }}
|
|
216
|
+
*/
|
|
217
|
+
export function checkSessionsIntegrity({ repoRoot } = {}) {
|
|
218
|
+
try {
|
|
219
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
220
|
+
|
|
221
|
+
const lines = readJsonlLines(path.join(repoRoot, SESSIONS_PATH));
|
|
222
|
+
if (lines === null || lines.length === 0) return null;
|
|
223
|
+
|
|
224
|
+
/** @type {{line: number, sessionId: string, error: string}[]} */
|
|
225
|
+
const schemaInvalid = [];
|
|
226
|
+
/** @type {{line: number, sessionId: string, error: string}[]} */
|
|
227
|
+
const mirrorSkipped = [];
|
|
228
|
+
let total = 0;
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
let record;
|
|
232
|
+
try {
|
|
233
|
+
record = JSON.parse(lines[i]);
|
|
234
|
+
} catch {
|
|
235
|
+
continue; // unparseable line — not this probe's concern
|
|
236
|
+
}
|
|
237
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
|
238
|
+
total += 1;
|
|
239
|
+
|
|
240
|
+
const sessionId =
|
|
241
|
+
typeof record.session_id === 'string' && record.session_id.length > 0
|
|
242
|
+
? record.session_id
|
|
243
|
+
: '<no session_id>';
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
validateSession(record);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
schemaInvalid.push({
|
|
249
|
+
line: i + 1,
|
|
250
|
+
sessionId,
|
|
251
|
+
error: err && typeof err.message === 'string' ? err.message : 'unknown validation failure',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const skipReason = mirrorSkipReason(record);
|
|
256
|
+
if (skipReason !== null) {
|
|
257
|
+
mirrorSkipped.push({ line: i + 1, sessionId, error: skipReason });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (total === 0) return null;
|
|
262
|
+
if (schemaInvalid.length === 0 && mirrorSkipped.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
// alert only when records are ACTUALLY being dropped by vault-mirror —
|
|
265
|
+
// those sessions have no vault note right now. A schema-invalid record
|
|
266
|
+
// that still mirrors is corruption without loss: warn.
|
|
267
|
+
const severity = mirrorSkipped.length > 0 ? 'alert' : 'warn';
|
|
268
|
+
|
|
269
|
+
const parts = [];
|
|
270
|
+
if (schemaInvalid.length > 0) {
|
|
271
|
+
parts.push(
|
|
272
|
+
`${schemaInvalid.length} of ${total} records fail validateSession (${formatIds(schemaInvalid)})`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (mirrorSkipped.length > 0) {
|
|
276
|
+
parts.push(
|
|
277
|
+
`${mirrorSkipped.length} are dropped by vault-mirror as skipped-invalid — those sessions have NO vault note (${formatIds(mirrorSkipped)})`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const base =
|
|
282
|
+
`sessions-integrity: ${parts.join('; ')} — records were appended without passing ` +
|
|
283
|
+
`scripts/emit-session.mjs (which validates and would have refused). ` +
|
|
284
|
+
`Inspect: node -e with validateSession from scripts/lib/session-schema/validator.mjs; ` +
|
|
285
|
+
`re-emit affected records via scripts/emit-session.mjs`;
|
|
286
|
+
|
|
287
|
+
const message = severity === 'alert' ? `🚨 ${base}.` : `⚠ ${base}.`;
|
|
288
|
+
|
|
289
|
+
return { severity, message, total, schemaInvalid, mirrorSkipped };
|
|
290
|
+
} catch {
|
|
291
|
+
// Defensive catch-all — banner must never throw.
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
* by the time this probe fires. Gating on "no live lock" would make the
|
|
21
21
|
* banner structurally silent forever. Instead:
|
|
22
22
|
*
|
|
23
|
-
* - `lastLedgerAt` = `completed_at` of the last PARSEABLE
|
|
24
|
-
* `sessions.jsonl` record
|
|
25
|
-
* backward, skipping malformed
|
|
23
|
+
* - `lastLedgerAt` = `completed_at` of the last PARSEABLE, GENUINE
|
|
24
|
+
* (non-backfill-stub) `sessions.jsonl` record
|
|
25
|
+
* (scanned from EOF backward, skipping malformed
|
|
26
|
+
* lines) — see "Backfill-stub self-erasure fix"
|
|
27
|
+
* below for the stub-skip logic and its fallback.
|
|
26
28
|
* - `cutoff` = the CURRENT session's `session.lock`
|
|
27
29
|
* `started_at` (via `readLock()`); when no lock is
|
|
28
30
|
* readable, `cutoff = now` (all events count).
|
|
@@ -36,6 +38,45 @@
|
|
|
36
38
|
* meaningful when > 0 (foreign activity happened
|
|
37
39
|
* AFTER the last ledger entry).
|
|
38
40
|
*
|
|
41
|
+
* Backfill-stub self-erasure fix — the anchor axis: a backfill-produced
|
|
42
|
+
* `sessions.jsonl` record (`_backfill_source` / `status: 'abandoned'`, see
|
|
43
|
+
* `session-close-backfill.mjs` `synthesizeRecord()`) sets `completed_at =
|
|
44
|
+
* max(started_at, lastTerminalMs ?? nowMs)`. When the abandoned session
|
|
45
|
+
* never emitted a STOPPED/ENDED event — the COMMON case, since that is
|
|
46
|
+
* *why* it is "abandoned" — `completed_at` silently becomes the BACKFILL
|
|
47
|
+
* RUN's own wall-clock instant, not a measurement of when the session
|
|
48
|
+
* actually ended. Anchoring `lastLedgerEntry()` on that value means a
|
|
49
|
+
* backfill run can retroactively erase a multi-day staleness gap just by
|
|
50
|
+
* writing a stub today (observed: a 92.5h gap to the last GENUINE record
|
|
51
|
+
* collapsed to 0.6h the moment a backfill stub landed).
|
|
52
|
+
*
|
|
53
|
+
* Two axes were available to fix this: (a) skip stub records when scanning
|
|
54
|
+
* for the ledger anchor, keeping `completed_at` as the anchor field; or (b)
|
|
55
|
+
* blanket-switch the anchor field to `started_at` for every record. (b) was
|
|
56
|
+
* rejected — for a GENUINE multi-hour session, `started_at` sits hours
|
|
57
|
+
* before `completed_at`, so switching the anchor field universally would
|
|
58
|
+
* inflate `deltaHours` for perfectly healthy, promptly-closed sessions
|
|
59
|
+
* (a session's own mid-session events would newly count as "after" the
|
|
60
|
+
* anchor), reintroducing false positives on the opposite side. (a) is
|
|
61
|
+
* chosen: `lastLedgerEntry()` skips any record `isBackfillStub()` flags and
|
|
62
|
+
* keeps searching backward for a GENUINE `completed_at`. Stub recognition
|
|
63
|
+
* uses EITHER marker (OR, not AND) deliberately — both are set by the same
|
|
64
|
+
* producer today, but requiring both would silently stop matching the day a
|
|
65
|
+
* future backfill variant drops one of them while keeping the other; OR
|
|
66
|
+
* degrades gracefully (still catches it), AND does not.
|
|
67
|
+
*
|
|
68
|
+
* All-stub fallback (deliberately NOT null): when NO genuine record exists
|
|
69
|
+
* anywhere in the file — every record is a backfill stub — this is a
|
|
70
|
+
* STRONGER signal of the close-through gap than an ordinary stale ledger,
|
|
71
|
+
* not a weaker one: no session has EVER genuinely closed. The module's
|
|
72
|
+
* usual fail-quiet convention (null on missing/empty/ambiguous input) does
|
|
73
|
+
* not extend to "we have data but all of it is synthetic" — that state IS
|
|
74
|
+
* the failure this banner exists to catch, so `lastLedgerEntry()` instead
|
|
75
|
+
* anchors on the newest stub's `started_at` (grounded in the real
|
|
76
|
+
* `orchestrator.session.started` event in the common case — see
|
|
77
|
+
* `synthesizeRecord()` — unlike that same stub's fabricated `completed_at`)
|
|
78
|
+
* and flags the result `stubFallback: true` for callers that want to say so.
|
|
79
|
+
*
|
|
39
80
|
* Severity: warn above `2 × DEFAULT_TTL_HOURS` (8h, imported from
|
|
40
81
|
* `session-lock.mjs` rather than duplicated), alert above 24h.
|
|
41
82
|
*
|
|
@@ -60,6 +101,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
60
101
|
import path from 'node:path';
|
|
61
102
|
|
|
62
103
|
import { readLock, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
104
|
+
import { isRealSession } from './session-schema/filters.mjs';
|
|
63
105
|
|
|
64
106
|
/** Repo-relative path to the session ledger (one record per closed session). */
|
|
65
107
|
const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
|
|
@@ -93,15 +135,51 @@ function readJsonlLines(filePath) {
|
|
|
93
135
|
}
|
|
94
136
|
|
|
95
137
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
138
|
+
* True when a `sessions.jsonl` record's `completed_at` was SYNTHESIZED by
|
|
139
|
+
* the backfill engine (`scripts/lib/session-close-backfill.mjs`
|
|
140
|
+
* `synthesizeRecord()`) rather than measured at real session-close time —
|
|
141
|
+
* see the module-header "Backfill-stub self-erasure fix" section above for
|
|
142
|
+
* the full reasoning behind the OR (not AND) combination of the two markers.
|
|
143
|
+
*
|
|
144
|
+
* Reuses `isRealSession()` from `./session-schema/filters.mjs` — its own doc
|
|
145
|
+
* names `status: 'abandoned'` "the canonical marker" for exactly this phantom
|
|
146
|
+
* class, so this is the SAME predicate every other real/phantom-aware
|
|
147
|
+
* consumer in this repo already relies on, not a hand-rolled duplicate of it.
|
|
148
|
+
* `_backfill_source` is layered on top as the second, independent signal.
|
|
149
|
+
* Caller guarantees `record` is already a non-null object (see
|
|
150
|
+
* `lastLedgerEntry()`'s guard above the call site).
|
|
151
|
+
*
|
|
152
|
+
* @param {object} record
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
function isBackfillStub(record) {
|
|
156
|
+
if (!isRealSession(record)) return true;
|
|
157
|
+
return typeof record._backfill_source === 'string' && record._backfill_source.length > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scan `sessions.jsonl` lines from EOF backward and return the anchor
|
|
162
|
+
* instant to measure ledger staleness against. Malformed or non-conforming
|
|
163
|
+
* lines (bad JSON, non-object) are skipped, not treated as fatal.
|
|
164
|
+
*
|
|
165
|
+
* Two passes, in priority order:
|
|
166
|
+
* 1. GENUINE — the last (by position) record that is NOT `isBackfillStub()`
|
|
167
|
+
* and carries a valid `completed_at`. This is the trustworthy case:
|
|
168
|
+
* `completed_at` was written by the real session-end path.
|
|
169
|
+
* 2. STUB-FALLBACK — only reached when the loop above finds no genuine
|
|
170
|
+
* record at all (every record is a stub, or the file has none). Anchors
|
|
171
|
+
* on the newest-by-position stub's `started_at` instead of its
|
|
172
|
+
* `completed_at` — see the module-header design note for why. Flags
|
|
173
|
+
* `stubFallback: true` on the returned object; omitted (`undefined`) on
|
|
174
|
+
* the genuine path so existing callers checking `ledger.ms`/`ledger.iso`
|
|
175
|
+
* see no behavioural change.
|
|
100
176
|
*
|
|
101
177
|
* @param {string[]} lines
|
|
102
|
-
* @returns {{iso: string, ms: number}|null}
|
|
178
|
+
* @returns {{iso: string, ms: number, stubFallback?: true}|null}
|
|
103
179
|
*/
|
|
104
180
|
function lastLedgerEntry(lines) {
|
|
181
|
+
let newestStub = null; // newest-by-position stub with a parseable started_at
|
|
182
|
+
|
|
105
183
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
106
184
|
let record;
|
|
107
185
|
try {
|
|
@@ -109,12 +187,28 @@ function lastLedgerEntry(lines) {
|
|
|
109
187
|
} catch {
|
|
110
188
|
continue;
|
|
111
189
|
}
|
|
112
|
-
if (!record || typeof record !== 'object'
|
|
190
|
+
if (!record || typeof record !== 'object') continue;
|
|
191
|
+
|
|
192
|
+
if (isBackfillStub(record)) {
|
|
193
|
+
// Never anchor on a stub's completed_at (it may be the backfill run's
|
|
194
|
+
// own wall-clock) — remember it only as a fallback candidate, and only
|
|
195
|
+
// the first (nearest-EOF, i.e. newest-by-position) one seen.
|
|
196
|
+
if (newestStub === null && typeof record.started_at === 'string') {
|
|
197
|
+
const startedMs = Date.parse(record.started_at);
|
|
198
|
+
if (Number.isFinite(startedMs)) newestStub = { iso: record.started_at, ms: startedMs };
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof record.completed_at !== 'string') continue;
|
|
113
204
|
const ms = Date.parse(record.completed_at);
|
|
114
205
|
if (!Number.isFinite(ms)) continue;
|
|
115
206
|
return { iso: record.completed_at, ms };
|
|
116
207
|
}
|
|
117
|
-
|
|
208
|
+
|
|
209
|
+
// No genuine record anywhere — see module-header "All-stub fallback" note:
|
|
210
|
+
// this is a stronger alarm signal than null, not a null-worthy absence.
|
|
211
|
+
return newestStub ? { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true } : null;
|
|
118
212
|
}
|
|
119
213
|
|
|
120
214
|
/**
|
|
@@ -182,10 +276,16 @@ function resolveCutoffMs(repoRoot, nowMs) {
|
|
|
182
276
|
* Check sessions-ledger staleness and produce a session-start banner.
|
|
183
277
|
*
|
|
184
278
|
* Silent (`null`) when: `sessions.jsonl` is missing/empty/entirely
|
|
185
|
-
* unparseable
|
|
279
|
+
* unparseable-or-anchor-less (see `lastLedgerEntry()` — this now also
|
|
280
|
+
* covers "every record is a backfill stub with no parseable `started_at`
|
|
281
|
+
* anywhere"), `events.jsonl` is missing/empty, no foreign (pre-cutoff)
|
|
186
282
|
* event exists, the foreign event is not after the last ledger entry, or the
|
|
187
283
|
* resulting gap is under the warn threshold. Never throws.
|
|
188
284
|
*
|
|
285
|
+
* When the anchor comes from the all-stub fallback (`ledger.stubFallback`),
|
|
286
|
+
* `lastLedgerAt` is a STUB's `started_at`, not a genuine `completed_at` — the
|
|
287
|
+
* message says so explicitly rather than implying a real close was measured.
|
|
288
|
+
*
|
|
189
289
|
* @param {{repoRoot: string, now?: number}} opts
|
|
190
290
|
* - `repoRoot`: REQUIRED absolute path to the repo root.
|
|
191
291
|
* - `now`: optional injectable clock (epoch ms); defaults to `Date.now()`.
|
|
@@ -196,6 +296,7 @@ function resolveCutoffMs(repoRoot, nowMs) {
|
|
|
196
296
|
* lastLedgerAt: string,
|
|
197
297
|
* lastForeignEventAt: string,
|
|
198
298
|
* deltaHours: number,
|
|
299
|
+
* stubFallback?: true,
|
|
199
300
|
* }}
|
|
200
301
|
*/
|
|
201
302
|
export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
@@ -226,8 +327,15 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
226
327
|
|
|
227
328
|
const severity = deltaHours > ALERT_THRESHOLD_HOURS ? 'alert' : 'warn';
|
|
228
329
|
|
|
330
|
+
// stubFallback (see lastLedgerEntry()): every sessions.jsonl record is a
|
|
331
|
+
// backfill stub — ledger.iso is a STUB's started_at, not a measured
|
|
332
|
+
// completed_at. Say so explicitly rather than implying a real close.
|
|
333
|
+
const ledgerDescription = ledger.stubFallback
|
|
334
|
+
? `last sessions.jsonl entry is backfill-stub-only — newest stub started_at ${ledger.iso}`
|
|
335
|
+
: `last sessions.jsonl entry ${ledger.iso}`;
|
|
336
|
+
|
|
229
337
|
const base =
|
|
230
|
-
`sessions-staleness:
|
|
338
|
+
`sessions-staleness: ${ledgerDescription} is ${deltaHours}h behind ` +
|
|
231
339
|
`pre-session events.jsonl activity ${foreign.iso} — possible close-through gap ` +
|
|
232
340
|
`(sessions ended without a ledger record; run node scripts/backfill-abandoned-sessions.mjs --dry-run)`;
|
|
233
341
|
|
|
@@ -239,6 +347,7 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
|
|
|
239
347
|
lastLedgerAt: ledger.iso,
|
|
240
348
|
lastForeignEventAt: foreign.iso,
|
|
241
349
|
deltaHours,
|
|
350
|
+
...(ledger.stubFallback ? { stubFallback: true } : {}),
|
|
242
351
|
};
|
|
243
352
|
} catch {
|
|
244
353
|
// Defensive catch-all — banner must never throw.
|