session-orchestrator 3.22.0 → 3.24.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 (316) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,446 @@
1
+ /**
2
+ * sessions-canonical.mjs — one record per physical session (#1167).
3
+ *
4
+ * `.orchestrator/metrics/sessions.jsonl` is APPEND-ONLY by design: nothing is
5
+ * ever rewritten in place, so the same physical session can appear more than
6
+ * once. A consumer that treats "one line = one session" therefore over-counts,
7
+ * and every duration/effectiveness aggregate computed from the raw file is
8
+ * silently wrong by however many duplicates happen to sit in its window.
9
+ *
10
+ * This module is the READ-side collapse. It never repairs the file (see § 3).
11
+ *
12
+ * ── THE THREE RULES, AND WHAT MEASURED THEM ─────────────────────────────────
13
+ *
14
+ * (1) NEWEST-WINS PER `session_id`.
15
+ * File order is chronological, so the LAST record carrying an id is the
16
+ * current one. This is the same reading rule
17
+ * `session-close-backfill.mjs::classifyExisting()` already applies (it
18
+ * takes `matches[matches.length - 1]`); this module generalises it to the
19
+ * whole file. Measured 2026-09-02 @ c3ab480 over 286 records: one id
20
+ * (2026-05-10) carries a byte-identical duplicate LINE — an older
21
+ * collision class than (3), and rule (1) alone resolves it.
22
+ *
23
+ * (2) NARROW COLLAPSE OF THE SYSTEMIC DOUBLE-STUB CLASS.
24
+ * Two `abandoned` records with an EXACT `started_at` + `completed_at`
25
+ * tuple match are one physical session recorded twice by the two backfill
26
+ * writers: `hooks/on-session-end.mjs` resolves the semantic id from
27
+ * `current-session.json` and writes `main-YYYY-MM-DD-session-N`, while
28
+ * `scripts/backfill-abandoned-sessions.mjs` could resolve a semantic id
29
+ * ONLY via `orchestrator.session.lock.acquired` — a session that lost the
30
+ * lock-acquire race has no such event, so it fell through to the synthetic
31
+ * mint (`<branch>-<date>-abandoned-<sha8>`, `_synthetic_session_id: true`)
32
+ * and wrote a SECOND stub for the same session. The join-back was
33
+ * impossible because `raw_session_id` is null on 286/286 records
34
+ * (`jq -s '[.[]|select(.raw_session_id != null)]|length'` → 0, measured
35
+ * 2026-09-02 @ c3ab480), so the two records share no key at all — only
36
+ * their millisecond-identical timestamps.
37
+ * Measured population: 8 such pairs over 6 weeks (16 records), via
38
+ * `jq -r '[.started_at,.completed_at,.status]|@tsv' … | sort | uniq -d`.
39
+ * The NON-synthetic record survives; the synthetic mint is the artefact.
40
+ *
41
+ * Deliberately narrow. The collapse requires BOTH records to be
42
+ * `status: 'abandoned'` and BOTH timestamps to be present and equal.
43
+ * `started_at` alone is NOT enough (two real sessions can start in the
44
+ * same millisecond of a re-fire), and `completed` records are never
45
+ * collapsed (an authoritative record is a truth claim about itself, never
46
+ * an artefact of a second writer).
47
+ *
48
+ * (3) AN ATTESTABLE `supersedes: X` REMOVES record X.
49
+ * The #1068 AC3/AC4 supersede path appends an authoritative `completed`
50
+ * record carrying a forward pointer to the backfilled `abandoned` stub it
51
+ * refutes. The stub is kept on disk verbatim (AC4 — forensic provenance);
52
+ * a canonical READER must drop it, or the same session is counted as both
53
+ * abandoned and completed.
54
+ *
55
+ * Two constraints, both measured defects of the first implementation:
56
+ * - ORDER-INDEPENDENT. A record is dropped iff some SURVIVING record
57
+ * supersedes it (a fixpoint over the supersede graph; cycles broken by
58
+ * keeping the newest member). Deleting in file order made a chain
59
+ * `C → B → A` resolve to `{C, A}` or `{C}` depending on the
60
+ * permutation the appends happened to land in, and a mutual pair
61
+ * resolved by insertion order.
62
+ * - ATTESTABLE ONLY. The marker is honoured only when the target is not
63
+ * authoritative (`status` `abandoned`, or absent on a legacy stub —
64
+ * never `completed`) AND the two records share a join key (equal
65
+ * `raw_session_id`, or byte-equal `started_at` — the shape
66
+ * `session-close-backfill.mjs::synthesizeRecord()` emits, since stub
67
+ * and superseder are synthesized from the same gathered events).
68
+ * Without that constraint ONE appended line could delete ANY id from
69
+ * EVERY reader of this module, the armed autonomy verdict included. A
70
+ * refused marker keeps both records and is reported (never logged)
71
+ * via `canonicalizeSessionsDetailed().ignoredSupersedes`.
72
+ *
73
+ * RULE ORDER: (1) → (2) → (3). The double-stub collapse must run BEFORE
74
+ * supersede removal: with the reverse order a `supersedes` append deleted the
75
+ * authentic stub first, shrank the tuple group to a single member, and the
76
+ * synthetic phantom then survived the very session that refuted it.
77
+ *
78
+ * ── WHAT THIS MODULE DOES NOT DO ────────────────────────────────────────────
79
+ * - It never writes. The 8 historical pairs stay on disk; the ledger is
80
+ * append-only and the duplicates are their own provenance.
81
+ * - It is not a phantom filter. `status: 'abandoned'` records SURVIVE here —
82
+ * dropping them is `session-schema/filters.mjs`'s job
83
+ * (`isRealSession` / `filterRealSessions` / `tailRealSessions`), and the
84
+ * two compose: canonicalize first, then filter.
85
+ *
86
+ * Plain Node ESM. Named exports. `canonicalizeSessions` is pure; only
87
+ * `readCanonicalSessions` touches the filesystem (sync, `readFileSync`).
88
+ */
89
+
90
+ import fs from 'node:fs';
91
+ import path from 'node:path';
92
+
93
+ const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
94
+
95
+ /**
96
+ * True when the value is a usable record object (not null, not an array).
97
+ * @param {unknown} v
98
+ * @returns {boolean}
99
+ */
100
+ function isRecordObject(v) {
101
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
102
+ }
103
+
104
+ /** Non-empty string guard — `''` is never a usable id or timestamp. */
105
+ function isNonEmptyString(v) {
106
+ return typeof v === 'string' && v.length > 0;
107
+ }
108
+
109
+ /**
110
+ * The timestamp a record is ordered by when a supersede CYCLE has to be broken:
111
+ * `completed_at` when present, else `started_at`, else `''` (sorts last).
112
+ * ISO-8601 strings compare lexicographically, so no Date parsing is needed.
113
+ * @param {object} rec
114
+ * @returns {string}
115
+ */
116
+ function cycleOrderTimestamp(rec) {
117
+ if (isNonEmptyString(rec.completed_at)) return rec.completed_at;
118
+ if (isNonEmptyString(rec.started_at)) return rec.started_at;
119
+ return '';
120
+ }
121
+
122
+ /**
123
+ * True when `superseder`'s `supersedes` marker is ATTESTABLE against `target`.
124
+ *
125
+ * `supersedes` is a forward pointer inside an append-only file that anyone (or
126
+ * any buggy writer) can append a line to, and a reader that obeys it blindly
127
+ * lets a single appended line delete ANY id from EVERY consumer — including the
128
+ * armed autonomy verdict. So the marker is honoured only for the shape the
129
+ * #1068 writer actually produces: the stub it refutes is never an AUTHORITATIVE
130
+ * record — its `status` is `abandoned`, or absent/null on a legacy stub, but
131
+ * never any other declared status (a `completed` record is a truth claim about
132
+ * itself and can never be deleted by an appended pointer) — and both records
133
+ * were synthesized from the SAME gathered events, hence share an attestable
134
+ * join key —
135
+ * - equal non-empty `raw_session_id` (the #1167 harness-uuid join), or
136
+ * - byte-equal non-empty `started_at` (`session-close-backfill.mjs`
137
+ * `synthesizeRecord()` derives `startedIso` from the same event set for the
138
+ * stub and for the record that supersedes it).
139
+ * Anything else is a data-integrity anomaly: BOTH records are kept and the
140
+ * marker is reported via `canonicalizeSessionsDetailed().ignoredSupersedes`.
141
+ *
142
+ * @param {object} superseder
143
+ * @param {object} target
144
+ * @returns {string|null} null when the marker is valid, else the reject reason
145
+ */
146
+ function supersedeRejectReason(superseder, target) {
147
+ // Absent/null `status` is a legacy stub, not an authoritative record; any
148
+ // OTHER declared status (`completed` above all) is untouchable.
149
+ if (isNonEmptyString(target.status) && target.status !== 'abandoned') {
150
+ return 'target-not-abandoned';
151
+ }
152
+ const a = superseder.raw_session_id;
153
+ const b = target.raw_session_id;
154
+ if (isNonEmptyString(a) && isNonEmptyString(b) && a === b) return null;
155
+ if (isNonEmptyString(superseder.started_at) && superseder.started_at === target.started_at) {
156
+ return null;
157
+ }
158
+ return 'no-shared-join-key';
159
+ }
160
+
161
+ /**
162
+ * Rule (3) — collapse the systemic two-writer double stub. Mutates `byId` by
163
+ * deleting the synthetic twin of each qualifying pair.
164
+ * @param {Map<string, object>} byId
165
+ * @returns {void}
166
+ */
167
+ function collapseAbandonedTuples(byId) {
168
+ // Group ONLY the records eligible for the systemic double-stub class; every
169
+ // other record bypasses this pass entirely and can never be dropped by it.
170
+ const byTuple = new Map();
171
+ for (const rec of byId.values()) {
172
+ if (rec.status !== 'abandoned') continue;
173
+ if (!isNonEmptyString(rec.started_at) || !isNonEmptyString(rec.completed_at)) continue;
174
+ const key = `${rec.started_at} ${rec.completed_at}`;
175
+ const group = byTuple.get(key);
176
+ if (group) group.push(rec);
177
+ else byTuple.set(key, [rec]);
178
+ }
179
+ for (const group of byTuple.values()) {
180
+ if (group.length < 2) continue;
181
+ const authentic = group.filter((r) => r._synthetic_session_id !== true);
182
+ // All-synthetic (or all-authentic) groups are left intact: with no
183
+ // non-synthetic record to prefer there is no evidence about WHICH one is
184
+ // the artefact, and guessing would delete a session nobody can recover.
185
+ if (authentic.length === 0 || authentic.length === group.length) continue;
186
+ for (const rec of group) {
187
+ if (rec._synthetic_session_id === true) byId.delete(rec.session_id);
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Rule (2) — order-independent supersede resolution. Mutates `byId` by deleting
194
+ * every record that a SURVIVING record supersedes, and appends every rejected
195
+ * marker to `ignored`.
196
+ *
197
+ * A record is dropped iff some record that itself survives supersedes it; the
198
+ * marking is a fixpoint over the supersede graph, so it depends on the EDGES
199
+ * only, never on the order the records appear in the file. A chain
200
+ * `C → B → A` therefore always resolves to `{C, A}` (B is dropped by the
201
+ * surviving C, so B's own marker no longer removes A).
202
+ *
203
+ * A cycle (`X → Y`, `Y → X`) has no fixpoint; it is broken deterministically by
204
+ * keeping the NEWEST member (`completed_at ?? started_at`, ties by ascending
205
+ * `session_id`) and re-running the propagation.
206
+ *
207
+ * @param {Map<string, object>} byId
208
+ * @param {Array<{by: string, target: string, reason: string}>} ignored
209
+ * @returns {void}
210
+ */
211
+ function resolveSupersedes(byId, ignored) {
212
+ /** targetId → Set of ids of records that validly supersede it. */
213
+ const supersededBy = new Map();
214
+ for (const rec of byId.values()) {
215
+ const target = rec.supersedes;
216
+ if (!isNonEmptyString(target) || target === rec.session_id) continue;
217
+ const targetRec = byId.get(target);
218
+ // A marker pointing at an id that is not present removes nothing; it is not
219
+ // an anomaly either (the target may legitimately have been collapsed by
220
+ // rule 3 first, or simply predate this window of the ledger).
221
+ if (!targetRec) continue;
222
+ const reason = supersedeRejectReason(rec, targetRec);
223
+ if (reason !== null) {
224
+ ignored.push({ by: rec.session_id, target, reason });
225
+ continue;
226
+ }
227
+ const set = supersededBy.get(target);
228
+ if (set) set.add(rec.session_id);
229
+ else supersededBy.set(target, new Set([rec.session_id]));
230
+ }
231
+ if (supersededBy.size === 0) return;
232
+
233
+ const ids = [...byId.keys()];
234
+ /** id → 'alive' | 'dead'; absent = not yet decided. */
235
+ const state = new Map();
236
+ for (;;) {
237
+ let changed = true;
238
+ while (changed) {
239
+ changed = false;
240
+ for (const id of ids) {
241
+ if (state.has(id)) continue;
242
+ const sup = supersededBy.get(id);
243
+ if (!sup || sup.size === 0) {
244
+ state.set(id, 'alive');
245
+ changed = true;
246
+ continue;
247
+ }
248
+ let anyAlive = false;
249
+ let allDead = true;
250
+ for (const s of sup) {
251
+ const st = state.get(s);
252
+ if (st === 'alive') anyAlive = true;
253
+ if (st !== 'dead') allDead = false;
254
+ }
255
+ if (anyAlive) {
256
+ state.set(id, 'dead');
257
+ changed = true;
258
+ } else if (allDead) {
259
+ state.set(id, 'alive');
260
+ changed = true;
261
+ }
262
+ }
263
+ }
264
+ const undecided = ids.filter((id) => !state.has(id));
265
+ if (undecided.length === 0) break;
266
+ // Cycle: keep the newest member, then let propagation settle the rest.
267
+ undecided.sort((a, b) => {
268
+ const ta = cycleOrderTimestamp(byId.get(a));
269
+ const tb = cycleOrderTimestamp(byId.get(b));
270
+ if (ta !== tb) return ta < tb ? 1 : -1;
271
+ return a < b ? -1 : 1;
272
+ });
273
+ state.set(undecided[0], 'alive');
274
+ }
275
+
276
+ for (const [id, st] of state) {
277
+ if (st === 'dead') byId.delete(id);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Collapse a raw sessions.jsonl record array to one record per physical
283
+ * session AND report the supersede markers that were refused. Pure — the input
284
+ * array is never mutated.
285
+ *
286
+ * Rules, applied in this order (see the module header for the measured
287
+ * justification of each):
288
+ * 1. newest-wins per `session_id` (file order is chronological);
289
+ * 2. two `abandoned` records with an exact, both-present
290
+ * `started_at` + `completed_at` tuple collapse to the non-synthetic one;
291
+ * 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X`.
292
+ *
293
+ * The double-stub collapse runs BEFORE supersede removal so that a stub which
294
+ * is itself about to be superseded still shadows its synthetic twin — with the
295
+ * old order the twin outlived the record it duplicated (a `supersedes` append
296
+ * shrank the tuple group to one member, and the phantom survived the session
297
+ * that refuted it).
298
+ *
299
+ * Records without a usable `session_id` are dropped, unless
300
+ * `keepUnidentified: true` (they cannot be deduplicated; a COUNT-style or
301
+ * effectiveness-style consumer would rather keep them than shrink its `n`).
302
+ *
303
+ * Output order follows FIRST appearance of each surviving id in the input; kept
304
+ * unidentified records are appended after them, in their original order.
305
+ *
306
+ * @param {Array<unknown>} records
307
+ * @param {object} [opts]
308
+ * @param {boolean} [opts.keepUnidentified=false] pass id-less record objects
309
+ * through untouched instead of dropping them.
310
+ * @returns {{records: Array<object>, ignoredSupersedes: Array<{by: string,
311
+ * target: string, reason: string}>}}
312
+ */
313
+ export function canonicalizeSessionsDetailed(records, { keepUnidentified = false } = {}) {
314
+ if (!Array.isArray(records)) return { records: [], ignoredSupersedes: [] };
315
+
316
+ // -- (1) newest-wins per id ------------------------------------------------
317
+ // Map insertion order = FIRST appearance of the id; the stored value is the
318
+ // LAST record carrying it, so a superseding append wins without reordering
319
+ // the ledger's chronology.
320
+ const byId = new Map();
321
+ const unidentified = [];
322
+ for (const rec of records) {
323
+ if (!isRecordObject(rec)) continue;
324
+ if (!isNonEmptyString(rec.session_id)) {
325
+ if (keepUnidentified) unidentified.push(rec);
326
+ continue;
327
+ }
328
+ byId.set(rec.session_id, rec);
329
+ }
330
+
331
+ // -- (2) narrow abandoned-tuple collapse -----------------------------------
332
+ collapseAbandonedTuples(byId);
333
+
334
+ // -- (3) supersede removal (order-independent, join-key constrained) -------
335
+ const ignoredSupersedes = [];
336
+ resolveSupersedes(byId, ignoredSupersedes);
337
+
338
+ return { records: [...byId.values(), ...unidentified], ignoredSupersedes };
339
+ }
340
+
341
+ /**
342
+ * Collapse a raw sessions.jsonl record array to one record per physical
343
+ * session. Pure — the input array is never mutated. Thin wrapper over
344
+ * `canonicalizeSessionsDetailed`, returning only the records (the array shape
345
+ * every consumer reads).
346
+ *
347
+ * @param {Array<unknown>} records
348
+ * @param {object} [opts] — see `canonicalizeSessionsDetailed`.
349
+ * @param {boolean} [opts.keepUnidentified=false]
350
+ * @returns {Array<object>} canonical records
351
+ */
352
+ export function canonicalizeSessions(records, opts) {
353
+ return canonicalizeSessionsDetailed(records, opts).records;
354
+ }
355
+
356
+ /**
357
+ * Count DISTINCT physical sessions in RAW `sessions.jsonl` text. Pure — no fs,
358
+ * so an async reader keeps its own `readFile` and only the counting rule is
359
+ * shared (the two async consumers, `memory-banner.mjs` and
360
+ * `cold-start-detector.mjs`, carried byte-identical copies of this body).
361
+ *
362
+ * Blank lines (incl. the trailing newline) are skipped. A line that does not
363
+ * PARSE is not counted at all — it cannot be attributed to any session (this
364
+ * replaces the pre-#1167 "count every non-empty line, never parse" rule).
365
+ *
366
+ * `canonicalizeSessions` DROPS records without a `session_id` (they cannot be
367
+ * deduplicated). For a COUNT that would under-report rather than de-duplicate,
368
+ * so id-less records are counted as-is and only the id-bearing ones go through
369
+ * the identity collapse.
370
+ *
371
+ * @param {string} raw — full file contents.
372
+ * @returns {number}
373
+ */
374
+ export function countSessionsInJsonl(raw) {
375
+ if (typeof raw !== 'string' || raw.length === 0) return 0;
376
+ const parsed = [];
377
+ for (const line of raw.split('\n')) {
378
+ const trimmed = line.trim();
379
+ if (!trimmed) continue;
380
+ try {
381
+ parsed.push(JSON.parse(trimmed));
382
+ } catch {
383
+ /* skip malformed line */
384
+ }
385
+ }
386
+ const identified = parsed.filter((r) => isRecordObject(r) && isNonEmptyString(r.session_id));
387
+ const anonymous = parsed.length - identified.length;
388
+ return canonicalizeSessions(identified).length + anonymous;
389
+ }
390
+
391
+ /**
392
+ * Read `sessions.jsonl` and return its canonical records (see
393
+ * `canonicalizeSessions` for the three collapse rules).
394
+ *
395
+ * Synchronous by design — every consumer of the ledger in this repo reads it
396
+ * with `readFileSync`, and the file is small (286 records / ~0.5 MB at the
397
+ * time of writing). A MISSING file (ENOENT) yields `[]` silently; an UNREADABLE
398
+ * one (EACCES/EISDIR/…) yields `[]` with a stderr WARN (#1188); each malformed line
399
+ * is skipped rather than aborting the whole read (same posture as the readers
400
+ * in `session-close-backfill.mjs` and `backfill-abandoned-sessions.mjs`).
401
+ *
402
+ * @param {object} [args]
403
+ * @param {string} [args.repoRoot] project root; the ledger is resolved as
404
+ * `<repoRoot>/.orchestrator/metrics/sessions.jsonl`. Defaults to
405
+ * `process.cwd()` when neither this nor `filePath` is given.
406
+ * @param {string} [args.filePath] explicit ledger path (wins over `repoRoot`).
407
+ * @returns {Array<object>} canonical records
408
+ */
409
+ export function readCanonicalSessions({ repoRoot, filePath } = {}) {
410
+ const resolved = isNonEmptyString(filePath)
411
+ ? filePath
412
+ : path.join(isNonEmptyString(repoRoot) ? repoRoot : process.cwd(), ...SESSIONS_REL);
413
+
414
+ let raw;
415
+ try {
416
+ raw = fs.readFileSync(resolved, 'utf8');
417
+ } catch (err) {
418
+ // #1188 — ENOENT and EACCES/EISDIR are different facts: a missing ledger is
419
+ // the ordinary fresh-repo case; an UNREADABLE one previously read as "no
420
+ // sessions" and made every downstream count silently wrong. Same split as
421
+ // readLockDetailed (session-lock.mjs § absent vs unreadable).
422
+ if (!err || err.code !== 'ENOENT') {
423
+ process.stderr.write(
424
+ `⚠ readCanonicalSessions: cannot read ${resolved} ` +
425
+ `(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
426
+ 'treating as EMPTY, counts below are floors\n',
427
+ );
428
+ }
429
+ // CEILING (BV-004): still [] rather than throw — SessionStart callers must
430
+ // not crash on a transient permissions fault. REVISIT if the warn rate in
431
+ // events.jsonl shows masked corruption.
432
+ return [];
433
+ }
434
+
435
+ const parsed = [];
436
+ for (const line of raw.split('\n')) {
437
+ const trimmed = line.trim();
438
+ if (!trimmed) continue;
439
+ try {
440
+ parsed.push(JSON.parse(trimmed));
441
+ } catch {
442
+ /* skip malformed line */
443
+ }
444
+ }
445
+ return canonicalizeSessions(parsed);
446
+ }