session-orchestrator 3.23.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (393) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1401 -0
  81. package/NOTICE +11 -6
  82. package/README.md +127 -92
  83. package/agents/db-specialist.md +0 -1
  84. package/agents/eval-judge.md +1 -1
  85. package/agents/skill-applied-judge.md +1 -1
  86. package/assets/wave-lifecycle.svg +98 -0
  87. package/commands/release.md +6 -3
  88. package/commands/session.md +18 -3
  89. package/docs/README.md +4 -0
  90. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  91. package/docs/baseline.md +67 -0
  92. package/docs/ci-setup.md +249 -48
  93. package/docs/codex-setup.md +66 -22
  94. package/docs/components.md +37 -16
  95. package/docs/cursor-setup.md +6 -2
  96. package/docs/events-schema.md +51 -10
  97. package/docs/instruction-delivery.md +62 -0
  98. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  99. package/docs/migration-v4.md +341 -0
  100. package/docs/pi-setup.md +6 -1
  101. package/docs/plugin-architecture-v3.md +1 -1
  102. package/docs/rule-authoring.md +85 -19
  103. package/docs/scope-collision-guard.md +8 -8
  104. package/docs/session-config-reference.md +120 -61
  105. package/docs/session-config-template.md +40 -33
  106. package/docs/telemetry/telemetry-claims.md +11 -10
  107. package/docs/telemetry.md +187 -4
  108. package/docs/vault-docs-architecture.md +50 -11
  109. package/hooks/_lib/atomic-json.mjs +111 -0
  110. package/hooks/_lib/hook-import-set.json +1487 -0
  111. package/hooks/_lib/subagent-paths.mjs +143 -0
  112. package/hooks/_lib/subagent-transcript.mjs +562 -0
  113. package/hooks/config-protection.mjs +2 -2
  114. package/hooks/cwd-change-restore.mjs +11 -31
  115. package/hooks/enforce-commands.mjs +69 -0
  116. package/hooks/enforce-scope.mjs +35 -6
  117. package/hooks/hooks-codex.json +1 -1
  118. package/hooks/hooks-cursor.json +10 -0
  119. package/hooks/hooks-pi.json +5 -0
  120. package/hooks/hooks.json +6 -1
  121. package/hooks/loop-guard.mjs +3 -3
  122. package/hooks/on-session-end.mjs +280 -14
  123. package/hooks/on-session-start.mjs +153 -4
  124. package/hooks/on-stop.mjs +371 -17
  125. package/hooks/operator-steer.mjs +2 -2
  126. package/hooks/post-bash-write-verify.mjs +189 -4
  127. package/hooks/post-edit-import-probe.mjs +344 -0
  128. package/hooks/post-subagent-discovery-validator.mjs +278 -392
  129. package/hooks/post-tool-batch-wave-signal.mjs +272 -44
  130. package/hooks/post-tool-failure-corrective-context.mjs +11 -34
  131. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  132. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  133. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  134. package/hooks/skill-invocation-telemetry.mjs +17 -5
  135. package/hooks/subagent-telemetry.mjs +24 -30
  136. package/monitors/monitors.json +3 -3
  137. package/package.json +9 -1
  138. package/pi/prompts/session.md +2 -2
  139. package/plugin.json +27 -0
  140. package/scripts/autopilot.mjs +26 -12
  141. package/scripts/backfill-abandoned-sessions.mjs +130 -15
  142. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  143. package/scripts/dialectic-deriver.mjs +73 -8
  144. package/scripts/emit-event.mjs +10 -2
  145. package/scripts/export-hw-learnings.mjs +113 -1
  146. package/scripts/generate-agents-skills.mjs +378 -0
  147. package/scripts/generate-cursor-adapter.mjs +45 -8
  148. package/scripts/generate-hook-import-set.mjs +249 -0
  149. package/scripts/lib/agent-status.mjs +13 -2
  150. package/scripts/lib/auq/parse.mjs +5 -29
  151. package/scripts/lib/auto-dialectic.mjs +68 -0
  152. package/scripts/lib/auto-dream.mjs +38 -36
  153. package/scripts/lib/autonomy/suitability.mjs +6 -0
  154. package/scripts/lib/autopilot/loop.mjs +2 -2
  155. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  156. package/scripts/lib/build-live-signals.mjs +25 -22
  157. package/scripts/lib/ci-status-banner.mjs +220 -75
  158. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  159. package/scripts/lib/cold-start-detector.mjs +23 -14
  160. package/scripts/lib/config/auto-dream.mjs +2 -1
  161. package/scripts/lib/config/block-header.mjs +63 -0
  162. package/scripts/lib/config/block-preprocess.mjs +177 -0
  163. package/scripts/lib/config/broken-window.mjs +2 -1
  164. package/scripts/lib/config/cold-start.mjs +2 -1
  165. package/scripts/lib/config/config-protection.mjs +22 -2
  166. package/scripts/lib/config/context-coverage.mjs +2 -1
  167. package/scripts/lib/config/cross-repo.mjs +2 -1
  168. package/scripts/lib/config/custom-phases.mjs +2 -1
  169. package/scripts/lib/config/dialectic.mjs +2 -1
  170. package/scripts/lib/config/discovery-validator.mjs +9 -3
  171. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  172. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  173. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  174. package/scripts/lib/config/docs-staleness.mjs +2 -1
  175. package/scripts/lib/config/drift-check.mjs +2 -1
  176. package/scripts/lib/config/eval.mjs +2 -1
  177. package/scripts/lib/config/events-rotation.mjs +2 -1
  178. package/scripts/lib/config/evolve.mjs +8 -2
  179. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  180. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  181. package/scripts/lib/config/handover-gate.mjs +2 -1
  182. package/scripts/lib/config/health-endpoints.mjs +388 -0
  183. package/scripts/lib/config/issue-budget.mjs +2 -1
  184. package/scripts/lib/config/loop-guard.mjs +2 -1
  185. package/scripts/lib/config/memory.mjs +2 -1
  186. package/scripts/lib/config/moc-staleness.mjs +2 -1
  187. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  188. package/scripts/lib/config/private-config-dir.mjs +67 -0
  189. package/scripts/lib/config/reconcile.mjs +2 -1
  190. package/scripts/lib/config/remote-hosts.mjs +234 -0
  191. package/scripts/lib/config/section-extractor.mjs +7 -1
  192. package/scripts/lib/config/skill-evolution.mjs +2 -1
  193. package/scripts/lib/config/slopcheck.mjs +2 -1
  194. package/scripts/lib/config/state-md-lock.mjs +2 -1
  195. package/scripts/lib/config/templates-first.mjs +2 -1
  196. package/scripts/lib/config/test.mjs +2 -1
  197. package/scripts/lib/config/vault-integration.mjs +7 -1
  198. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  199. package/scripts/lib/config/vault-staleness.mjs +2 -1
  200. package/scripts/lib/config/vault-sync.mjs +2 -1
  201. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  202. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  203. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  204. package/scripts/lib/config.mjs +31 -3
  205. package/scripts/lib/convergence-monitor.mjs +82 -16
  206. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  207. package/scripts/lib/dispatcher/rank.mjs +124 -48
  208. package/scripts/lib/ecosystem-health.mjs +16 -2
  209. package/scripts/lib/eval/engine.mjs +9 -1
  210. package/scripts/lib/eval/session-resolve.mjs +23 -4
  211. package/scripts/lib/events-schema.mjs +48 -0
  212. package/scripts/lib/events.mjs +256 -7
  213. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  214. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  215. package/scripts/lib/frontmatter-guard.mjs +131 -13
  216. package/scripts/lib/gates/gate-full.mjs +26 -0
  217. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  218. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  219. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  220. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  221. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  222. package/scripts/lib/host-identity.mjs +50 -11
  223. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  224. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  225. package/scripts/lib/learnings/io.mjs +60 -6
  226. package/scripts/lib/memory-banner.mjs +20 -8
  227. package/scripts/lib/memory-proposals/store.mjs +30 -22
  228. package/scripts/lib/owner-config-banner.mjs +43 -6
  229. package/scripts/lib/owner-config-loader.mjs +21 -10
  230. package/scripts/lib/owner-interview.mjs +3 -3
  231. package/scripts/lib/owner-yaml.mjs +207 -14
  232. package/scripts/lib/peer-discovery.mjs +20 -2
  233. package/scripts/lib/platform.mjs +108 -15
  234. package/scripts/lib/plugin-update-banner.mjs +406 -0
  235. package/scripts/lib/project-hygiene.mjs +38 -2
  236. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  237. package/scripts/lib/quality-gate.mjs +133 -44
  238. package/scripts/lib/reconcile/emitter.mjs +68 -6
  239. package/scripts/lib/reconcile/engine.mjs +249 -9
  240. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  241. package/scripts/lib/reconcile/writer.mjs +40 -18
  242. package/scripts/lib/scope-gate.mjs +36 -0
  243. package/scripts/lib/session-close-backfill.mjs +125 -18
  244. package/scripts/lib/session-discovery.mjs +57 -3
  245. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  246. package/scripts/lib/session-id.mjs +12 -23
  247. package/scripts/lib/session-identity/own-session.mjs +187 -11
  248. package/scripts/lib/session-lock-shape.mjs +43 -0
  249. package/scripts/lib/session-lock.mjs +5 -10
  250. package/scripts/lib/session-registry.mjs +25 -9
  251. package/scripts/lib/session-schema/constants.mjs +36 -2
  252. package/scripts/lib/session-schema/validator.mjs +38 -4
  253. package/scripts/lib/session-start-probes.mjs +18 -1
  254. package/scripts/lib/session-transition.mjs +1 -1
  255. package/scripts/lib/sessions-canonical.mjs +446 -0
  256. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  257. package/scripts/lib/skill-health/join.mjs +17 -4
  258. package/scripts/lib/state-md.mjs +78 -0
  259. package/scripts/lib/sunset/walker.mjs +6 -0
  260. package/scripts/lib/telemetry/schema.mjs +255 -17
  261. package/scripts/lib/telemetry/sync.mjs +417 -24
  262. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  263. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  264. package/scripts/lib/validate/check-agents.mjs +3 -3
  265. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  266. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  267. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  268. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  269. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  270. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  271. package/scripts/lib/validate/check-skill-script-paths.mjs +455 -0
  272. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  273. package/scripts/lib/validate/check-unwired-features.mjs +0 -9
  274. package/scripts/lib/validate/check-validator-registration.mjs +254 -0
  275. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  276. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  277. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  278. package/scripts/lib/vault-backfill/template.mjs +63 -6
  279. package/scripts/lib/vault-mirror/process.mjs +165 -42
  280. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  281. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  282. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  283. package/scripts/lib/vault-status/narrative-mirror.mjs +129 -37
  284. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  285. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  286. package/scripts/lib/wave-executor/remote-dispatch.mjs +502 -0
  287. package/scripts/lib/wave-resource-gate.mjs +133 -7
  288. package/scripts/lib/wave-sizing.mjs +4 -1
  289. package/scripts/lib/wave-transcript-tail.mjs +142 -8
  290. package/scripts/materialize-wave-scope.mjs +32 -9
  291. package/scripts/memory-propose.mjs +146 -8
  292. package/scripts/migrate-cold-start-seed.mjs +4 -1
  293. package/scripts/parse-config.mjs +60 -3
  294. package/scripts/promote-vault-strict.mjs +4 -15
  295. package/scripts/release.mjs +337 -29
  296. package/scripts/repair-invalid-sessions.mjs +3 -3
  297. package/scripts/run-quality-gate.mjs +128 -11
  298. package/scripts/site-numbers.mjs +36 -4
  299. package/scripts/sweep-expired-learnings.mjs +90 -0
  300. package/scripts/sync-vault-schema.mjs +3 -1
  301. package/scripts/telemetry.mjs +2 -2
  302. package/scripts/validate-plugin.mjs +187 -0
  303. package/scripts/validate-wave-scope.mjs +28 -8
  304. package/scripts/vault-consolidate.mjs +3 -11
  305. package/scripts/vault-integration-watcher.mjs +2 -4
  306. package/scripts/vault-mirror.mjs +111 -26
  307. package/scripts/wave-scope-binding.mjs +215 -0
  308. package/skills/_shared/instruction-file-resolution.md +10 -0
  309. package/skills/_shared/parallel-aware-auq.md +31 -2
  310. package/skills/_shared/parallel-aware-preamble.md +18 -4
  311. package/skills/_shared/platform-tools.md +1 -1
  312. package/skills/_shared/state-ownership.md +1 -1
  313. package/skills/architecture/SKILL.md +7 -5
  314. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  315. package/skills/autopilot/SKILL.md +4 -18
  316. package/skills/claude-md-drift-check/SKILL.md +5 -1
  317. package/skills/claude-md-drift-check/checker.mjs +62 -2
  318. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  319. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  320. package/skills/discovery/probes-arch.md +20 -18
  321. package/skills/dispatcher/SKILL.md +3 -2
  322. package/skills/ecosystem-health/SKILL.md +4 -1
  323. package/skills/ecosystem-health/wizard.md +5 -0
  324. package/skills/evolve/SKILL.md +87 -11
  325. package/skills/frontmatter-guard/SKILL.md +11 -5
  326. package/skills/npm-publish/SKILL.md +1 -1
  327. package/skills/reconcile/SKILL.md +38 -2
  328. package/skills/remote-offload/SKILL.md +89 -0
  329. package/skills/session-end/SKILL.md +18 -905
  330. package/skills/session-end/phase-3-6-tail.md +19 -9
  331. package/skills/session-end/plan-verification.md +221 -155
  332. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  333. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  334. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  335. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  336. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  337. package/skills/session-end/references/session-summary-template.md +62 -0
  338. package/skills/session-plan/SKILL.md +49 -0
  339. package/skills/session-start/SKILL.md +41 -900
  340. package/skills/session-start/phase-8-5-express-path.md +1 -1
  341. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  342. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  343. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  344. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  345. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  346. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  347. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  348. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  349. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  350. package/skills/vault-sync/validator.mjs +21 -27
  351. package/skills/wave-executor/SKILL.md +16 -2
  352. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  353. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  354. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  355. package/skills/wave-executor/wave-loop.md +14 -1271
  356. package/templates/_shared/journey-manifest.md +10 -6
  357. package/.cursor/commands/autopilot-multi.md +0 -14
  358. package/.cursor/commands/contract-version-bump.md +0 -14
  359. package/.cursor/commands/journey-audit.md +0 -14
  360. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  361. package/.cursor/skills/daily/SKILL.md +0 -12
  362. package/.cursor/skills/domain-model/SKILL.md +0 -13
  363. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  364. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  365. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  366. package/commands/autopilot-multi.md +0 -74
  367. package/commands/contract-version-bump.md +0 -28
  368. package/commands/journey-audit.md +0 -43
  369. package/pi/prompts/autopilot-multi.md +0 -12
  370. package/pi/prompts/contract-version-bump.md +0 -12
  371. package/pi/prompts/journey-audit.md +0 -12
  372. package/scripts/autopilot-multi.mjs +0 -885
  373. package/scripts/backfill-learnings-expires.mjs +0 -196
  374. package/scripts/backfill-learnings.mjs +0 -203
  375. package/scripts/fleet-instruction-scan.mjs +0 -141
  376. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  377. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  378. package/scripts/lib/webhook-url.mjs +0 -105
  379. package/scripts/lifecycle-sim-v6.mjs +0 -347
  380. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  381. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  382. package/scripts/upload-social-preview.mjs +0 -316
  383. package/skills/_shared/model-selection.md +0 -64
  384. package/skills/contract-version-bump/SKILL.md +0 -219
  385. package/skills/daily/SKILL.md +0 -222
  386. package/skills/daily/generate.sh +0 -92
  387. package/skills/daily/templates/daily.md.tpl +0 -36
  388. package/skills/journey-audit/SKILL.md +0 -269
  389. package/skills/skill-creator/SKILL.md +0 -168
  390. package/skills/ubiquitous-language/SKILL.md +0 -97
  391. package/skills/vault-sync/package-lock.json +0 -40
  392. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  393. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -39,6 +39,7 @@
39
39
  * @property {number} waveNumber
40
40
  * @property {number | null} filesChanged
41
41
  * @property {number | null} testPassed
42
+ * @property {number | null} testFailed
42
43
  * @property {number | null} agentDispatchCount
43
44
  */
44
45
 
@@ -143,21 +144,56 @@ function tailRead(absPath, prevOffset) {
143
144
  * Event types this monitor is meant to classify. Anything else is ignored even
144
145
  * when it carries a wave number.
145
146
  *
146
- * The allowlist is a wave-LIFECYCLE prefix plus the agent-dispatch counters —
147
- * i.e. exactly the records that can carry the three measurements
148
- * `evaluateSignals` compares (`files_changed`, `test.passed`,
149
- * `agents_dispatched`). It is deliberately fail-CLOSED: a new event type that
150
- * gains a `wave_number` field must be added here consciously.
147
+ * The allowlist is a wave-LIFECYCLE prefix, the agent-dispatch/-stop counters,
148
+ * and the quality-gate envelope — i.e. exactly the records that can carry the
149
+ * three measurements `evaluateSignals` compares (`files_changed`, the test
150
+ * pass count, the per-wave agent count). It is deliberately fail-CLOSED: a new
151
+ * event type that gains a `wave_number` field must be added here consciously.
152
+ *
153
+ * The gate admission is a TYPE-AND-SHAPE gate, never a bare prefix widening —
154
+ * see `isWaveScopedEvent` for why.
151
155
  */
152
156
  const WAVE_EVENT_PREFIX = 'orchestrator.wave.';
153
157
  const WAVE_EVENT_NAMES = new Set(['agent.dispatched', 'orchestrator.agent.dispatched']);
158
+ const AGENT_STOPPED_EVENT = 'orchestrator.agent.stopped';
159
+ const GATE_EVENT_PREFIX = 'orchestrator.quality_gate.';
160
+
161
+ /**
162
+ * True when a record carries a `counts` object this monitor can fold, i.e. a
163
+ * plain object with a numeric `passed`. `counts: null`, an array, or a string
164
+ * payload are all rejected — the shape half of the type-and-shape gate.
165
+ *
166
+ * @param {Record<string, unknown>} rec
167
+ * @returns {boolean}
168
+ */
169
+ function hasFoldableCounts(rec) {
170
+ const counts = rec.counts;
171
+ if (counts === null || typeof counts !== 'object' || Array.isArray(counts)) return false;
172
+ return pickInt(/** @type {Record<string, unknown>} */ (counts).passed) !== null;
173
+ }
154
174
 
155
175
  /**
176
+ * Type-AND-shape gate: admit a record only when its event type is one this
177
+ * monitor classifies AND the record actually carries a measurement `classify`
178
+ * can fold. A bare prefix widening for `orchestrator.quality_gate.*` would
179
+ * re-open the #966 trap — the gate is the single highest-volume record type in
180
+ * `events.jsonl`, and admitting the session-level runs (no `wave_number`, or an
181
+ * envelope without `counts`) would instantiate a `WaveSummary` per gate run,
182
+ * advance `latestWave`, and burn the once-per-wave `alreadyEmitted` keys before
183
+ * the real wave record arrives. Hence the discriminator: a gate record is
184
+ * wave-scoped only when it has BOTH `wave_number` and a well-formed `counts`.
185
+ *
156
186
  * @param {string} evType
187
+ * @param {Record<string, unknown>} rec
157
188
  * @returns {boolean}
158
189
  */
159
- function isWaveScopedEvent(evType) {
160
- return evType.startsWith(WAVE_EVENT_PREFIX) || WAVE_EVENT_NAMES.has(evType);
190
+ function isWaveScopedEvent(evType, rec = {}) {
191
+ if (evType.startsWith(WAVE_EVENT_PREFIX)) return true;
192
+ if (WAVE_EVENT_NAMES.has(evType) || evType === AGENT_STOPPED_EVENT) return true;
193
+ if (evType.startsWith(GATE_EVENT_PREFIX)) {
194
+ return pickInt(rec.wave_number) !== null && hasFoldableCounts(rec);
195
+ }
196
+ return false;
161
197
  }
162
198
 
163
199
  /**
@@ -179,12 +215,29 @@ function isWaveScopedEvent(evType) {
179
215
  * `wave_number`, every gate run would have instantiated or refreshed a
180
216
  * `WaveSummary`, advanced `latestWave`, and burnt the once-per-wave
181
217
  * `alreadyEmitted` keys — suppressing the genuine signal when the real wave
182
- * record arrived later. Several other high-volume types (`session.stopped`,
183
- * `memory.propose_invoked`) already carry a wave and sat in the same trap.
218
+ * record arrived later. Several other high-volume types (`turn.stopped` — and its
219
+ * deprecated alias `session.stopped`, GitLab #1234 — plus `memory.propose_invoked`)
220
+ * already carry a wave and sat in the same trap. The gate is an ALLOWLIST
221
+ * (`isWaveRelevantType`), so the rename needed no edit here: a new name is
222
+ * ignored by default, which is the correct default for this classifier.
184
223
  *
185
- * Note the measurement keys are read FLAT (`rec['test.passed']`) and are
186
- * deliberately NOT reconciled with the gate event's nested `counts.passed` —
187
- * folding one into the other would silently change what this monitor measures.
224
+ * ## Where the pass count actually comes from (#980)
225
+ *
226
+ * Two independent paths, deliberately not merged:
227
+ * - the FLAT `rec['test.passed']` alias, kept for a future direct emitter on
228
+ * a wave-lifecycle record — no producer writes it today;
229
+ * - the gate envelope's NESTED `counts.passed`, folded ONLY for
230
+ * `orchestrator.quality_gate.*` records that also carry `wave_number`.
231
+ * That gating is what keeps the #966 invariant intact: an ungated record
232
+ * (a `wave.completed` carrying `counts`, or a session-level gate run with
233
+ * no `wave_number`) still folds NOTHING, so a gate run cannot manufacture a
234
+ * `WaveSummary` and burn the once-per-wave emit keys.
235
+ *
236
+ * `orchestrator.agent.stopped` is counted per record toward the wave's agent
237
+ * count, the same way `agent.dispatched` is — it is the only per-agent record
238
+ * this repo actually emits with a wave number (11,754 records measured
239
+ * 2026-09-05 in `.orchestrator/metrics/events.jsonl`, against 0 for
240
+ * `agent.dispatched`).
188
241
  *
189
242
  * @param {Record<string, unknown>} rec
190
243
  * @param {Map<number, WaveSummary>} state
@@ -192,7 +245,7 @@ function isWaveScopedEvent(evType) {
192
245
  */
193
246
  function classify(rec, state) {
194
247
  const evType = String(rec.event_type ?? rec.event ?? '');
195
- if (!isWaveScopedEvent(evType)) return null;
248
+ if (!isWaveScopedEvent(evType, rec)) return null;
196
249
 
197
250
  const waveNumber = pickInt(rec.wave_number ?? rec.wave ?? rec.waveId);
198
251
  if (waveNumber === null) return null;
@@ -202,6 +255,7 @@ function classify(rec, state) {
202
255
  waveNumber,
203
256
  filesChanged: null,
204
257
  testPassed: null,
258
+ testFailed: null,
205
259
  agentDispatchCount: null,
206
260
  };
207
261
  state.set(waveNumber, summary);
@@ -210,8 +264,17 @@ function classify(rec, state) {
210
264
  if (filesChanged !== null) summary.filesChanged = filesChanged;
211
265
  const testPassed = pickInt(rec['test.passed'] ?? rec.test_passed ?? rec.testsPassed);
212
266
  if (testPassed !== null) summary.testPassed = testPassed;
213
- // Count one agent.dispatched event toward this wave's dispatch count.
214
- if (WAVE_EVENT_NAMES.has(evType)) {
267
+ // Nested gate envelope — admitted by isWaveScopedEvent only WITH wave_number,
268
+ // so the ungated #966 invariant (no fold) survives for every other record.
269
+ if (evType.startsWith(GATE_EVENT_PREFIX)) {
270
+ const counts = /** @type {Record<string, unknown>} */ (rec.counts);
271
+ const gatePassed = pickInt(counts.passed);
272
+ if (gatePassed !== null) summary.testPassed = gatePassed;
273
+ const gateFailed = pickInt(counts.failed);
274
+ if (gateFailed !== null) summary.testFailed = gateFailed;
275
+ }
276
+ // Count one dispatched/stopped agent record toward this wave's agent count.
277
+ if (WAVE_EVENT_NAMES.has(evType) || evType === AGENT_STOPPED_EVENT) {
215
278
  summary.agentDispatchCount = (summary.agentDispatchCount ?? 0) + 1;
216
279
  } else {
217
280
  const dispatched = pickInt(rec.agents_dispatched ?? rec.agentsDispatched);
@@ -443,4 +506,7 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.a
443
506
  main();
444
507
  }
445
508
 
446
- export { classify, isWaveScopedEvent };
509
+ // `_evaluateSignals` is a test-only export (leading `_` per the repo convention,
510
+ // cf. `_clearCompileCache` in scripts/lib/agent-output-schema.mjs): the signal
511
+ // firing is otherwise reachable only through the live tail loop.
512
+ export { classify, isWaveScopedEvent, evaluateSignals as _evaluateSignals };
@@ -29,12 +29,12 @@
29
29
  */
30
30
 
31
31
  import { readdirSync, existsSync } from 'node:fs';
32
- import os from 'node:os';
33
32
  import path from 'node:path';
34
33
 
35
34
  import { getConfinementRoot, getCrossRepoProjects } from '../config/cross-repo.mjs';
36
35
  import { validatePathInsideProject } from '../path-utils.mjs';
37
36
  import { readLock, isLockLive } from '../session-lock.mjs';
37
+ import { expandTilde } from '../common.mjs';
38
38
 
39
39
  /**
40
40
  * @typedef {Object} Candidate
@@ -97,21 +97,6 @@ function shouldDescendInto(name) {
97
97
  return true;
98
98
  }
99
99
 
100
- /**
101
- * Expand a leading `~` to the current user's home directory. Mirrors the helper
102
- * in board-writer.mjs (a shared extraction is deferred to a later epic). Used to
103
- * normalise config-declared cross-repo paths that may begin with `~/`.
104
- *
105
- * @param {string} p
106
- * @returns {string}
107
- */
108
- function expandHome(p) {
109
- if (typeof p !== 'string' || p.length === 0) return p;
110
- if (p === '~') return os.homedir();
111
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
112
- return p;
113
- }
114
-
115
100
  /**
116
101
  * Derive free/busy status for a single repo from its session.lock lease.
117
102
  * Mirrors board-writer.mjs collectRows semantics exactly:
@@ -322,7 +307,7 @@ export async function enumerateCandidates({ startDir, now, deps } = {}) {
322
307
  if (Array.isArray(declared)) {
323
308
  for (const raw of declared) {
324
309
  if (typeof raw !== 'string' || raw.length === 0) continue;
325
- const expanded = expandHome(raw);
310
+ const expanded = expandTilde(raw);
326
311
  const abs = path.resolve(expanded);
327
312
  // Confinement-filter against the same root as the FS scan.
328
313
  const guard = validatePathInsideProjectFn(abs, root);
@@ -29,12 +29,12 @@
29
29
  * @typedef {{ repoRoot: string, repoName: string, free: boolean, status: 'frei'|'in-progress'|'force-closed', heartbeat: string|null, sessionId: string|null }} Candidate
30
30
  */
31
31
 
32
- import { readFileSync } from 'node:fs';
33
32
  import path from 'node:path';
34
33
 
35
34
  import { scanBacklog } from '../backlog-scan.mjs';
36
35
  import { checkCiStatus as realCheckCiStatus } from '../ci-status-banner.mjs';
37
36
  import { probe as realProbe, evaluate as realEvaluate, DEFAULT_RESOURCE_THRESHOLDS as CANONICAL_RESOURCE_THRESHOLDS } from '../resource-probe.mjs';
37
+ import { readCanonicalSessions } from '../sessions-canonical.mjs';
38
38
  import { isRealSession } from '../session-schema/filters.mjs';
39
39
 
40
40
  /** Staleness cap (days). Beyond this, additional age does not raise the score. */
@@ -154,17 +154,29 @@ async function defaultFetchPriority(repoRoot, nowMs) {
154
154
 
155
155
  /**
156
156
  * Default STALENESS source: read `<repoRoot>/.orchestrator/metrics/sessions.jsonl`,
157
- * find the last REAL (non-phantom) record scanning backward from the tail, and
158
- * compute days since `completed_at` (fallback `started_at`). No file / no
159
- * parsable REAL record / no timestamp ⇒ `STALENESS_CAP_DAYS` (treat as
160
- * maximally stale = most worthwhile).
157
+ * find the MOST RECENT REAL (non-phantom) record's timestamp, and compute days
158
+ * since `completed_at` (fallback `started_at`). No file / no parsable REAL
159
+ * record / no timestamp ⇒ `STALENESS_CAP_DAYS` (treat as maximally stale =
160
+ * most worthwhile).
161
161
  *
162
- * Scans backward PAST any trailing `status: 'abandoned'` phantom stubs (#834)
163
- * — session-close-backfill writes these for sessions that ended without a real
164
- * close (0 waves, seconds of runtime). Stopping at the raw last LINE would let
165
- * a single recent phantom make a genuinely neglected repo look freshly
166
- * touched, defeating the dispatcher's whole purpose (this is the N=1 extreme
167
- * case of the phantom-tail problem — one stub is enough to zero out staleness).
162
+ * Skips `status: 'abandoned'` phantom stubs (#834) — session-close-backfill
163
+ * writes these for sessions that ended without a real close (0 waves, seconds
164
+ * of runtime). Counting one would let a single recent phantom make a
165
+ * genuinely neglected repo look freshly touched, defeating the dispatcher's
166
+ * whole purpose (this is the N=1 extreme case of the phantom-tail problem —
167
+ * one stub is enough to zero out staleness).
168
+ *
169
+ * #1209: takes the MAX timestamp over every canonical REAL record instead of
170
+ * scanning backward through raw file lines from the tail. A raw backward scan
171
+ * trusts FILE POSITION as a proxy for recency, which silently breaks the
172
+ * moment a backfill/repair writer appends an OLDER record after genuinely
173
+ * newer ones already exist — `session-record-repair.mjs` and
174
+ * `migrate-sessions-jsonl.mjs` both do this by design (see
175
+ * `sessions-canonical.mjs`'s module header, "by-design raw" list). A
176
+ * duplicated `session_id` re-appended late at the tail would make the old
177
+ * backward scan report the STALE re-append's date instead of the genuinely
178
+ * newer session's date; `readCanonicalSessions` collapses the duplicate to
179
+ * one record first, so the max-reduce below sees only the true timestamps.
168
180
  *
169
181
  * @param {string} repoRoot
170
182
  * @param {number} nowMs
@@ -173,33 +185,21 @@ async function defaultFetchPriority(repoRoot, nowMs) {
173
185
  async function defaultStaleDaysFor(repoRoot, nowMs) {
174
186
  try {
175
187
  const file = path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
176
- const raw = readFileSync(file, 'utf8');
177
- const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
178
- if (lines.length === 0) return STALENESS_CAP_DAYS;
179
-
180
- // Scan backward for the last REAL (non-abandoned) session record, skipping
181
- // both corrupt lines and phantom stubs.
182
- let last = null;
183
- for (let i = lines.length - 1; i >= 0; i -= 1) {
184
- let parsed;
185
- try {
186
- parsed = JSON.parse(lines[i]);
187
- } catch {
188
- continue; // Skip a corrupt line and try the previous one.
189
- }
190
- if (isRealSession(parsed)) {
191
- last = parsed;
192
- break;
193
- }
188
+ const records = readCanonicalSessions({ filePath: file });
189
+ if (records.length === 0) return STALENESS_CAP_DAYS;
190
+
191
+ let latestMs = null;
192
+ for (const rec of records) {
193
+ if (!isRealSession(rec)) continue;
194
+ const iso = rec.completed_at || rec.started_at || null;
195
+ if (!iso || typeof iso !== 'string') continue;
196
+ const t = Date.parse(iso);
197
+ if (Number.isNaN(t)) continue;
198
+ if (latestMs === null || t > latestMs) latestMs = t;
194
199
  }
195
- if (!last || typeof last !== 'object') return STALENESS_CAP_DAYS;
196
-
197
- const iso = last.completed_at || last.started_at || null;
198
- if (!iso || typeof iso !== 'string') return STALENESS_CAP_DAYS;
199
- const t = Date.parse(iso);
200
- if (Number.isNaN(t)) return STALENESS_CAP_DAYS;
200
+ if (latestMs === null) return STALENESS_CAP_DAYS;
201
201
 
202
- const days = (nowMs - t) / MS_PER_DAY;
202
+ const days = (nowMs - latestMs) / MS_PER_DAY;
203
203
  return days > 0 ? days : 0;
204
204
  } catch {
205
205
  // No sessions file (or unreadable) ⇒ never worked on ⇒ maximally stale.
@@ -209,21 +209,75 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
209
209
 
210
210
  /**
211
211
  * Default READINESS (CI) source: thin wrapper over `checkCiStatus`.
212
- * Returns the 'green'|'red'|'unknown' status string, or null (no-op).
213
- * null / 'unknown' are treated as non-blocking by `scoreCandidate`.
212
+ *
213
+ * Passes the probe's result through VERBATIM rather than flattening it to a
214
+ * status string (#1031 follow-up). `checkCiStatus` has THREE return states —
215
+ * `null` (benign absence), `{status, …}` (a real reading), and
216
+ * `{severity:'warn', degraded:<reason>}` (the state could NOT be read) — and
217
+ * the old `typeof result.status === 'string' ? result.status : null` collapsed
218
+ * the third onto the first. That was not a scoring bug (both are non-blocking)
219
+ * but it discarded the one thing the operator needs to know: the ranking was
220
+ * computed WITHOUT a CI reading, and why. {@link normalizeCiSignal} in
221
+ * `rankCandidates` performs the shape reduction and keeps the reason.
222
+ *
223
+ * A THROW is the fourth state, and it is NOT the first: `checkCiStatus` is
224
+ * documented never to throw, so an exception here means the probe itself broke
225
+ * (a bad import, an unexpected runtime error). Returning `null` for it would
226
+ * assert measured ABSENCE — the one thing we know is false. It is mapped to the
227
+ * degraded shape instead, so {@link normalizeCiSignal} surfaces it as
228
+ * `ciStatus: 'unknown'` + `ciDegraded: 'probe-threw'` rather than silently.
214
229
  *
215
230
  * @param {{ repoRoot: string }} args
216
- * @returns {Promise<'green'|'red'|'unknown'|null>}
231
+ * @returns {Promise<'green'|'red'|'unknown'|null|object>} raw probe result.
217
232
  */
218
233
  async function defaultCheckCiStatus({ repoRoot }) {
219
234
  try {
220
- const result = await realCheckCiStatus({ repoRoot });
221
- return result && typeof result.status === 'string' ? result.status : null;
222
- } catch {
223
- return null;
235
+ return (await realCheckCiStatus({ repoRoot })) ?? null;
236
+ } catch (err) {
237
+ const detail = err instanceof Error ? err.message : String(err);
238
+ return {
239
+ severity: 'warn',
240
+ ok: false,
241
+ message: `⚠ ci-status: CI probe threw — state UNKNOWN, not "green". ${detail}`,
242
+ degraded: 'probe-threw',
243
+ };
224
244
  }
225
245
  }
226
246
 
247
+ /**
248
+ * Reduce whatever a `checkCiStatus` dep returned to the pair the ranking needs:
249
+ * a scoring status and — when the state was UNREADABLE — the reason.
250
+ *
251
+ * Accepts every shape the dep contract permits, old and new:
252
+ * - a bare status string (`'green'`/`'red'`/`'unknown'`) — the shape every
253
+ * injected test dep uses; passed through unchanged.
254
+ * - `null`/`undefined` — genuine absence, no signal, no reason.
255
+ * - `{status: <string>}` — a real reading from `checkCiStatus`.
256
+ * - `{degraded: <reason>}` with no usable `status` — the probe FAILED. Mapped
257
+ * to `'unknown'`, which `scoreCandidate` already treats as non-blocking
258
+ * (`ciFactor` dampens on `'red'` only), so ranking is byte-identical to the
259
+ * previous `null` — but the reason survives into `readiness.ciDegraded`
260
+ * and into a `warnings` entry.
261
+ * - anything else — `null`, as before.
262
+ *
263
+ * `ciDegraded` is `null` (and the caller OMITS the key) whenever the state was
264
+ * readable, so existing strict `toEqual` pins on `signals.readiness` hold.
265
+ *
266
+ * @param {unknown} raw
267
+ * @returns {{ ciStatus: 'green'|'red'|'unknown'|null, ciDegraded: string|null }}
268
+ */
269
+ export function normalizeCiSignal(raw) {
270
+ if (typeof raw === 'string' && raw) return { ciStatus: raw, ciDegraded: null };
271
+ if (!raw || typeof raw !== 'object') return { ciStatus: null, ciDegraded: null };
272
+ if (typeof raw.status === 'string' && raw.status) {
273
+ return { ciStatus: raw.status, ciDegraded: null };
274
+ }
275
+ if (typeof raw.degraded === 'string' && raw.degraded) {
276
+ return { ciStatus: 'unknown', ciDegraded: raw.degraded };
277
+ }
278
+ return { ciStatus: null, ciDegraded: null };
279
+ }
280
+
227
281
  /**
228
282
  * Default READINESS (resource) source: probe the host ONCE and evaluate against
229
283
  * the canonical default thresholds. This is a HOST-level signal (identical for
@@ -290,7 +344,7 @@ export function defaultDeps() {
290
344
  * ranked: Array<{ candidate: Candidate, score: number, signals: {
291
345
  * priority: { criticalCount: number, highCount: number } | null,
292
346
  * staleDays: number,
293
- * readiness: { ciStatus: 'green'|'red'|'unknown'|null, resourceVerdict: 'green'|'warn'|'degraded'|'critical' },
347
+ * readiness: { ciStatus: 'green'|'red'|'unknown'|null, resourceVerdict: 'green'|'warn'|'degraded'|'critical', ciDegraded?: string },
294
348
  * } }>,
295
349
  * warnings: string[],
296
350
  * }>}
@@ -342,18 +396,40 @@ export async function rankCandidates(freeCandidates, opts = {}) {
342
396
  staleDays = STALENESS_CAP_DAYS;
343
397
  }
344
398
 
345
- // READINESS — CI status.
399
+ // READINESS — CI status. A degraded probe result ("state unknown") is
400
+ // NON-BLOCKING but not INVISIBLE: it scores like `null`, and reports why.
401
+ // Declared WITHOUT an initialiser on purpose: both paths below assign, and
402
+ // a `= null` seed would quietly re-create a fall-through "absent" default.
346
403
  let ciStatus;
404
+ let ciDegraded;
347
405
  try {
348
- ciStatus = await deps.checkCiStatus({ repoRoot });
406
+ ({ ciStatus, ciDegraded } = normalizeCiSignal(await deps.checkCiStatus({ repoRoot })));
349
407
  } catch {
350
- ciStatus = null;
408
+ // A THROWING dep is not measured ABSENCE. Leaving `ciDegraded` at null
409
+ // here re-created the exact collapse `normalizeCiSignal` exists to
410
+ // prevent: the ranking silently proceeded as if no CI signal existed,
411
+ // when in truth the probe broke. Scoring is unchanged (`'unknown'` is
412
+ // non-blocking) — only the reason becomes visible.
413
+ ciStatus = 'unknown';
414
+ ciDegraded = 'probe-threw';
415
+ }
416
+ if (ciDegraded) {
417
+ warnings.push(
418
+ `CI state unknown for ${repoName} (${ciDegraded}) — ranked without CI dampening`,
419
+ );
351
420
  }
352
421
 
353
422
  const signals = {
354
423
  priority,
355
424
  staleDays,
356
- readiness: { ciStatus, resourceVerdict },
425
+ readiness: {
426
+ ciStatus,
427
+ resourceVerdict,
428
+ // Key OMITTED when the CI state was readable — a strict `toEqual` on
429
+ // `signals.readiness` must not have to know about a field that only
430
+ // exists on the failure path.
431
+ ...(ciDegraded ? { ciDegraded } : {}),
432
+ },
357
433
  };
358
434
  const score = scoreCandidate(signals);
359
435
 
@@ -164,13 +164,27 @@ async function watchLoop(intervalS) {
164
164
  }
165
165
 
166
166
  /**
167
+ * Poll delay.
168
+ *
169
+ * The timer is deliberately NOT `unref()`d: it is the only handle this process
170
+ * holds (the two signal handlers do not keep the loop alive), so an unref'd
171
+ * timer drains the event loop and node exits 0 the instant the first tick is
172
+ * scheduled — a watcher that supervises nothing while looking like a clean
173
+ * shutdown, because it exits 0 with an empty stderr. Measured 2026-09-06 on the
174
+ * unref'd variant: `node scripts/lib/ecosystem-health.mjs --watch --interval=1`
175
+ * returned exit 0 after 48 ms instead of running until SIGTERM. Third copy of
176
+ * the #980 defect A1 measured in `scripts/lib/wave-transcript-tail.mjs` and
177
+ * `scripts/lib/convergence-monitor.mjs`; this one was missed when those two
178
+ * were fixed. Pinned by a DURATION assertion (an exit-code assertion cannot
179
+ * tell a healthy monitor from a dead one) in
180
+ * `tests/lib/ecosystem-health-watch.test.mjs`.
181
+ *
167
182
  * @param {number} ms
168
183
  * @returns {Promise<void>}
169
184
  */
170
185
  function sleep(ms) {
171
186
  return new Promise((resolve) => {
172
- const t = setTimeout(resolve, ms);
173
- t.unref?.();
187
+ setTimeout(resolve, ms);
174
188
  });
175
189
  }
176
190
 
@@ -36,6 +36,7 @@ import path from 'node:path';
36
36
 
37
37
  import { resolvePluginRoot } from '../common.mjs';
38
38
  import { readJsonlFile } from '../io.mjs';
39
+ import { readCanonicalSessions } from '../sessions-canonical.mjs';
39
40
  import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
40
41
  import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
41
42
 
@@ -538,7 +539,14 @@ export function evaluateSession(opts = {}) {
538
539
 
539
540
  const sessionsPath = path.join(metricsDir, 'sessions.jsonl');
540
541
  const eventsPath = path.join(metricsDir, 'events.jsonl');
541
- const records = readJsonlFile(sessionsPath, { skipInvalid: true });
542
+ // #1209: sessions.jsonl is APPEND-ONLY (the same physical session can carry
543
+ // more than one line — crash-recovery re-appends, #1068 stub/supersede
544
+ // pairs), so a raw readJsonlFile() left resolveSession()/findPeerOverlap()
545
+ // to hand-roll their own dedup over duplicated / phantom-doubled records.
546
+ // readCanonicalSessions() collapses those first (newest-wins per
547
+ // session_id, #1068 double-stub collapse, supersede removal) — see
548
+ // session-resolve.mjs for how that simplifies both callers below.
549
+ const records = readCanonicalSessions({ filePath: sessionsPath });
542
550
  const events = readJsonlFile(eventsPath, { skipInvalid: true });
543
551
 
544
552
  const { record: session, resolvedVia } = resolveSession(records, sessionId);
@@ -48,11 +48,17 @@ export function resolveSession(records, sessionId) {
48
48
 
49
49
  // Explicit selection: the LAST record carrying this session_id (records may be
50
50
  // rewritten/backfilled across a session's life; the latest is authoritative).
51
+ //
52
+ // #1209: engine.mjs's production caller now passes CANONICAL records
53
+ // (readCanonicalSessions already resolved newest-wins per session_id before
54
+ // this array reaches here — see sessions-canonical.mjs), so at most one
55
+ // record can carry a given id in practice. `resolveSession` stays a
56
+ // general, pure function — `findLast` keeps the exact "last match wins"
57
+ // semantics for a caller that hands in raw, un-deduped records directly
58
+ // (e.g. the unit tests below), while degenerating to a single candidate on
59
+ // the canonical path.
51
60
  if (sessionId) {
52
- let match = null;
53
- for (const r of records) {
54
- if (isPlainObject(r) && r.session_id === sessionId) match = r;
55
- }
61
+ const match = records.findLast((r) => isPlainObject(r) && r.session_id === sessionId) ?? null;
56
62
  if (!match) {
57
63
  throw new SessionResolutionError(`session not found: ${sessionId}`);
58
64
  }
@@ -114,6 +120,19 @@ export function computeWindow(record) {
114
120
  * rewrites of the SAME session) are excluded, as are records without a valid
115
121
  * window.
116
122
  *
123
+ * #1209: the #1068 double-stub class — two ABANDONED records for ONE physical
124
+ * session (a real semantic `session_id` + a synthetic backfill twin, sharing
125
+ * an exact `started_at`/`completed_at` tuple, see `sessions-canonical.mjs`
126
+ * rule 2) — used to be counted as TWO distinct peers here, because the raw
127
+ * `records` array carried both lines under different ids and this function has
128
+ * no way to know they are the same physical session. With engine.mjs's
129
+ * production caller now passing `readCanonicalSessions()` output,
130
+ * `collapseAbandonedTuples()` has already dropped the synthetic twin before
131
+ * this array reaches here — a double-stub pair can therefore no longer
132
+ * produce a false EXTRA peer (`count` inflated by one) on the canonical path.
133
+ * `findPeerOverlap` itself stays general/pure for callers (including the unit
134
+ * tests below) that pass raw, un-deduped records directly.
135
+ *
117
136
  * @param {object[]} records — all sessions.jsonl records.
118
137
  * @param {object} resolved — the resolved session record.
119
138
  * @returns {{ count: number, peers: string[] }} unique overlapping session_ids.
@@ -12,6 +12,54 @@
12
12
  * Pure functions, no filesystem access — safe to import anywhere.
13
13
  */
14
14
 
15
+ /**
16
+ * Current events.jsonl record schema version (#1177). Mirrors
17
+ * `subagents-schema.mjs § CURRENT_SCHEMA_VERSION`.
18
+ *
19
+ * Records written before #1177 carry NO `schema_version` key at all; an absent
20
+ * key therefore reads as "pre-versioned", never as version 0. Stamping is
21
+ * strictly additive — see `stampEventSchemaVersion()`.
22
+ */
23
+ export const CURRENT_SCHEMA_VERSION = 1;
24
+
25
+ /**
26
+ * Thrown by `emitEvent()` when a record fails `validateEventRecord()`.
27
+ *
28
+ * Carries the individual validator messages so a CLI/hook caller can surface
29
+ * them without re-running the validator.
30
+ */
31
+ export class EventValidationError extends Error {
32
+ /**
33
+ * @param {string} message — human-readable summary.
34
+ * @param {string[]} [errors=[]] — the validator's individual error strings.
35
+ * @param {string} [eventType] — the offending event name, when known.
36
+ */
37
+ constructor(message, errors = [], eventType) {
38
+ super(message);
39
+ this.name = 'EventValidationError';
40
+ this.errors = errors;
41
+ this.eventType = eventType;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Return a shallow copy of `record` with `schema_version` stamped to
47
+ * `CURRENT_SCHEMA_VERSION` — but ONLY when the field is absent
48
+ * (`undefined`/`null`). An existing value is never overwritten, so a caller
49
+ * (or a migration re-writing historical records) keeps authority over its own
50
+ * version field.
51
+ *
52
+ * @param {object} record
53
+ * @returns {object} shallow copy, `schema_version` guaranteed present.
54
+ */
55
+ export function stampEventSchemaVersion(record) {
56
+ const out = { ...record };
57
+ if (out.schema_version === undefined || out.schema_version === null) {
58
+ out.schema_version = CURRENT_SCHEMA_VERSION;
59
+ }
60
+ return out;
61
+ }
62
+
15
63
  /** ISO-8601 UTC timestamp with trailing Z (e.g. 2026-05-28T14:35:13.123Z). */
16
64
  const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
17
65