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
@@ -67,12 +67,31 @@ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs'
67
67
  // Constants
68
68
  // ---------------------------------------------------------------------------
69
69
 
70
- /** session_type enum accepted by the schema — lock.mode is coerced against it. */
71
- const VALID_SESSION_TYPES = new Set(['feature', 'deep', 'housekeeping']);
70
+ /**
71
+ * The three real session MODES — deliberately NOT the schema's VALID_SESSION_TYPES,
72
+ * which since GitLab #1234 also carries `unknown`. This set answers a narrower
73
+ * question: "is `gathered.mode` a MEASUREMENT?". `unknown` must never pass it, or
74
+ * an events record carrying `mode: 'unknown'` would be recorded as a measured type
75
+ * (`_session_type_inferred` absent) when it is the opposite.
76
+ */
77
+ const MEASURED_SESSION_MODES = new Set(['feature', 'deep', 'housekeeping']);
78
+
79
+ /**
80
+ * session_type written when nothing in events.jsonl measured the mode
81
+ * (GitLab #1234). Always paired with `_session_type_inferred` + `_synthetic`.
82
+ */
83
+ const UNMEASURED_SESSION_TYPE = 'unknown';
72
84
 
73
85
  const EVENT_STARTED = 'orchestrator.session.started';
74
86
  const EVENT_LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
87
+ // Both names for one generation (GitLab #1234): `hooks/on-stop.mjs` now emits
88
+ // `orchestrator.turn.stopped` as the canonical name and keeps the legacy
89
+ // `orchestrator.session.stopped` (with `deprecated: true`) beside it until
90
+ // 2027-03-06. A terminal-event probe must accept EITHER, or every session that
91
+ // closes after the legacy name is dropped silently loses its attested end and
92
+ // falls back to the flagged `lastEventMs` estimate.
75
93
  const EVENT_STOPPED = 'orchestrator.session.stopped';
94
+ const EVENT_TURN_STOPPED = 'orchestrator.turn.stopped';
76
95
  const EVENT_ENDED = 'orchestrator.session.ended';
77
96
 
78
97
  const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
@@ -108,14 +127,24 @@ function markerName(id) {
108
127
  }
109
128
 
110
129
  /**
111
- * Read a JSONL file into an array of parsed objects. Missing file → []; each
112
- * malformed line is skipped rather than aborting the whole read. Never throws.
130
+ * Read a JSONL file into an array of parsed objects. Missing file (ENOENT)
131
+ * [] silently; an unreadable one (EACCES/EISDIR/…) [] with a stderr WARN
132
+ * (#1210 — ENOENT and other read failures are different facts, same split as
133
+ * `sessions-canonical.mjs` `readCanonicalSessions`). Each malformed line is
134
+ * skipped rather than aborting the whole read. Never throws.
113
135
  */
114
136
  function readJsonlSafe(readFileSync, filePath) {
115
137
  let raw;
116
138
  try {
117
139
  raw = readFileSync(filePath, 'utf8');
118
- } catch {
140
+ } catch (err) {
141
+ if (!err || err.code !== 'ENOENT') {
142
+ process.stderr.write(
143
+ `⚠ readJsonlSafe: cannot read ${filePath} ` +
144
+ `(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
145
+ 'treating as EMPTY, counts below are floors\n',
146
+ );
147
+ }
119
148
  return [];
120
149
  }
121
150
  const out = [];
@@ -207,7 +236,7 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
207
236
  if (typeof ev.timestamp === 'string') startedAt = ev.timestamp;
208
237
  if (typeof ev.branch === 'string' && ev.branch.length > 0) branch = ev.branch;
209
238
  if (typeof ev.project === 'string') project = ev.project;
210
- } else if (ev.event === EVENT_STOPPED || ev.event === EVENT_ENDED) {
239
+ } else if (ev.event === EVENT_STOPPED || ev.event === EVENT_TURN_STOPPED || ev.event === EVENT_ENDED) {
211
240
  if (!Number.isNaN(ts)) {
212
241
  lastTerminalMs = lastTerminalMs === null ? ts : Math.max(lastTerminalMs, ts);
213
242
  }
@@ -307,9 +336,9 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
307
336
  // Guard the same monotonic invariant as before: never earlier than started_at.
308
337
  const completedIso = new Date(Math.max(startedMs, completedMs)).toISOString();
309
338
 
310
- let sessionType = 'housekeeping';
339
+ let sessionType = UNMEASURED_SESSION_TYPE;
311
340
  let inferred = true;
312
- if (gathered.mode && VALID_SESSION_TYPES.has(gathered.mode)) {
341
+ if (gathered.mode && MEASURED_SESSION_MODES.has(gathered.mode)) {
313
342
  sessionType = gathered.mode;
314
343
  inferred = false;
315
344
  }
@@ -346,7 +375,36 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
346
375
  _backfill_incomplete_fields: incomplete,
347
376
  };
348
377
  if (branchFound) record.branch = gathered.branch;
349
- if (inferred) record._session_type_inferred = true;
378
+ if (inferred) {
379
+ record._session_type_inferred = true;
380
+ // GitLab #1234 — BACKFILLER HONESTY, half landed 2026-09-06.
381
+ //
382
+ // `session_type` above is now `'unknown'`, not the old `'housekeeping'`
383
+ // DEFAULT that nothing in events.jsonl ever said. Measured 2026-09-06, all
384
+ // 1.656 `abandoned` records in the 90-day fleet window carry
385
+ // `_session_type_inferred: true` + `total_waves: 0`, and NO organically
386
+ // written `abandoned` record exists anywhere — that `housekeeping` guess is
387
+ // what produced the "27 % close rate" figure that turned out to be an
388
+ // artefact (the real rate is 21,3 %). VALID_SESSION_TYPES was widened with
389
+ // `unknown` in `scripts/lib/session-schema/constants.mjs` to make this
390
+ // sayable; historical records keep their `housekeeping` label verbatim
391
+ // (sessions.jsonl is append-only), so a reader wanting the honest
392
+ // population still filters on `_synthetic !== true` rather than on the type.
393
+ //
394
+ // The OTHER half is deliberately NOT landed: `status` stays `'abandoned'`
395
+ // even though `'unresolved'` is the honest word and the schema now accepts
396
+ // it. Six executable phantom-stub filters key on the literal `abandoned`
397
+ // (census + revisit trigger in `scripts/lib/session-schema/validator.mjs`
398
+ // § SESSION_STATUS) and none of them is in this change's file scope —
399
+ // flipping the emitter first would make every new stub invisible to all six
400
+ // and re-open the #834 phantom-in-signal class fleet-wide. Repoint those
401
+ // filters onto one both-accepting predicate, then flip this one line.
402
+ //
403
+ // `_synthetic: true` still carries the claim no enum can: this record was
404
+ // COMPOSED. A consumer that filters on `_synthetic !== true` gets only
405
+ // measured records without needing to know either enum.
406
+ record._synthetic = true;
407
+ }
350
408
  if (synthetic) record._synthetic_session_id = true;
351
409
  if (completedEstimated) record._completed_at_estimated = true;
352
410
  // #1068 AC3/AC4 — forensic supersede marker. sessions.jsonl is append-only,
@@ -42,6 +42,7 @@
42
42
  import { open, readFile } from 'node:fs/promises';
43
43
  import path from 'node:path';
44
44
 
45
+ import { readCanonicalSessions } from './sessions-canonical.mjs';
45
46
  import { withStateMdLock } from './session-lock.mjs';
46
47
  import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
47
48
  import { parseStateMd } from './state-md/yaml-parser.mjs';
@@ -127,34 +128,22 @@ function isValidMode(mode) {
127
128
  * - Malformed JSONL line → silently skipped (per-line try/catch).
128
129
  * - Lines without a string `session_id` field → filtered out.
129
130
  *
130
- * Performance note: sessions.jsonl is line-oriented but typically <100 KB.
131
- * A single readFile is faster than line-streaming at this size. Should the
132
- * file grow past ~5 MB a future change can swap to a `readline` stream with
133
- * early-exit; not a launch blocker.
131
+ * #1209: migrated to `readCanonicalSessions` (dedup per `session_id`, drop
132
+ * id-less lines same three rules `sessions-canonical.mjs` documents).
133
+ * Number-neutral for THIS reader: the only consumer of its output
134
+ * (`resolveSemanticSessionId`) folds `candidateIds` through `Math.max` over
135
+ * the parsed `n` — "Duplicates are fine — Math.max handles them" (see that
136
+ * function's body) — so collapsing a duplicate `session_id` line to one
137
+ * record changes nothing observable here. Migrated anyway for one collapse
138
+ * implementation across all `sessions.jsonl` raw-line-scan readers (#1209).
134
139
  *
135
140
  * @param {string} repoRoot
136
- * @returns {Promise<string[]>} Array of session_id strings (may include duplicates).
141
+ * @returns {Promise<string[]>} Array of session_id strings (may include duplicates
142
+ * only across DIFFERENT ids — never the same id twice, see above).
137
143
  */
138
144
  async function readSessionIdsFromHistory(repoRoot) {
139
145
  const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
140
- let raw;
141
- try {
142
- raw = await readFile(filePath, 'utf8');
143
- } catch {
144
- return [];
145
- }
146
- const ids = [];
147
- for (const line of raw.split(/\r?\n/)) {
148
- const trimmed = line.trim();
149
- if (trimmed === '') continue;
150
- try {
151
- const parsed = JSON.parse(trimmed);
152
- if (typeof parsed?.session_id === 'string') ids.push(parsed.session_id);
153
- } catch {
154
- // Malformed line — skip silently (audit §3.1 robustness contract).
155
- }
156
- }
157
- return ids;
146
+ return readCanonicalSessions({ filePath }).map((rec) => rec.session_id);
158
147
  }
159
148
 
160
149
  /**
@@ -26,7 +26,49 @@
26
26
  * session id.
27
27
  */
28
28
 
29
- import { readLock } from '../session-lock.mjs';
29
+ import { readFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ import { isLockShape } from '../session-lock-shape.mjs';
32
+
33
+ /**
34
+ * Read the two session ids out of `<repoRoot>/.orchestrator/session.lock`.
35
+ *
36
+ * A deliberate, behaviour-identical stand-in for `readLock()` from
37
+ * `../session-lock.mjs` (#1153 P7): that import drags a static closure of six
38
+ * modules (session-lock → exclusivity-matrix, file-lock, io, host-identity,
39
+ * crypto-digest-utils — measured 2026-09-04 by following its `^import` lines)
40
+ * into every consumer of this module, of which the live
41
+ * `hooks/enforce-scope.mjs` runs on EVERY Edit/Write. Two strings do not need
42
+ * a lock manager.
43
+ *
44
+ * Tolerance is matched to `readLock()` exactly, which collapses every non-ok
45
+ * outcome of `readLockDetailed()` to `null`: missing file, unreadable file,
46
+ * invalid JSON, and **valid JSON that fails the lock schema** all yield `{}`
47
+ * here. The schema check is not reproduced but IMPORTED — `isLockShape()` from
48
+ * `../session-lock-shape.mjs` is the same predicate `parseLock()` applies, and
49
+ * that module imports nothing, so sharing it costs the hook chain no closure.
50
+ * A copy would have been free to drift the fail-OPEN way: a relaxed
51
+ * `parseLock` plus an unchanged copy here drops the lock tier's ids, the own
52
+ * manifest reads `foreign`, and enforcement switches itself off silently.
53
+ *
54
+ * Never throws.
55
+ *
56
+ * @param {string} repoRoot
57
+ * @returns {{ session_id?: string, semantic_session_id?: string }}
58
+ */
59
+ function readLockIds(repoRoot) {
60
+ try {
61
+ const raw = readFileSync(
62
+ path.join(repoRoot ?? process.cwd(), '.orchestrator', 'session.lock'),
63
+ 'utf8',
64
+ );
65
+ const obj = JSON.parse(raw);
66
+ if (!isLockShape(obj)) return {};
67
+ return { session_id: obj.session_id, semantic_session_id: obj.semantic_session_id };
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
30
72
 
31
73
  /**
32
74
  * The set of session ids that provably name THIS session — the UNION of every
@@ -112,10 +154,10 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
112
154
 
113
155
  // Source 3 — repo-global lock file (the manifest writer's own identity).
114
156
  try {
115
- const lock = readLock({ repoRoot });
157
+ const lock = readLockIds(repoRoot);
116
158
  for (const key of ['session_id', 'semantic_session_id']) add(lock?.[key]);
117
159
  } catch {
118
- /* readLock never throws by contract, but that contract is not ours to trust */
160
+ /* readLockIds never throws by contract, but that contract is not ours to trust */
119
161
  }
120
162
  return ids;
121
163
  }
@@ -190,9 +232,11 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
190
232
  * - `'own'` — an id matched.
191
233
  *
192
234
  * Both id fields are consulted because they address the same session under two
193
- * naming schemes: `session` is the raw harness session id (a UUID on Claude
194
- * Code), `semantic_session` the `<branch>-<date>-<mode>-<n>` form. A harness
235
+ * naming schemes: `session_id` is the raw harness session id (a UUID on Claude
236
+ * Code), `semantic_session_id` the `<branch>-<date>-<mode>-<n>` form. A harness
195
237
  * that resolves only the semantic one must still recognise its own manifest.
238
+ * The pre-#1153 spellings `session` / `semantic_session` are still READ (see
239
+ * {@link MANIFEST_SESSION_KEYS}).
196
240
  *
197
241
  * @param {unknown} scope — parsed wave-scope manifest (any shape; a non-object
198
242
  * simply yields no ids, hence `'unknown'`).
@@ -204,14 +248,85 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
204
248
  * latter returns a `string[]`: a bare array is NOT a Set and folds to the
205
249
  * empty set below, yielding `'unknown'` for every manifest.
206
250
  * @returns {{ verdict: 'own'|'foreign'|'unknown', manifestIds: string[] }}
251
+ * @see MANIFEST_SESSION_KEYS / {@link manifestSessionBinding} — defined
252
+ * immediately below rather than above this doc comment, because the three
253
+ * live hooks import this module on EVERY tool call: a use-before-define here
254
+ * throws inside the hook chain and locks every session sharing the checkout
255
+ * out of Edit/Write/Bash (measured 2026-09-04, ~8 minutes, #1153 P2).
207
256
  */
257
+ /**
258
+ * The session-binding key names of a `wave-scope.json` manifest — the ONE place
259
+ * these literals live (#1153 P2). Every reader imports them from here instead
260
+ * of repeating the strings: `scripts/materialize-wave-scope.mjs`,
261
+ * `scripts/validate-wave-scope.mjs`, `scripts/memory-propose.mjs`, and — via
262
+ * {@link classifyManifestSession} — `scripts/lib/events.mjs` plus the three
263
+ * live hooks.
264
+ *
265
+ * `current` are the canonical names, chosen to match the two neighbouring
266
+ * session artefacts a reader already knows: `.orchestrator/session.lock` and
267
+ * `current-session.json` both spell them `session_id` / `semantic_session_id`,
268
+ * and `scripts/lib/quality-gate.mjs` reads that exact pair. One session
269
+ * identity should not carry two spellings depending on which file names it.
270
+ *
271
+ * `legacy` are the pre-#1153 spellings, and they are **accepted on the READ
272
+ * side only, until the next minor release**. `wave-scope.json` is git-ignored
273
+ * and session-ephemeral, so the one surviving reason to read them is a package
274
+ * upgraded mid-wave with an old-format manifest already on disk. The writer
275
+ * (`scripts/wave-scope-binding.mjs`) emits `current` exclusively.
276
+ *
277
+ * @type {Readonly<{ current: readonly string[], legacy: readonly string[] }>}
278
+ */
279
+ export const MANIFEST_SESSION_KEYS = Object.freeze({
280
+ current: Object.freeze(['session_id', 'semantic_session_id']),
281
+ legacy: Object.freeze(['session', 'semantic_session']),
282
+ });
283
+
284
+ /**
285
+ * Resolve the binding out of a manifest under BOTH key spellings. A non-object
286
+ * — including an ARRAY, which `typeof` calls `'object'` — yields no ids.
287
+ *
288
+ * **A CONFLICT yields no value for that slot, and that is fail-CLOSED.**
289
+ * When both spellings of one slot are present with different non-empty values,
290
+ * the manifest is self-contradictory (`scripts/validate-wave-scope.mjs` calls
291
+ * exactly this an ERROR — but no hook runs the validator, so the classifier is
292
+ * the only thing standing between the manifest and the guard). Preferring the
293
+ * current spelling let a peer DISARM this session's guard by appending
294
+ * `"session_id": "attacker"` beside a legitimate legacy `"session": "<me>"`:
295
+ * the slot then resolved to an id this session does not carry,
296
+ * {@link classifyManifestSession} returned `'foreign'`, and
297
+ * `hooks/enforce-scope.mjs` skips enforcement on `'foreign'`. Dropping the slot
298
+ * instead collapses the verdict to `'unknown'`, which every caller treats as
299
+ * "keep enforcing".
300
+ *
301
+ * Both present and EQUAL (after trim) → that value. Only one present → that
302
+ * value. Both present and different → the slot is omitted.
303
+ *
304
+ * @param {unknown} scope
305
+ * @returns {{ session_id?: string, semantic_session_id?: string }}
306
+ */
307
+ export function manifestSessionBinding(scope) {
308
+ const out = {};
309
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return out;
310
+ MANIFEST_SESSION_KEYS.current.forEach((key, i) => {
311
+ const legacyKey = MANIFEST_SESSION_KEYS.legacy[i];
312
+ const pick = (v) => (typeof v === 'string' && v.trim() ? v.trim() : '');
313
+ const current = pick(scope[key]);
314
+ const legacy = pick(scope[legacyKey]);
315
+ if (current && legacy && current !== legacy) return; // conflict → no value
316
+ const value = current || legacy;
317
+ if (value) out[key] = value;
318
+ });
319
+ return out;
320
+ }
321
+
322
+ /** @see the contract note above `MANIFEST_SESSION_KEYS` — the full docblock for
323
+ * this function sits there, separated from it only because the constants must
324
+ * be defined before use (#1153 P2). */
208
325
  export function classifyManifestSession(scope, ownIds) {
209
326
  const manifestIds = [];
210
- if (scope && typeof scope === 'object' && !Array.isArray(scope)) {
211
- for (const key of ['session', 'semantic_session']) {
212
- const value = typeof scope[key] === 'string' ? scope[key].trim() : '';
213
- if (value) manifestIds.push(value);
214
- }
327
+ const binding = manifestSessionBinding(scope);
328
+ for (const key of MANIFEST_SESSION_KEYS.current) {
329
+ if (binding[key]) manifestIds.push(binding[key]);
215
330
  }
216
331
  const own = ownIds instanceof Set ? ownIds : new Set();
217
332
  if (manifestIds.length === 0 || own.size === 0) return { verdict: 'unknown', manifestIds };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * session-lock-shape.mjs — the ONE predicate that decides whether a parsed
3
+ * JSON value is a `.orchestrator/session.lock` record (#1153 P7).
4
+ *
5
+ * **Zero imports on purpose.** One of the two consumers is
6
+ * `scripts/lib/session-identity/own-session.mjs`, which the live
7
+ * `hooks/enforce-scope.mjs` loads on EVERY Edit/Write; anything this module
8
+ * imported would join that hook's static closure. It therefore holds six
9
+ * `typeof` checks and nothing else.
10
+ *
11
+ * **The two consumers, and why they must not drift apart:**
12
+ *
13
+ * - `scripts/lib/session-lock.mjs` `parseLock()` — the lock manager's own
14
+ * reader; a value that fails this predicate is `null` there.
15
+ * - `scripts/lib/session-identity/own-session.mjs` `readLockIds()` — the
16
+ * dependency-free stand-in that reads the same two ids out of the same
17
+ * file without dragging the lock manager into a hook.
18
+ *
19
+ * The drift direction is FAIL-OPEN, which is why the predicate is shared
20
+ * rather than repeated: if `parseLock` relaxed the shape and `readLockIds` did
21
+ * not, the lock tier of {@link readOwnSessionIds} would stop contributing its
22
+ * ids, this session's own manifest would classify `foreign`, and
23
+ * `hooks/enforce-scope.mjs` would silently skip enforcement for the whole wave.
24
+ *
25
+ * @param {unknown} obj — a value already parsed from JSON.
26
+ * @returns {boolean} true when `obj` carries all six required lock fields with
27
+ * the right primitive types. `semantic_session_id` and `last_heartbeat` are
28
+ * deliberately NOT required — both are optional by schema (v1 locks predate
29
+ * `last_heartbeat`, and `semantic_session_id` is absent on harnesses that
30
+ * resolve no semantic id).
31
+ */
32
+ export function isLockShape(obj) {
33
+ return (
34
+ typeof obj === 'object' &&
35
+ obj !== null &&
36
+ typeof obj.session_id === 'string' &&
37
+ typeof obj.started_at === 'string' &&
38
+ typeof obj.mode === 'string' &&
39
+ typeof obj.pid === 'number' &&
40
+ typeof obj.host === 'string' &&
41
+ typeof obj.ttl_hours === 'number'
42
+ );
43
+ }
@@ -43,6 +43,7 @@ import os from 'node:os';
43
43
  import path from 'node:path';
44
44
  import crypto from 'node:crypto';
45
45
  import { classifyMode } from './exclusivity-matrix.mjs';
46
+ import { isLockShape } from './session-lock-shape.mjs';
46
47
  import { isPidAliveOnHost } from './file-lock.mjs';
47
48
  import { writeJsonAtomicSync } from './io.mjs';
48
49
  import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
@@ -199,16 +200,10 @@ function heartbeatAgeMinutes(lock) {
199
200
  function parseLock(raw) {
200
201
  try {
201
202
  const obj = JSON.parse(raw);
202
- if (
203
- typeof obj === 'object' &&
204
- obj !== null &&
205
- typeof obj.session_id === 'string' &&
206
- typeof obj.started_at === 'string' &&
207
- typeof obj.mode === 'string' &&
208
- typeof obj.pid === 'number' &&
209
- typeof obj.host === 'string' &&
210
- typeof obj.ttl_hours === 'number'
211
- ) {
203
+ // The six-field predicate lives in ONE place (#1153 P7) — see
204
+ // `./session-lock-shape.mjs` for the other consumer and the fail-open
205
+ // drift this sharing prevents.
206
+ if (isLockShape(obj)) {
212
207
  // Schema v1 → v2 normalisation: when `last_heartbeat` is absent or
213
208
  // non-string, treat the lock as if it heartbeat-ed once at started_at.
214
209
  const normalised = { ...obj };
@@ -5,11 +5,13 @@
5
5
  * peer-detection (F2, #168) and hooks/on-stop.mjs clean deregister + zombie
6
6
  * sweep (F3, #169).
7
7
  *
8
- * Registry location: `~/.config/session-orchestrator/sessions/active/<sessionId>.json`
9
- * Sweep log: `~/.config/session-orchestrator/sessions/sweep.log` (JSONL)
8
+ * Registry location: `<private-config-dir>/sessions/active/<sessionId>.json`
9
+ * Sweep log: `<private-config-dir>/sessions/sweep.log` (JSONL)
10
10
  *
11
- * Overridable via env var `SO_SESSION_REGISTRY_DIR` (points to the parent
12
- * `sessions/` directory, not `active/`) — used by tests for isolation.
11
+ * `<private-config-dir>` is `resolvePrivateConfigDir()` `SO_CONFIG_HOME` >
12
+ * `XDG_CONFIG_HOME` > `~/.config/session-orchestrator`. `SO_SESSION_REGISTRY_DIR`
13
+ * overrides the whole `sessions/` directory (not `active/`) and outranks all
14
+ * three — used by tests for isolation.
13
15
  *
14
16
  * Heartbeat schema (per issue #167):
15
17
  * {
@@ -27,7 +29,6 @@
27
29
  * deliberately, see `skills/_shared/state-ownership.md` § Schema v1 Sunset.
28
30
  */
29
31
 
30
- import os from 'node:os';
31
32
  import path from 'node:path';
32
33
  import crypto from 'node:crypto';
33
34
  import { promises as fs } from 'node:fs';
@@ -36,16 +37,31 @@ import { digestSha256 } from './crypto-digest-utils.mjs';
36
37
  import { appendFileSync, mkdirSync } from 'node:fs';
37
38
 
38
39
  import { utcTimestamp, appendJsonl } from './common.mjs';
40
+ import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
39
41
 
40
42
  // ---------------------------------------------------------------------------
41
43
  // Paths
42
44
  // ---------------------------------------------------------------------------
43
45
 
44
- /** Parent directory for all session-registry state. */
46
+ /**
47
+ * Parent directory for all session-registry state.
48
+ *
49
+ * Precedence: `SO_SESSION_REGISTRY_DIR` (names the `sessions/` dir ITSELF —
50
+ * highest, and what every existing test uses for isolation) > the host-private
51
+ * config dir resolved by `resolvePrivateConfigDir()` (`SO_CONFIG_HOME` >
52
+ * `XDG_CONFIG_HOME` > `~/.config/session-orchestrator`).
53
+ *
54
+ * Hardcoding `~/.config` here was the #1223 hazard class in its registry form:
55
+ * a sandboxed probe run that set `SO_CONFIG_HOME=<tmp>` moved every OTHER
56
+ * host-private artefact but not this one, so the throwaway session registered
57
+ * itself in the operator's REAL registry and was then discovered as a live peer.
58
+ * `.trim()` guards the whitespace-only env value (`development.md` § Error
59
+ * Handling) — `' '` is truthy and would otherwise be returned verbatim.
60
+ */
45
61
  export function registryBaseDir() {
46
- const override = process.env.SO_SESSION_REGISTRY_DIR;
47
- if (override && override.length > 0) return override;
48
- return path.join(os.homedir(), '.config', 'session-orchestrator', 'sessions');
62
+ const override = (process.env.SO_SESSION_REGISTRY_DIR || '').trim();
63
+ if (override) return override;
64
+ return path.join(resolvePrivateConfigDir(), 'sessions');
49
65
  }
50
66
 
51
67
  /** Directory holding one JSON file per active session. */
@@ -56,8 +56,31 @@ export const SESSION_KEY_ALIASES = Object.freeze({
56
56
  // Enums / required field lists
57
57
  // ---------------------------------------------------------------------------
58
58
 
59
- /** Closed set of valid session_type values. */
60
- export const VALID_SESSION_TYPES = Object.freeze(['feature', 'deep', 'housekeeping']);
59
+ /**
60
+ * Closed set of valid session_type values.
61
+ *
62
+ * `unknown` (GitLab #1234, added 2026-09-06) is NOT a fourth session MODE — it is
63
+ * the absence of a measurement, and it exists so a reconstructed record can say
64
+ * so instead of guessing. Measured 2026-09-06 over the 90-day fleet corpus: all
65
+ * 1.656 `abandoned` records carry `_session_type_inferred: true` + `total_waves: 0`
66
+ * and NO organically written `abandoned` record exists anywhere — i.e. every one
67
+ * of them was labelled `housekeeping` by `scripts/lib/session-close-backfill.mjs`
68
+ * because the enum left it no alternative, and that guess is what produced the
69
+ * fleet-wide "27 % close rate" figure (the real rate is 21,3 %).
70
+ *
71
+ * Only `synthesizeRecord()` in `scripts/lib/session-close-backfill.mjs` writes it,
72
+ * and only for records it also flags `_session_type_inferred` + `_synthetic`.
73
+ * A live session never becomes `unknown`: `/session` still resolves one of the
74
+ * three modes, and `scripts/lib/wave-sizing.mjs` (which THROWS on a fourth value)
75
+ * is only ever fed the live type, never a ledger record.
76
+ *
77
+ * Known mis-bucket, out of this change's scope: `normalizeSessionType()` in
78
+ * `scripts/lib/telemetry/schema.mjs:382` maps any non-empty unlisted value to
79
+ * `'other'` ("something WAS measured and is not one of the three modes"), which
80
+ * is the opposite of what `unknown` means — even though that module already
81
+ * defines `SESSION_TYPE_UNKNOWN = 'unknown'` for its absent-branch.
82
+ */
83
+ export const VALID_SESSION_TYPES = Object.freeze(['feature', 'deep', 'housekeeping', 'unknown']);
61
84
 
62
85
  /**
63
86
  * Required fields for a schema_version=1 record. Validated by validateSession
@@ -131,4 +154,15 @@ export const OPTIONAL_FIELDS = Object.freeze([
131
154
  // into a note a human reads" ⊃ "schema-valid". A record missing it is a clean
132
155
  // vault-mirror skip, NOT a malformed record.
133
156
  'effectiveness',
157
+ // PRD docs/prd/2026-09-06-ultradeep-session-profile.md — `session_profile`
158
+ // names a WAVE-SHAPE variant on top of an unchanged `session_type`. It is
159
+ // additive and optional on purpose: `ultradeep` is deliberately NOT a member
160
+ // of VALID_SESSION_TYPES above, because that set is mirrored in
161
+ // scripts/lib/telemetry/schema.mjs (an unlisted type -> 'other') and in
162
+ // scripts/lib/wave-sizing.mjs (an unlisted type -> TypeError), where a new
163
+ // MODE would be MISLABELLED rather than rejected. That reasoning is unchanged
164
+ // by the `unknown` member added above: `unknown` is the absence of a
165
+ // measurement, not a mode, and nothing dispatches on it. Every historical
166
+ // record lacking the field validates unchanged.
167
+ 'session_profile',
134
168
  ]);
@@ -28,11 +28,33 @@ const EXPECTED_COST_TIERS = Object.freeze(['quick', 'standard', 'deep']);
28
28
 
29
29
  /**
30
30
  * Valid values for the optional `status` field (Epic #724 C1).
31
- * `completed` — record written by a normal /close flow.
32
- * `abandoned` — stub backfilled by the SessionEnd hook because the session
33
- * terminated without running /close.
31
+ * `completed` — record written by a normal /close flow.
32
+ * `abandoned` — stub backfilled by the SessionEnd hook because the session
33
+ * terminated without running /close.
34
+ * `unresolved` — the HONEST label for that same stub: "never reached /close" is
35
+ * observed, but "abandoned" is an interpretation of it (a session
36
+ * may have finished its work and merely skipped /close, or been
37
+ * killed). GitLab #1234, added 2026-09-06.
38
+ *
39
+ * CEILING (BV-004) — `unresolved` currently has NO writer, and the emitter must
40
+ * not adopt it yet. Six EXECUTABLE phantom-stub filters key on the literal
41
+ * `abandoned` and none is inside this change's file scope, so flipping the
42
+ * emitter today would make every new stub invisible to all of them and re-open
43
+ * the #834 phantom-in-signal class fleet-wide. Census measured 2026-09-06 via
44
+ * `rg -n "status\s*(!==|===|!=|==)\s*[\"']abandoned[\"']" scripts hooks --glob '!*.test.mjs'`:
45
+ * scripts/lib/session-schema/filters.mjs:56 (isRealSession — the shared helper,
46
+ * imported by sessions-staleness-banner, auto-dream, dialectic-deriver,
47
+ * harness-audit/categories/category4, evolve/autopilot-effectiveness)
48
+ * scripts/lib/sessions-canonical.mjs:149,172
49
+ * scripts/lib/eval/session-resolve.mjs:79
50
+ * scripts/mcp-server.sh:195
51
+ * scripts/compute-grounding-injection.sh:74
52
+ * REVISIT TRIGGER: once those six route through one predicate that accepts both
53
+ * `abandoned` and `unresolved`, switch `synthesizeRecord()` in
54
+ * `scripts/lib/session-close-backfill.mjs` to emit `unresolved` for inferred
55
+ * records — a one-line change, which is exactly why this value lands now.
34
56
  */
35
- const SESSION_STATUS = Object.freeze(['completed', 'abandoned']);
57
+ const SESSION_STATUS = Object.freeze(['completed', 'abandoned', 'unresolved']);
36
58
 
37
59
  /**
38
60
  * Canonical ISO-8601 UTC timestamp regex — accepts `YYYY-MM-DDTHH:MM:SSZ`
@@ -462,6 +484,18 @@ function _validateOptionalFields(entry) {
462
484
  }
463
485
  }
464
486
 
487
+ // `session_profile` — optional wave-shape profile (PRD
488
+ // docs/prd/2026-09-06-ultradeep-session-profile.md). Non-empty string or
489
+ // null/absent; absent means "no profile" and is NOT coerced to a string.
490
+ // Deliberately NOT validated against a closed set: unlike `session_type`,
491
+ // no consumer branches on the value, so an unrecognised profile is a
492
+ // readable record with an unknown shape rather than a silent mislabel.
493
+ if (entry.session_profile !== undefined && entry.session_profile !== null) {
494
+ if (typeof entry.session_profile !== 'string' || entry.session_profile.length === 0) {
495
+ throw new ValidationError('session_profile must be a non-empty string or null');
496
+ }
497
+ }
498
+
465
499
  // `_express_path_detail` — forensic sidecar written by `normalizeSession`
466
500
  // when it collapses a legacy object `express_path` onto its boolean. Holds
467
501
  // the pre-collapse object verbatim so the conversion stays reversible.
@@ -176,8 +176,16 @@ export const PROBES = [
176
176
  args: ({ repoRoot }) => ({ repoRoot }),
177
177
  // Bespoke shape: `{status, ok, details, …}` with no `message` field. The
178
178
  // banner text is prescribed by SKILL.md § Phase 4.
179
+ //
180
+ // The degraded branch is NOT decoration (#1031): this entry overrides BOTH
181
+ // `render` and `severityOf`, so the module-level defaults that already
182
+ // handle a `{severity:'warn', message, degraded}` result never run for this
183
+ // probe. Without these two lines a degraded ci-status result scored `'ok'`
184
+ // and rendered nothing — "could not read" displayed exactly like "green",
185
+ // which is the confusion the probe's own migration removed one layer down.
179
186
  render: (r) => {
180
187
  if (!r || typeof r !== 'object') return null;
188
+ if (r.degraded) return typeof r.message === 'string' && r.message ? r.message : null;
181
189
  if (r.status === 'red') {
182
190
  const pid = r.details?.currentPipelineId ?? '?';
183
191
  const green = r.lastGreen
@@ -193,7 +201,16 @@ export const PROBES = [
193
201
  return null;
194
202
  },
195
203
  // `status: 'red'` is an alert even though the probe publishes no severity.
196
- severityOf: (r) => (r?.status === 'red' ? 'alert' : r?.status === 'green' && r?.allowFailureJobs ? 'warn' : 'ok'),
204
+ // A degraded result is a finding, never clean same rule as the generic
205
+ // path in `severityOf()` below.
206
+ severityOf: (r) =>
207
+ r?.degraded
208
+ ? 'warn'
209
+ : r?.status === 'red'
210
+ ? 'alert'
211
+ : r?.status === 'green' && r?.allowFailureJobs
212
+ ? 'warn'
213
+ : 'ok',
197
214
  },
198
215
  {
199
216
  id: 'qg-command-drift',