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.
Files changed (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. 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 all matched records, skipping null/undefined values.
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
- * record with a non-null token_input or token_output value (coverage metric).
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 no record had a non-null value.
36
- * @property {number|null} total_token_output - Sum of token_output across matched records; null when no record had a non-null value.
37
- * @property {number} subagents_with_tokens - Count of distinct agent_ids that had at least one non-null token value.
38
- * @property {number} matched_records - Total count of JSONL records matched by parentSessionId (includes null-token records).
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 (scanned from EOF
25
- * backward, skipping malformed lines).
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
- * Scan `sessions.jsonl` lines from EOF backward and return the `completed_at`
97
- * of the last PARSEABLE record that carries a valid ISO timestamp. Malformed
98
- * or non-conforming lines (bad JSON, missing/invalid `completed_at`) are
99
- * skipped, not treated as fatal.
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' || typeof record.completed_at !== 'string') continue;
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
- return null;
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, `events.jsonl` is missing/empty, no foreign (pre-cutoff)
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: last sessions.jsonl entry ${ledger.iso} is ${deltaHours}h behind ` +
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.