session-orchestrator 3.24.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 (350) 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 +1 -1
  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 +1125 -2
  81. package/NOTICE +11 -6
  82. package/README.md +127 -94
  83. package/agents/eval-judge.md +1 -1
  84. package/agents/skill-applied-judge.md +1 -1
  85. package/assets/wave-lifecycle.svg +98 -0
  86. package/commands/release.md +6 -3
  87. package/commands/session.md +18 -3
  88. package/docs/README.md +4 -0
  89. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  90. package/docs/baseline.md +67 -0
  91. package/docs/ci-setup.md +108 -62
  92. package/docs/codex-setup.md +65 -21
  93. package/docs/components.md +36 -15
  94. package/docs/cursor-setup.md +6 -2
  95. package/docs/events-schema.md +9 -6
  96. package/docs/instruction-delivery.md +62 -0
  97. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  98. package/docs/migration-v4.md +341 -0
  99. package/docs/pi-setup.md +6 -1
  100. package/docs/plugin-architecture-v3.md +1 -1
  101. package/docs/rule-authoring.md +85 -19
  102. package/docs/scope-collision-guard.md +5 -5
  103. package/docs/session-config-reference.md +57 -56
  104. package/docs/session-config-template.md +6 -29
  105. package/docs/telemetry.md +157 -3
  106. package/docs/vault-docs-architecture.md +50 -11
  107. package/hooks/_lib/hook-import-set.json +1487 -0
  108. package/hooks/_lib/subagent-transcript.mjs +562 -0
  109. package/hooks/config-protection.mjs +2 -2
  110. package/hooks/cwd-change-restore.mjs +2 -2
  111. package/hooks/enforce-commands.mjs +69 -0
  112. package/hooks/hooks-codex.json +1 -1
  113. package/hooks/hooks-cursor.json +10 -0
  114. package/hooks/hooks-pi.json +5 -0
  115. package/hooks/hooks.json +6 -1
  116. package/hooks/loop-guard.mjs +3 -3
  117. package/hooks/on-session-end.mjs +2 -2
  118. package/hooks/on-session-start.mjs +103 -2
  119. package/hooks/on-stop.mjs +36 -11
  120. package/hooks/operator-steer.mjs +2 -2
  121. package/hooks/post-bash-write-verify.mjs +85 -0
  122. package/hooks/post-edit-import-probe.mjs +344 -0
  123. package/hooks/post-subagent-discovery-validator.mjs +187 -431
  124. package/hooks/post-tool-batch-wave-signal.mjs +118 -4
  125. package/hooks/post-tool-failure-corrective-context.mjs +2 -2
  126. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  127. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  128. package/hooks/skill-invocation-telemetry.mjs +17 -5
  129. package/hooks/subagent-telemetry.mjs +13 -4
  130. package/monitors/monitors.json +3 -3
  131. package/package.json +9 -1
  132. package/pi/prompts/session.md +2 -2
  133. package/plugin.json +27 -0
  134. package/scripts/backfill-abandoned-sessions.mjs +50 -4
  135. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  136. package/scripts/dialectic-deriver.mjs +73 -8
  137. package/scripts/export-hw-learnings.mjs +113 -1
  138. package/scripts/generate-agents-skills.mjs +378 -0
  139. package/scripts/generate-cursor-adapter.mjs +45 -8
  140. package/scripts/generate-hook-import-set.mjs +249 -0
  141. package/scripts/lib/agent-status.mjs +13 -2
  142. package/scripts/lib/auto-dream.mjs +38 -36
  143. package/scripts/lib/autonomy/suitability.mjs +6 -0
  144. package/scripts/lib/autopilot/loop.mjs +2 -2
  145. package/scripts/lib/ci-status-banner.mjs +220 -75
  146. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  147. package/scripts/lib/config/auto-dream.mjs +2 -1
  148. package/scripts/lib/config/block-header.mjs +8 -0
  149. package/scripts/lib/config/block-preprocess.mjs +177 -0
  150. package/scripts/lib/config/broken-window.mjs +2 -1
  151. package/scripts/lib/config/cold-start.mjs +2 -1
  152. package/scripts/lib/config/config-protection.mjs +22 -2
  153. package/scripts/lib/config/context-coverage.mjs +2 -1
  154. package/scripts/lib/config/cross-repo.mjs +2 -1
  155. package/scripts/lib/config/custom-phases.mjs +2 -1
  156. package/scripts/lib/config/dialectic.mjs +2 -1
  157. package/scripts/lib/config/discovery-validator.mjs +2 -1
  158. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  159. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  160. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  161. package/scripts/lib/config/docs-staleness.mjs +2 -1
  162. package/scripts/lib/config/drift-check.mjs +2 -1
  163. package/scripts/lib/config/eval.mjs +2 -1
  164. package/scripts/lib/config/events-rotation.mjs +2 -1
  165. package/scripts/lib/config/evolve.mjs +8 -2
  166. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  167. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  168. package/scripts/lib/config/handover-gate.mjs +2 -1
  169. package/scripts/lib/config/health-endpoints.mjs +7 -2
  170. package/scripts/lib/config/issue-budget.mjs +2 -1
  171. package/scripts/lib/config/loop-guard.mjs +2 -1
  172. package/scripts/lib/config/memory.mjs +2 -1
  173. package/scripts/lib/config/moc-staleness.mjs +2 -1
  174. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  175. package/scripts/lib/config/private-config-dir.mjs +67 -0
  176. package/scripts/lib/config/reconcile.mjs +2 -1
  177. package/scripts/lib/config/remote-hosts.mjs +2 -1
  178. package/scripts/lib/config/section-extractor.mjs +7 -1
  179. package/scripts/lib/config/skill-evolution.mjs +2 -1
  180. package/scripts/lib/config/slopcheck.mjs +2 -1
  181. package/scripts/lib/config/state-md-lock.mjs +2 -1
  182. package/scripts/lib/config/templates-first.mjs +2 -1
  183. package/scripts/lib/config/test.mjs +2 -1
  184. package/scripts/lib/config/vault-integration.mjs +7 -1
  185. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  186. package/scripts/lib/config/vault-staleness.mjs +2 -1
  187. package/scripts/lib/config/vault-sync.mjs +2 -1
  188. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  189. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  190. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  191. package/scripts/lib/convergence-monitor.mjs +82 -16
  192. package/scripts/lib/dispatcher/rank.mjs +124 -48
  193. package/scripts/lib/ecosystem-health.mjs +16 -2
  194. package/scripts/lib/eval/engine.mjs +9 -1
  195. package/scripts/lib/eval/session-resolve.mjs +23 -4
  196. package/scripts/lib/events.mjs +22 -6
  197. package/scripts/lib/frontmatter-guard.mjs +131 -13
  198. package/scripts/lib/gates/gate-full.mjs +26 -0
  199. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  200. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  201. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  202. package/scripts/lib/host-identity.mjs +50 -11
  203. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  204. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  205. package/scripts/lib/learnings/io.mjs +60 -6
  206. package/scripts/lib/memory-proposals/store.mjs +30 -22
  207. package/scripts/lib/owner-config-banner.mjs +43 -6
  208. package/scripts/lib/owner-config-loader.mjs +21 -10
  209. package/scripts/lib/owner-interview.mjs +3 -3
  210. package/scripts/lib/owner-yaml.mjs +207 -14
  211. package/scripts/lib/platform.mjs +108 -15
  212. package/scripts/lib/plugin-update-banner.mjs +406 -0
  213. package/scripts/lib/project-hygiene.mjs +38 -2
  214. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  215. package/scripts/lib/quality-gate.mjs +133 -44
  216. package/scripts/lib/reconcile/emitter.mjs +68 -6
  217. package/scripts/lib/reconcile/engine.mjs +13 -4
  218. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  219. package/scripts/lib/reconcile/writer.mjs +40 -18
  220. package/scripts/lib/session-close-backfill.mjs +67 -9
  221. package/scripts/lib/session-id.mjs +12 -23
  222. package/scripts/lib/session-identity/own-session.mjs +125 -10
  223. package/scripts/lib/session-lock-shape.mjs +43 -0
  224. package/scripts/lib/session-lock.mjs +5 -10
  225. package/scripts/lib/session-registry.mjs +25 -9
  226. package/scripts/lib/session-schema/constants.mjs +36 -2
  227. package/scripts/lib/session-schema/validator.mjs +38 -4
  228. package/scripts/lib/session-start-probes.mjs +18 -1
  229. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  230. package/scripts/lib/skill-health/join.mjs +17 -4
  231. package/scripts/lib/state-md.mjs +78 -0
  232. package/scripts/lib/sunset/walker.mjs +6 -0
  233. package/scripts/lib/telemetry/schema.mjs +181 -9
  234. package/scripts/lib/telemetry/sync.mjs +368 -12
  235. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  236. package/scripts/lib/validate/check-agents.mjs +3 -3
  237. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  238. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  239. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  240. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  241. package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
  242. package/scripts/lib/validate/check-unwired-features.mjs +0 -2
  243. package/scripts/lib/validate/check-validator-registration.mjs +10 -4
  244. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  245. package/scripts/lib/vault-backfill/template.mjs +63 -6
  246. package/scripts/lib/vault-mirror/process.mjs +165 -42
  247. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  248. package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
  249. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  250. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  251. package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
  252. package/scripts/lib/wave-resource-gate.mjs +8 -2
  253. package/scripts/lib/wave-sizing.mjs +4 -1
  254. package/scripts/lib/wave-transcript-tail.mjs +118 -4
  255. package/scripts/materialize-wave-scope.mjs +12 -5
  256. package/scripts/memory-propose.mjs +19 -5
  257. package/scripts/migrate-cold-start-seed.mjs +4 -1
  258. package/scripts/parse-config.mjs +60 -3
  259. package/scripts/release.mjs +337 -29
  260. package/scripts/repair-invalid-sessions.mjs +3 -3
  261. package/scripts/run-quality-gate.mjs +128 -11
  262. package/scripts/sweep-expired-learnings.mjs +90 -0
  263. package/scripts/sync-vault-schema.mjs +3 -1
  264. package/scripts/telemetry.mjs +2 -2
  265. package/scripts/validate-plugin.mjs +161 -0
  266. package/scripts/validate-wave-scope.mjs +28 -8
  267. package/scripts/wave-scope-binding.mjs +215 -0
  268. package/skills/_shared/instruction-file-resolution.md +10 -0
  269. package/skills/_shared/parallel-aware-preamble.md +1 -0
  270. package/skills/_shared/platform-tools.md +1 -1
  271. package/skills/_shared/state-ownership.md +1 -1
  272. package/skills/architecture/SKILL.md +7 -5
  273. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  274. package/skills/autopilot/SKILL.md +4 -18
  275. package/skills/claude-md-drift-check/SKILL.md +5 -1
  276. package/skills/claude-md-drift-check/checker.mjs +62 -2
  277. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  278. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  279. package/skills/discovery/probes-arch.md +20 -18
  280. package/skills/dispatcher/SKILL.md +3 -2
  281. package/skills/evolve/SKILL.md +65 -26
  282. package/skills/frontmatter-guard/SKILL.md +11 -5
  283. package/skills/npm-publish/SKILL.md +1 -1
  284. package/skills/reconcile/SKILL.md +33 -0
  285. package/skills/remote-offload/SKILL.md +1 -1
  286. package/skills/session-end/SKILL.md +18 -905
  287. package/skills/session-end/phase-3-6-tail.md +10 -3
  288. package/skills/session-end/plan-verification.md +221 -155
  289. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  290. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  291. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  292. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  293. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  294. package/skills/session-end/references/session-summary-template.md +62 -0
  295. package/skills/session-plan/SKILL.md +49 -0
  296. package/skills/session-start/SKILL.md +22 -904
  297. package/skills/session-start/phase-8-5-express-path.md +1 -1
  298. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  299. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  300. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  301. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  302. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  303. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  304. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  305. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  306. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  307. package/skills/vault-sync/validator.mjs +21 -27
  308. package/skills/wave-executor/SKILL.md +15 -1
  309. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  310. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  311. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  312. package/skills/wave-executor/wave-loop.md +14 -1309
  313. package/templates/_shared/journey-manifest.md +10 -6
  314. package/.cursor/commands/autopilot-multi.md +0 -14
  315. package/.cursor/commands/contract-version-bump.md +0 -14
  316. package/.cursor/commands/journey-audit.md +0 -14
  317. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  318. package/.cursor/skills/daily/SKILL.md +0 -12
  319. package/.cursor/skills/domain-model/SKILL.md +0 -13
  320. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  321. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  322. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  323. package/commands/autopilot-multi.md +0 -74
  324. package/commands/contract-version-bump.md +0 -28
  325. package/commands/journey-audit.md +0 -43
  326. package/pi/prompts/autopilot-multi.md +0 -12
  327. package/pi/prompts/contract-version-bump.md +0 -12
  328. package/pi/prompts/journey-audit.md +0 -12
  329. package/scripts/autopilot-multi.mjs +0 -885
  330. package/scripts/backfill-learnings-expires.mjs +0 -196
  331. package/scripts/backfill-learnings.mjs +0 -203
  332. package/scripts/fleet-instruction-scan.mjs +0 -141
  333. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  334. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  335. package/scripts/lib/webhook-url.mjs +0 -105
  336. package/scripts/lifecycle-sim-v6.mjs +0 -347
  337. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  338. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  339. package/scripts/upload-social-preview.mjs +0 -316
  340. package/skills/_shared/model-selection.md +0 -64
  341. package/skills/contract-version-bump/SKILL.md +0 -219
  342. package/skills/daily/SKILL.md +0 -222
  343. package/skills/daily/generate.sh +0 -92
  344. package/skills/daily/templates/daily.md.tpl +0 -36
  345. package/skills/journey-audit/SKILL.md +0 -270
  346. package/skills/skill-creator/SKILL.md +0 -168
  347. package/skills/ubiquitous-language/SKILL.md +0 -97
  348. package/skills/vault-sync/package-lock.json +0 -40
  349. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  350. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -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.
@@ -41,7 +41,7 @@
41
41
 
42
42
  import { promises as fs, existsSync, readFileSync } from 'node:fs';
43
43
  import path from 'node:path';
44
- import { SO_PROJECT_DIR, SO_SHARED_DIR } from './platform.mjs';
44
+ import { getProjectDir, SO_SHARED_DIR } from './platform.mjs';
45
45
  import { readLock } from './session-lock.mjs';
46
46
  import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
47
47
  import {
@@ -70,7 +70,7 @@ import {
70
70
  * @param {string} [repoRoot=SO_PROJECT_DIR] — project root the events log lives under.
71
71
  * @returns {string}
72
72
  */
73
- export function eventsFilePath(repoRoot = SO_PROJECT_DIR) {
73
+ export function eventsFilePath(repoRoot = getProjectDir()) {
74
74
  return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
75
75
  }
76
76
 
@@ -152,10 +152,26 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
152
152
  * if rotation is common, the lock must be refreshed on rotation rather than
153
153
  * this comparison widened.
154
154
  *
155
+ * **THE manifest-binding writer contract (#1207).** This function is not only
156
+ * `emitEvent()`'s correlation fill — it is the canonical primitive for every
157
+ * caller that needs to name a `.orchestrator/`-adjacent artefact as "mine"
158
+ * without risking a peer's id. `skills/wave-executor/wave-loop.md` § Scope
159
+ * Manifest step 1 calls it directly to derive `wave-scope.json`'s `session_id` /
160
+ * `semantic_session_id` binding — a hand-written prose comparison against
161
+ * STATE.md previously stood in for exactly this check, and (per the STATE.md
162
+ * caveat above) that comparison could not veto a peer-owned lock. Any new
163
+ * writer facing the same "is this working-copy-shared artefact mine to
164
+ * stamp?" question should call this function rather than re-deriving the
165
+ * raw-id-vs-process-local comparison inline (see `scripts/memory-propose.mjs`
166
+ * `resolveRunningWaveId()` for a case that reads the SAME lock but needs the
167
+ * semantic id plus diagnostic detail this function's `{}`-on-any-mismatch
168
+ * contract intentionally does not expose, and keeps its own comparison for
169
+ * that reason).
170
+ *
155
171
  * @param {string} [root=SO_PROJECT_DIR] — the repo the record is pinned to.
156
172
  * @returns {{session_id?: string, semantic_session_id?: string}}
157
173
  */
158
- export function attributionForRecord(root = SO_PROJECT_DIR) {
174
+ export function attributionForRecord(root = getProjectDir()) {
159
175
  const attribution = sessionAttribution(root);
160
176
  const lockRawId =
161
177
  typeof attribution.session_id === 'string' ? attribution.session_id.trim() : '';
@@ -209,8 +225,8 @@ function waveScopePath(root) {
209
225
  * keys, for the same reason (a shared file cannot prove which process emits):
210
226
  *
211
227
  * - manifest classified `own` → fill (as a NUMBER, see below).
212
- * - anything else → omit. That includes an UNBOUND manifest (no `session` /
213
- * `semantic_session`): since #1123 BOTH writers stamp the binding, so a
228
+ * - anything else → omit. That includes an UNBOUND manifest (no `session_id` /
229
+ * `semantic_session_id`): since #1123 BOTH writers stamp the binding, so a
214
230
  * manifest without one is a peer's or a stale artefact, never a legacy own
215
231
  * one. It also includes `unknown` because we cannot resolve our own
216
232
  * identity — stricter than `classifyManifestSession()`'s own `unknown`
@@ -298,7 +314,7 @@ export async function emitEvent(type, payload = {}, opts = {}) {
298
314
  // A payload that supplies EITHER session key suppresses BOTH: mixing a
299
315
  // caller's `session_id` with a lock-derived `semantic_session_id` would
300
316
  // silently produce a record whose two id fields name different sessions.
301
- const attributionRoot = opts.repoRoot ?? SO_PROJECT_DIR;
317
+ const attributionRoot = opts.repoRoot ?? getProjectDir();
302
318
  const correlation = {};
303
319
  if (payload.session_id === undefined && payload.semantic_session_id === undefined) {
304
320
  Object.assign(correlation, attributionForRecord(attributionRoot));
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Frontmatter-Guard library (issue #328).
3
3
  *
4
- * Reads the canonical vault-frontmatter Zod schema source and exposes helpers
4
+ * Reads the canonical vault-frontmatter Zod schema source when a
5
+ * projects-baseline checkout is reachable on this host — and exposes helpers
5
6
  * for generating a contextual schema snippet that can be injected into agent
6
7
  * prompts before vault-write tasks.
7
8
  *
@@ -10,15 +11,103 @@
10
11
  */
11
12
 
12
13
  import { digestSha256Short } from './crypto-digest-utils.mjs';
14
+ import { resolveHostPath } from './config/host-paths.mjs';
13
15
  import { readFileSync, statSync } from 'node:fs';
14
16
  import { homedir } from 'node:os';
15
- import { join } from 'node:path';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
16
19
 
17
- /** Absolute path to the canonical vault-frontmatter schema source. */
18
- const SCHEMA_SOURCE_PATH = join(
19
- homedir(),
20
- 'Projects/projects-baseline/packages/zod-schemas/src/vault-frontmatter.ts',
21
- );
20
+ /** Path of the schema source RELATIVE to a projects-baseline checkout root. */
21
+ const SCHEMA_REL_PATH = 'packages/zod-schemas/src/vault-frontmatter.ts';
22
+
23
+ /** This file lives at `<repoRoot>/scripts/lib/` — two levels up is the repo root. */
24
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
25
+
26
+ /**
27
+ * Candidate projects-baseline checkout roots.
28
+ *
29
+ * The baseline is OPTIONAL and PRIVATE (see `docs/baseline.md`), so this list
30
+ * must never assume a particular operator layout — it carries no host-specific
31
+ * directory names.
32
+ *
33
+ * When the host-local override (`SO_BASELINE_PATH` / `owner.yaml`
34
+ * `paths.baseline-path`) is set it is used ALONE: probing past a wrong explicit
35
+ * value would silently read a DIFFERENT baseline than the one named. Degrading
36
+ * to `null` (and the documented fallback enum set) is the honest outcome there.
37
+ * Only when nothing is configured do the two CONVENTIONS apply — the sibling
38
+ * checkout `scripts/sync-vault-schema.mjs` already uses, then the legacy
39
+ * `~/Projects` default this module shipped with.
40
+ *
41
+ * @returns {string[]}
42
+ */
43
+ function baselineCandidates() {
44
+ const configured = resolveHostPath('baseline-path', null);
45
+ if (typeof configured === 'string' && configured.trim() !== '') return [configured.trim()];
46
+ return [
47
+ resolve(REPO_ROOT, '..', 'projects-baseline'),
48
+ join(homedir(), 'Projects', 'projects-baseline'),
49
+ ];
50
+ }
51
+
52
+ /** @type {{ resolved: boolean, value: string|null }} */
53
+ const _pathCache = { resolved: false, value: null };
54
+
55
+ /**
56
+ * Resolve the canonical vault-frontmatter schema source, or `null` when no
57
+ * baseline checkout is reachable on this host.
58
+ *
59
+ * Ceiling: at most two `statSync` calls per invocation, and the result is
60
+ * memoised for the process lifetime. Revisit if the candidate list ever grows
61
+ * past a handful of entries — then it needs a real search, not a probe loop.
62
+ *
63
+ * @param {{ refresh?: boolean }} [opts] — `refresh: true` re-probes (tests only)
64
+ * @returns {string|null}
65
+ */
66
+ export function resolveSchemaSourcePath({ refresh = false } = {}) {
67
+ if (!refresh && _pathCache.resolved) return _pathCache.value;
68
+ let found = null;
69
+ for (const base of baselineCandidates()) {
70
+ const candidate = join(base, SCHEMA_REL_PATH);
71
+ try {
72
+ if (statSync(candidate).isFile()) {
73
+ found = candidate;
74
+ break;
75
+ }
76
+ } catch {
77
+ /* candidate absent — try the next one */
78
+ }
79
+ }
80
+ _pathCache.resolved = true;
81
+ _pathCache.value = found;
82
+ return found;
83
+ }
84
+
85
+ /**
86
+ * Fallback enum/field set used when no baseline checkout is reachable.
87
+ *
88
+ * Values mirror `skills/vault-sync/validator.mjs` (`vaultNoteTypeSchema` /
89
+ * `vaultNoteStatusSchema`), which is this repo's own in-tree copy of the
90
+ * canonical schema and is what `vault-sync` actually validates against. Using
91
+ * it means a baseline-less host injects a snippet the local validator accepts,
92
+ * rather than throwing.
93
+ */
94
+ const FALLBACK_SCHEMA = Object.freeze({
95
+ typeEnum: Object.freeze([
96
+ 'note', 'daily', 'project', 'person', 'reference',
97
+ 'idea', 'learning', 'session', 'peer-card', 'board',
98
+ ]),
99
+ statusEnum: Object.freeze([
100
+ 'draft', 'active', 'verified', 'archived', 'production',
101
+ 'mvp', 'idea', 'maintenance', 'planned', 'paused', 'dead',
102
+ ]),
103
+ requiredFields: Object.freeze(['id', 'type', 'created', 'updated']),
104
+ idRegex: '^[a-z0-9]+(?:-[a-z0-9]+)*$',
105
+ tagsRegex: '^[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*$',
106
+ schemaText: null,
107
+ });
108
+
109
+ /** One WARN per process, not one per call — the condition is constant. */
110
+ let _warnedFallback = false;
22
111
 
23
112
  /**
24
113
  * In-memory mtime cache so repeated calls within a single process invocation
@@ -93,9 +182,12 @@ function _parseSchema(text) {
93
182
  * @returns {{ typeEnum: string[], statusEnum: string[], requiredFields: string[], idRegex: string, tagsRegex: string, schemaText: string } | null}
94
183
  */
95
184
  export function readVaultSchema() {
185
+ const sourcePath = resolveSchemaSourcePath();
186
+ if (sourcePath === null) return null;
187
+
96
188
  let mtime;
97
189
  try {
98
- mtime = statSync(SCHEMA_SOURCE_PATH).mtimeMs;
190
+ mtime = statSync(sourcePath).mtimeMs;
99
191
  } catch {
100
192
  // File missing or inaccessible
101
193
  return null;
@@ -107,7 +199,7 @@ export function readVaultSchema() {
107
199
 
108
200
  let text;
109
201
  try {
110
- text = readFileSync(SCHEMA_SOURCE_PATH, 'utf8');
202
+ text = readFileSync(sourcePath, 'utf8');
111
203
  } catch {
112
204
  return null;
113
205
  }
@@ -122,10 +214,17 @@ export function readVaultSchema() {
122
214
  * Compute an 8-character SHA-256 hex prefix of the given schema source text.
123
215
  * Stable across calls for the same input — useful as a cache-busting token.
124
216
  *
125
- * @param {string} schemaText
126
- * @returns {string}
217
+ * Returns `null` when there is NO schema text (absent baseline checkout,
218
+ * `readVaultSchema()` → `null`). Hashing "nothing" previously produced
219
+ * `e3b0c442` — the SHA-256 of the empty string — which is a real-looking token
220
+ * that compares equal across every baseline-less host, so a cache keyed on it
221
+ * would report "schema unchanged" while having measured nothing at all.
222
+ *
223
+ * @param {string|null|undefined} schemaText
224
+ * @returns {string|null} 8-char hex prefix, or `null` when there is no schema
127
225
  */
128
226
  export function computeSchemaHash(schemaText) {
227
+ if (typeof schemaText !== 'string' || schemaText.length === 0) return null;
129
228
  return digestSha256Short(schemaText);
130
229
  }
131
230
 
@@ -133,11 +232,30 @@ export function computeSchemaHash(schemaText) {
133
232
  * Generate a deterministic Markdown snippet documenting the vault frontmatter
134
233
  * schema, suitable for injection into agent prompts before vault-write tasks.
135
234
  *
136
- * @param {{ typeEnum: string[], statusEnum: string[], requiredFields: string[], idRegex: string, tagsRegex: string }} schema
235
+ * Degrades instead of throwing when no schema is available: a host without a
236
+ * projects-baseline checkout gets `readVaultSchema() === null`, and destructuring
237
+ * that killed the caller with `Cannot destructure property 'typeEnum' of
238
+ * 'schema' as it is undefined`. The guard below falls back to FALLBACK_SCHEMA
239
+ * and warns ONCE on stderr — an injected snippet that is one schema-version
240
+ * behind is worth incomparably more than a crashed pre-dispatch hook.
241
+ *
242
+ * @param {{ typeEnum: string[], statusEnum: string[], requiredFields: string[], idRegex: string, tagsRegex: string }|null} [schema]
137
243
  * @returns {string}
138
244
  */
139
245
  export function generateFrontmatterSnippet(schema) {
140
- const { typeEnum, statusEnum, requiredFields, idRegex, tagsRegex: _tagsRegex } = schema;
246
+ let source = schema;
247
+ if (source === null || typeof source !== 'object') {
248
+ if (!_warnedFallback) {
249
+ _warnedFallback = true;
250
+ process.stderr.write(
251
+ 'frontmatter-guard: no projects-baseline schema reachable — using the in-module ' +
252
+ 'fallback enum set (see docs/baseline.md). Set SO_BASELINE_PATH or ' +
253
+ 'owner.yaml paths.baseline-path to read the canonical schema.\n',
254
+ );
255
+ }
256
+ source = FALLBACK_SCHEMA;
257
+ }
258
+ const { typeEnum, statusEnum, requiredFields, idRegex, tagsRegex: _tagsRegex } = source;
141
259
 
142
260
  const typeList = typeEnum.map((v) => `\`${v}\``).join(' | ');
143
261
  const statusList = statusEnum.map((v) => `\`${v}\``).join(' | ');
@@ -9,6 +9,7 @@ import {
9
9
  runCheck,
10
10
  extractCount,
11
11
  extractTestCounts,
12
+ extractFailedTestFiles,
12
13
  collectDebugArtifacts,
13
14
  } from './gate-helpers.mjs';
14
15
 
@@ -64,11 +65,26 @@ const testCounts =
64
65
  // from a measured one — with `suite_died: false` derived from it, stating a
65
66
  // verdict nobody had checked. Absent, not zero: the same contract this
66
67
  // envelope's `counts` field already keeps (`admitSuiteCounts`).
68
+ //
69
+ // `failed_files` (this change) is the fifth member of that same set and joins
70
+ // it for the same reason: it is DERIVED from the runner's file-level report, so
71
+ // it is published exactly when the file-level measurement exists. A count with
72
+ // no name is what made the 2026-09-06 pre-push block unusable —
73
+ // `files_failed: 1` out of 662, and reconstructing WHICH file cost a manual
74
+ // re-materialisation of the tracked tree. An EMPTY array here is meaningful and
75
+ // is NOT the absent case: it says the file-level summary was parsed and no path
76
+ // could be read out of it (a non-vitest reporter, a truncated capture), which
77
+ // is a parser gap worth seeing — the unmeasured case is the absent key.
78
+ const failedTestFiles = testResult.status === 'fail'
79
+ ? extractFailedTestFiles(testResult.fullOutput ?? testResult.output ?? '')
80
+ : [];
81
+
67
82
  const fileFields = testCounts.files
68
83
  ? {
69
84
  files_total: testCounts.files.total,
70
85
  files_passed: testCounts.files.passed,
71
86
  files_failed: testCounts.files.failed,
87
+ failed_files: failedTestFiles,
72
88
  // Self-diagnosing: true exactly when `status: 'fail'` sits beside a
73
89
  // test-case `failed: 0` that a file-level failure explains. Greppable —
74
90
  // a consumer no longer has to recompute the contradiction by hand.
@@ -154,6 +170,16 @@ const failed = [tcResult, testResult, lintResult].some(
154
170
  // of silence" the hook promises still holds; and the operator gets the failure
155
171
  // at the moment of the block instead of a second run to find it.
156
172
  if (failed) {
173
+ // Names FIRST, raw output after. Under the pre-push hook the raw block is
174
+ // hundreds of lines; the operator reads the top of it, and the one fact he
175
+ // needs to act (`npx vitest run <file>`) must not sit at the bottom.
176
+ if (failedTestFiles.length > 0) {
177
+ process.stderr.write(
178
+ `\n──── failing test files (${failedTestFiles.length}) ────\n` +
179
+ failedTestFiles.map((f) => ` ${f}\n`).join('') +
180
+ ` reproduce: npx vitest run ${failedTestFiles.join(' ')}\n`,
181
+ );
182
+ }
157
183
  for (const [name, result] of [
158
184
  ['typecheck', tcResult],
159
185
  ['test', testResult],