session-orchestrator 3.23.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (393) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1401 -0
  81. package/NOTICE +11 -6
  82. package/README.md +127 -92
  83. package/agents/db-specialist.md +0 -1
  84. package/agents/eval-judge.md +1 -1
  85. package/agents/skill-applied-judge.md +1 -1
  86. package/assets/wave-lifecycle.svg +98 -0
  87. package/commands/release.md +6 -3
  88. package/commands/session.md +18 -3
  89. package/docs/README.md +4 -0
  90. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  91. package/docs/baseline.md +67 -0
  92. package/docs/ci-setup.md +249 -48
  93. package/docs/codex-setup.md +66 -22
  94. package/docs/components.md +37 -16
  95. package/docs/cursor-setup.md +6 -2
  96. package/docs/events-schema.md +51 -10
  97. package/docs/instruction-delivery.md +62 -0
  98. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  99. package/docs/migration-v4.md +341 -0
  100. package/docs/pi-setup.md +6 -1
  101. package/docs/plugin-architecture-v3.md +1 -1
  102. package/docs/rule-authoring.md +85 -19
  103. package/docs/scope-collision-guard.md +8 -8
  104. package/docs/session-config-reference.md +120 -61
  105. package/docs/session-config-template.md +40 -33
  106. package/docs/telemetry/telemetry-claims.md +11 -10
  107. package/docs/telemetry.md +187 -4
  108. package/docs/vault-docs-architecture.md +50 -11
  109. package/hooks/_lib/atomic-json.mjs +111 -0
  110. package/hooks/_lib/hook-import-set.json +1487 -0
  111. package/hooks/_lib/subagent-paths.mjs +143 -0
  112. package/hooks/_lib/subagent-transcript.mjs +562 -0
  113. package/hooks/config-protection.mjs +2 -2
  114. package/hooks/cwd-change-restore.mjs +11 -31
  115. package/hooks/enforce-commands.mjs +69 -0
  116. package/hooks/enforce-scope.mjs +35 -6
  117. package/hooks/hooks-codex.json +1 -1
  118. package/hooks/hooks-cursor.json +10 -0
  119. package/hooks/hooks-pi.json +5 -0
  120. package/hooks/hooks.json +6 -1
  121. package/hooks/loop-guard.mjs +3 -3
  122. package/hooks/on-session-end.mjs +280 -14
  123. package/hooks/on-session-start.mjs +153 -4
  124. package/hooks/on-stop.mjs +371 -17
  125. package/hooks/operator-steer.mjs +2 -2
  126. package/hooks/post-bash-write-verify.mjs +189 -4
  127. package/hooks/post-edit-import-probe.mjs +344 -0
  128. package/hooks/post-subagent-discovery-validator.mjs +278 -392
  129. package/hooks/post-tool-batch-wave-signal.mjs +272 -44
  130. package/hooks/post-tool-failure-corrective-context.mjs +11 -34
  131. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  132. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  133. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  134. package/hooks/skill-invocation-telemetry.mjs +17 -5
  135. package/hooks/subagent-telemetry.mjs +24 -30
  136. package/monitors/monitors.json +3 -3
  137. package/package.json +9 -1
  138. package/pi/prompts/session.md +2 -2
  139. package/plugin.json +27 -0
  140. package/scripts/autopilot.mjs +26 -12
  141. package/scripts/backfill-abandoned-sessions.mjs +130 -15
  142. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  143. package/scripts/dialectic-deriver.mjs +73 -8
  144. package/scripts/emit-event.mjs +10 -2
  145. package/scripts/export-hw-learnings.mjs +113 -1
  146. package/scripts/generate-agents-skills.mjs +378 -0
  147. package/scripts/generate-cursor-adapter.mjs +45 -8
  148. package/scripts/generate-hook-import-set.mjs +249 -0
  149. package/scripts/lib/agent-status.mjs +13 -2
  150. package/scripts/lib/auq/parse.mjs +5 -29
  151. package/scripts/lib/auto-dialectic.mjs +68 -0
  152. package/scripts/lib/auto-dream.mjs +38 -36
  153. package/scripts/lib/autonomy/suitability.mjs +6 -0
  154. package/scripts/lib/autopilot/loop.mjs +2 -2
  155. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  156. package/scripts/lib/build-live-signals.mjs +25 -22
  157. package/scripts/lib/ci-status-banner.mjs +220 -75
  158. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  159. package/scripts/lib/cold-start-detector.mjs +23 -14
  160. package/scripts/lib/config/auto-dream.mjs +2 -1
  161. package/scripts/lib/config/block-header.mjs +63 -0
  162. package/scripts/lib/config/block-preprocess.mjs +177 -0
  163. package/scripts/lib/config/broken-window.mjs +2 -1
  164. package/scripts/lib/config/cold-start.mjs +2 -1
  165. package/scripts/lib/config/config-protection.mjs +22 -2
  166. package/scripts/lib/config/context-coverage.mjs +2 -1
  167. package/scripts/lib/config/cross-repo.mjs +2 -1
  168. package/scripts/lib/config/custom-phases.mjs +2 -1
  169. package/scripts/lib/config/dialectic.mjs +2 -1
  170. package/scripts/lib/config/discovery-validator.mjs +9 -3
  171. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  172. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  173. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  174. package/scripts/lib/config/docs-staleness.mjs +2 -1
  175. package/scripts/lib/config/drift-check.mjs +2 -1
  176. package/scripts/lib/config/eval.mjs +2 -1
  177. package/scripts/lib/config/events-rotation.mjs +2 -1
  178. package/scripts/lib/config/evolve.mjs +8 -2
  179. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  180. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  181. package/scripts/lib/config/handover-gate.mjs +2 -1
  182. package/scripts/lib/config/health-endpoints.mjs +388 -0
  183. package/scripts/lib/config/issue-budget.mjs +2 -1
  184. package/scripts/lib/config/loop-guard.mjs +2 -1
  185. package/scripts/lib/config/memory.mjs +2 -1
  186. package/scripts/lib/config/moc-staleness.mjs +2 -1
  187. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  188. package/scripts/lib/config/private-config-dir.mjs +67 -0
  189. package/scripts/lib/config/reconcile.mjs +2 -1
  190. package/scripts/lib/config/remote-hosts.mjs +234 -0
  191. package/scripts/lib/config/section-extractor.mjs +7 -1
  192. package/scripts/lib/config/skill-evolution.mjs +2 -1
  193. package/scripts/lib/config/slopcheck.mjs +2 -1
  194. package/scripts/lib/config/state-md-lock.mjs +2 -1
  195. package/scripts/lib/config/templates-first.mjs +2 -1
  196. package/scripts/lib/config/test.mjs +2 -1
  197. package/scripts/lib/config/vault-integration.mjs +7 -1
  198. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  199. package/scripts/lib/config/vault-staleness.mjs +2 -1
  200. package/scripts/lib/config/vault-sync.mjs +2 -1
  201. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  202. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  203. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  204. package/scripts/lib/config.mjs +31 -3
  205. package/scripts/lib/convergence-monitor.mjs +82 -16
  206. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  207. package/scripts/lib/dispatcher/rank.mjs +124 -48
  208. package/scripts/lib/ecosystem-health.mjs +16 -2
  209. package/scripts/lib/eval/engine.mjs +9 -1
  210. package/scripts/lib/eval/session-resolve.mjs +23 -4
  211. package/scripts/lib/events-schema.mjs +48 -0
  212. package/scripts/lib/events.mjs +256 -7
  213. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  214. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  215. package/scripts/lib/frontmatter-guard.mjs +131 -13
  216. package/scripts/lib/gates/gate-full.mjs +26 -0
  217. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  218. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  219. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  220. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  221. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  222. package/scripts/lib/host-identity.mjs +50 -11
  223. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  224. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  225. package/scripts/lib/learnings/io.mjs +60 -6
  226. package/scripts/lib/memory-banner.mjs +20 -8
  227. package/scripts/lib/memory-proposals/store.mjs +30 -22
  228. package/scripts/lib/owner-config-banner.mjs +43 -6
  229. package/scripts/lib/owner-config-loader.mjs +21 -10
  230. package/scripts/lib/owner-interview.mjs +3 -3
  231. package/scripts/lib/owner-yaml.mjs +207 -14
  232. package/scripts/lib/peer-discovery.mjs +20 -2
  233. package/scripts/lib/platform.mjs +108 -15
  234. package/scripts/lib/plugin-update-banner.mjs +406 -0
  235. package/scripts/lib/project-hygiene.mjs +38 -2
  236. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  237. package/scripts/lib/quality-gate.mjs +133 -44
  238. package/scripts/lib/reconcile/emitter.mjs +68 -6
  239. package/scripts/lib/reconcile/engine.mjs +249 -9
  240. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  241. package/scripts/lib/reconcile/writer.mjs +40 -18
  242. package/scripts/lib/scope-gate.mjs +36 -0
  243. package/scripts/lib/session-close-backfill.mjs +125 -18
  244. package/scripts/lib/session-discovery.mjs +57 -3
  245. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  246. package/scripts/lib/session-id.mjs +12 -23
  247. package/scripts/lib/session-identity/own-session.mjs +187 -11
  248. package/scripts/lib/session-lock-shape.mjs +43 -0
  249. package/scripts/lib/session-lock.mjs +5 -10
  250. package/scripts/lib/session-registry.mjs +25 -9
  251. package/scripts/lib/session-schema/constants.mjs +36 -2
  252. package/scripts/lib/session-schema/validator.mjs +38 -4
  253. package/scripts/lib/session-start-probes.mjs +18 -1
  254. package/scripts/lib/session-transition.mjs +1 -1
  255. package/scripts/lib/sessions-canonical.mjs +446 -0
  256. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  257. package/scripts/lib/skill-health/join.mjs +17 -4
  258. package/scripts/lib/state-md.mjs +78 -0
  259. package/scripts/lib/sunset/walker.mjs +6 -0
  260. package/scripts/lib/telemetry/schema.mjs +255 -17
  261. package/scripts/lib/telemetry/sync.mjs +417 -24
  262. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  263. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  264. package/scripts/lib/validate/check-agents.mjs +3 -3
  265. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  266. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  267. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  268. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  269. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  270. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  271. package/scripts/lib/validate/check-skill-script-paths.mjs +455 -0
  272. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  273. package/scripts/lib/validate/check-unwired-features.mjs +0 -9
  274. package/scripts/lib/validate/check-validator-registration.mjs +254 -0
  275. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  276. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  277. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  278. package/scripts/lib/vault-backfill/template.mjs +63 -6
  279. package/scripts/lib/vault-mirror/process.mjs +165 -42
  280. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  281. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  282. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  283. package/scripts/lib/vault-status/narrative-mirror.mjs +129 -37
  284. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  285. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  286. package/scripts/lib/wave-executor/remote-dispatch.mjs +502 -0
  287. package/scripts/lib/wave-resource-gate.mjs +133 -7
  288. package/scripts/lib/wave-sizing.mjs +4 -1
  289. package/scripts/lib/wave-transcript-tail.mjs +142 -8
  290. package/scripts/materialize-wave-scope.mjs +32 -9
  291. package/scripts/memory-propose.mjs +146 -8
  292. package/scripts/migrate-cold-start-seed.mjs +4 -1
  293. package/scripts/parse-config.mjs +60 -3
  294. package/scripts/promote-vault-strict.mjs +4 -15
  295. package/scripts/release.mjs +337 -29
  296. package/scripts/repair-invalid-sessions.mjs +3 -3
  297. package/scripts/run-quality-gate.mjs +128 -11
  298. package/scripts/site-numbers.mjs +36 -4
  299. package/scripts/sweep-expired-learnings.mjs +90 -0
  300. package/scripts/sync-vault-schema.mjs +3 -1
  301. package/scripts/telemetry.mjs +2 -2
  302. package/scripts/validate-plugin.mjs +187 -0
  303. package/scripts/validate-wave-scope.mjs +28 -8
  304. package/scripts/vault-consolidate.mjs +3 -11
  305. package/scripts/vault-integration-watcher.mjs +2 -4
  306. package/scripts/vault-mirror.mjs +111 -26
  307. package/scripts/wave-scope-binding.mjs +215 -0
  308. package/skills/_shared/instruction-file-resolution.md +10 -0
  309. package/skills/_shared/parallel-aware-auq.md +31 -2
  310. package/skills/_shared/parallel-aware-preamble.md +18 -4
  311. package/skills/_shared/platform-tools.md +1 -1
  312. package/skills/_shared/state-ownership.md +1 -1
  313. package/skills/architecture/SKILL.md +7 -5
  314. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  315. package/skills/autopilot/SKILL.md +4 -18
  316. package/skills/claude-md-drift-check/SKILL.md +5 -1
  317. package/skills/claude-md-drift-check/checker.mjs +62 -2
  318. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  319. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  320. package/skills/discovery/probes-arch.md +20 -18
  321. package/skills/dispatcher/SKILL.md +3 -2
  322. package/skills/ecosystem-health/SKILL.md +4 -1
  323. package/skills/ecosystem-health/wizard.md +5 -0
  324. package/skills/evolve/SKILL.md +87 -11
  325. package/skills/frontmatter-guard/SKILL.md +11 -5
  326. package/skills/npm-publish/SKILL.md +1 -1
  327. package/skills/reconcile/SKILL.md +38 -2
  328. package/skills/remote-offload/SKILL.md +89 -0
  329. package/skills/session-end/SKILL.md +18 -905
  330. package/skills/session-end/phase-3-6-tail.md +19 -9
  331. package/skills/session-end/plan-verification.md +221 -155
  332. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  333. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  334. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  335. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  336. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  337. package/skills/session-end/references/session-summary-template.md +62 -0
  338. package/skills/session-plan/SKILL.md +49 -0
  339. package/skills/session-start/SKILL.md +41 -900
  340. package/skills/session-start/phase-8-5-express-path.md +1 -1
  341. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  342. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  343. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  344. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  345. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  346. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  347. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  348. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  349. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  350. package/skills/vault-sync/validator.mjs +21 -27
  351. package/skills/wave-executor/SKILL.md +16 -2
  352. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  353. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  354. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  355. package/skills/wave-executor/wave-loop.md +14 -1271
  356. package/templates/_shared/journey-manifest.md +10 -6
  357. package/.cursor/commands/autopilot-multi.md +0 -14
  358. package/.cursor/commands/contract-version-bump.md +0 -14
  359. package/.cursor/commands/journey-audit.md +0 -14
  360. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  361. package/.cursor/skills/daily/SKILL.md +0 -12
  362. package/.cursor/skills/domain-model/SKILL.md +0 -13
  363. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  364. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  365. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  366. package/commands/autopilot-multi.md +0 -74
  367. package/commands/contract-version-bump.md +0 -28
  368. package/commands/journey-audit.md +0 -43
  369. package/pi/prompts/autopilot-multi.md +0 -12
  370. package/pi/prompts/contract-version-bump.md +0 -12
  371. package/pi/prompts/journey-audit.md +0 -12
  372. package/scripts/autopilot-multi.mjs +0 -885
  373. package/scripts/backfill-learnings-expires.mjs +0 -196
  374. package/scripts/backfill-learnings.mjs +0 -203
  375. package/scripts/fleet-instruction-scan.mjs +0 -141
  376. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  377. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  378. package/scripts/lib/webhook-url.mjs +0 -105
  379. package/scripts/lifecycle-sim-v6.mjs +0 -347
  380. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  381. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  382. package/scripts/upload-social-preview.mjs +0 -316
  383. package/skills/_shared/model-selection.md +0 -64
  384. package/skills/contract-version-bump/SKILL.md +0 -219
  385. package/skills/daily/SKILL.md +0 -222
  386. package/skills/daily/generate.sh +0 -92
  387. package/skills/daily/templates/daily.md.tpl +0 -36
  388. package/skills/journey-audit/SKILL.md +0 -269
  389. package/skills/skill-creator/SKILL.md +0 -168
  390. package/skills/ubiquitous-language/SKILL.md +0 -97
  391. package/skills/vault-sync/package-lock.json +0 -40
  392. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  393. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import path from 'node:path';
30
+ import os from 'node:os';
31
+ import fs from 'node:fs';
30
32
 
31
33
  import {
32
34
  resolveConsent,
@@ -34,11 +36,15 @@ import {
34
36
  writeTelemetryState,
35
37
  TELEMETRY_JSON_PATH,
36
38
  } from './consent.mjs';
37
- import { buildUsagePing, projectUsagePing } from './schema.mjs';
39
+ import { buildUsagePing, projectUsagePing, normalizeSessionProfile } from './schema.mjs';
38
40
  import { ensureAnonId } from './anon-id.mjs';
39
41
  import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
40
42
  import { loadOwnerConfig } from '../owner-yaml.mjs';
41
43
  import { readJsonlFile } from '../io.mjs';
44
+ import { readCanonicalSessions } from '../sessions-canonical.mjs';
45
+ import { resolvePrivateConfigDir } from '../config/private-config-dir.mjs';
46
+ import { readSessionProfile } from '../state-md.mjs';
47
+ import { resolveStateMdPath } from '../state-md/frontmatter-mutators.mjs';
42
48
 
43
49
  // ---------------------------------------------------------------------------
44
50
  // Constants
@@ -84,24 +90,305 @@ function defaultSender({ env, timeoutMs }) {
84
90
  signal: AbortSignal.timeout(timeoutMs),
85
91
  });
86
92
  if (!res.ok) {
87
- throw new Error(`telemetry endpoint returned HTTP ${res.status}`);
93
+ // The status travels ON the error: `flush` needs it to tell a TRANSPORT
94
+ // failure (re-queue) from a SCHEMA rejection (evict — see
95
+ // `isSchemaRejection`). A bare Error carries no such distinction, and
96
+ // parsing the message string would be a second, drift-prone encoding.
97
+ const err = new Error(`telemetry endpoint returned HTTP ${res.status}`);
98
+ err.status = res.status;
99
+ throw err;
88
100
  }
89
101
  };
90
102
  }
91
103
 
104
+ // ---------------------------------------------------------------------------
105
+ // Sandbox guard (GitLab #1234)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * THE BUG THIS EXISTS FOR: Wave-1 sandbox runs sent 6 production pings with a
110
+ * wrong session_type on 2026-09-06.
111
+ *
112
+ * Six agent sandboxes executed `hooks/on-session-end.mjs` from the repo checkout
113
+ * at 11:02:50–11:03:36Z. Each one resolved the OPERATOR's real `anon_id` and real
114
+ * consent from `~/.config/session-orchestrator/telemetry.json` — because
115
+ * `paths.mjs` computes that path from `homedir()` and does NOT honour
116
+ * `SO_CONFIG_HOME` — while `owner.yaml` was unreachable inside the sandbox and
117
+ * `sessions.jsonl` did not exist. Result: six real records on the ingest server,
118
+ * attributed to a real person, carrying `session_type: "other"` and `fleet: 0`.
119
+ * Same failure class as the d7-F2 registry leak: a bench harness whose SOURCE was
120
+ * faked but whose DESTINATION was not.
121
+ *
122
+ * The guard refuses the send. It runs AFTER the consent gate (so the documented
123
+ * outermost-seam invariant is untouched — see the module docblock) and BEFORE
124
+ * `buildBatch()`, so a refused send performs no network call, no queue write and
125
+ * NO anon-ID mint.
126
+ *
127
+ * Detection, three independent conditions — any ONE refuses:
128
+ *
129
+ * (a) `SO_TELEMETRY_DISABLED` / `DO_NOT_TRACK` — already refused one layer up by
130
+ * `resolveConsent()`; re-asserted here so the guard is complete on its own
131
+ * and a future reordering cannot silently drop it.
132
+ * (b) CONFIG-HOME SPLIT — `SO_CONFIG_HOME` / `XDG_CONFIG_HOME` declares a config
133
+ * home, but the telemetry state is NOT read from inside it. That split IS
134
+ * the leak: the caller believes it redirected the identity, and it did not.
135
+ * A caller that redirects CONSISTENTLY (declared home + a `statePath`
136
+ * inside it) has actually isolated itself and is permitted.
137
+ * (c) TEMP-ROOT — `CLAUDE_PROJECT_DIR` (or the cwd) sits under the OS temp
138
+ * directory or `/tmp`, WHILE the identity is a real one. A real operator
139
+ * session runs from a real checkout.
140
+ *
141
+ * A FOURTH outcome is the guard's own failure: if any probe throws, the answer is
142
+ * `sandbox: true` with `reason: 'sandbox:probe-failed'`. An environment the guard
143
+ * cannot classify is treated as one it would have refused.
144
+ *
145
+ * (b) and (c) share one principle, and it is the whole design: **the guard
146
+ * protects the DEFAULT host identity.** When the effective telemetry state path
147
+ * is itself throwaway — inside the declared config home, or under a temp root —
148
+ * there is no operator identity to leak and the send is permitted. That is what
149
+ * keeps a properly-isolated harness (this repo's convention: a tmp `HOME`, see
150
+ * `tests/_helpers/telemetry-isolation.mjs`) sendable, while the Wave-1 shape —
151
+ * a redirect that the state reader ignored, so the REAL anon_id was used —
152
+ * is refused.
153
+ *
154
+ * @param {object} [opts]
155
+ * @param {NodeJS.ProcessEnv} [opts.env]
156
+ * @param {string} [opts.statePath] — an explicit telemetry.json override, if any.
157
+ * @param {string} [opts.cwd]
158
+ * @returns {{ sandbox: boolean, reason: string|null }}
159
+ */
160
+ export function detectSandbox({ env = process.env, statePath, cwd } = {}) {
161
+ try {
162
+ // (a) explicit opt-out env — belt to resolveConsent's braces.
163
+ if (env?.SO_TELEMETRY_DISABLED === '1') return { sandbox: true, reason: 'sandbox:telemetry-disabled' };
164
+ const dnt = (env?.DO_NOT_TRACK || '').trim();
165
+ if (dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
166
+ return { sandbox: true, reason: 'sandbox:do-not-track' };
167
+ }
168
+
169
+ // The state path that will ACTUALLY be read — the identity at stake.
170
+ const effectiveStatePath = realOrSelf(statePath || TELEMETRY_JSON_PATH);
171
+
172
+ // (b) config-home split: a declared config home that the state path is not
173
+ // inside. `resolvePrivateConfigDir` returns the homedir default when
174
+ // nothing is declared, which is why the raw env vars are checked here —
175
+ // an undeclared default is not a split, it is the normal case.
176
+ const declaredHome = (env?.SO_CONFIG_HOME || '').trim() || (env?.XDG_CONFIG_HOME || '').trim();
177
+ if (declaredHome !== '') {
178
+ const declaredDir = realOrSelf(resolvePrivateConfigDir({ env }));
179
+ if (!isUnder(effectiveStatePath, declaredDir)) {
180
+ return { sandbox: true, reason: 'sandbox:config-home-split' };
181
+ }
182
+ }
183
+
184
+ // (c) temp-root project WHILE the identity is real. Compare REAL paths:
185
+ // macOS $TMPDIR is /var/folders/… symlinked to /private/var/folders/…,
186
+ // so a string prefix on the raw values misses every macOS sandbox.
187
+ const tempRoots = [os.tmpdir(), '/tmp'].filter(Boolean).map(realOrSelf);
188
+ const identityIsThrowaway = tempRoots.some((root) => isUnder(effectiveStatePath, root));
189
+ if (!identityIsThrowaway) {
190
+ const project = realOrSelf((env?.CLAUDE_PROJECT_DIR || '').trim() || cwd || process.cwd());
191
+ if (tempRoots.some((root) => isUnder(project, root))) {
192
+ return { sandbox: true, reason: 'sandbox:temp-root' };
193
+ }
194
+ }
195
+
196
+ return { sandbox: false, reason: null };
197
+ } catch {
198
+ // A guard that throws must never become a guard that permits — and until
199
+ // 2026-09-06 this catch said exactly that while doing the opposite
200
+ // (`{ sandbox: false }`, i.e. PERMIT on probe failure). It now fails CLOSED.
201
+ //
202
+ // What is refused is one ping, and the batch is not lost: `flush` returns
203
+ // `reason: 'sandbox:probe-failed'`, writes no queue mutation, and the next
204
+ // session's flush re-probes from scratch. What the old branch risked is the
205
+ // thing this guard exists to prevent — a real `anon_id` leaving an
206
+ // environment the guard could not classify.
207
+ return { sandbox: true, reason: 'sandbox:probe-failed' };
208
+ }
209
+ }
210
+
211
+ /** True when `candidate` IS `root` or lies beneath it (both already realpath'd). */
212
+ function isUnder(candidate, root) {
213
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
214
+ }
215
+
216
+ /**
217
+ * `fs.realpathSync` for a path that may not exist yet.
218
+ *
219
+ * Resolving the NEAREST EXISTING ANCESTOR and re-appending the remainder is the
220
+ * load-bearing part, not a nicety: on macOS `$TMPDIR` is `/var/folders/…`, a
221
+ * symlink to `/private/var/folders/…`. A plain `realpathSync` on a not-yet-created
222
+ * `<tmp>/telemetry.json` throws, the raw `/var/folders/…` string is returned, and
223
+ * it then fails to match the realpath'd `/private/var/folders/…` temp root — so
224
+ * every comparison against a path that does not exist yet silently comes out
225
+ * "not under the temp root". Measured while writing this guard's own tests.
226
+ *
227
+ * @param {string} p
228
+ * @returns {string}
229
+ */
230
+ function realOrSelf(p) {
231
+ let abs = path.resolve(p);
232
+ const tail = [];
233
+ for (;;) {
234
+ try {
235
+ return path.join(fs.realpathSync(abs), ...tail);
236
+ } catch {
237
+ const parent = path.dirname(abs);
238
+ if (parent === abs) return path.resolve(p); // reached the root, nothing exists
239
+ tail.unshift(path.basename(abs));
240
+ abs = parent;
241
+ }
242
+ }
243
+ }
244
+
92
245
  // ---------------------------------------------------------------------------
93
246
  // Batch build
94
247
  // ---------------------------------------------------------------------------
95
248
 
249
+ /**
250
+ * The canonical (#1167-deduplicated) session record most recently WRITTEN to
251
+ * the ledger — ranked by `completed_at` (falling back to `started_at` when
252
+ * absent), the closest analogue to "the last line of the file" once the reader
253
+ * no longer trusts append order.
254
+ *
255
+ * `readCanonicalSessions` reorders its output to "first appearance of each
256
+ * surviving id" (see sessions-canonical.mjs's own docstring) — it is NOT
257
+ * append order — so a raw `records[records.length - 1]` (the pre-#1186 read)
258
+ * silently picks the WRONG session once a `session_id` duplicate or a
259
+ * `supersedes` collapse reshuffles the array. A record with neither timestamp
260
+ * sorts last and is never chosen over a dated one.
261
+ *
262
+ * @param {Array<object>} records — canonical session records.
263
+ * @returns {object|null}
264
+ */
265
+ function mostRecentSession(records) {
266
+ let best = null;
267
+ let bestTs = '';
268
+ for (const rec of records) {
269
+ if (!rec || typeof rec !== 'object') continue;
270
+ const ts =
271
+ typeof rec.completed_at === 'string' && rec.completed_at
272
+ ? rec.completed_at
273
+ : typeof rec.started_at === 'string'
274
+ ? rec.started_at
275
+ : '';
276
+ if (ts && ts > bestTs) {
277
+ best = rec;
278
+ bestTs = ts;
279
+ }
280
+ }
281
+ return best;
282
+ }
283
+
284
+ /**
285
+ * Read the session PROFILE (STATE.md frontmatter `session-profile`) for the repo
286
+ * that owns `metricsDir`.
287
+ *
288
+ * The profile is a SECOND axis beside `session_type`: an ultradeep session is
289
+ * `session_type: "deep"` PLUS `session_profile: "ultradeep"`. It deliberately
290
+ * does NOT go through `normalizeSessionType`, which would flatten any unknown
291
+ * value to `'other'` and destroy the only signal that distinguishes the 7-wave
292
+ * form from an ordinary deep session.
293
+ *
294
+ * ABSENT IS NOT EMPTY. No STATE.md, no frontmatter, or no `session-profile` key
295
+ * ⇒ `null` ⇒ the ping OMITS the field. A derived (ledger-less) ping therefore
296
+ * carries no profile rather than an invented one.
297
+ *
298
+ * Never throws.
299
+ *
300
+ * @param {string} metricsDir — `<repoRoot>/.orchestrator/metrics`.
301
+ * @returns {string|null}
302
+ */
303
+ export function readSessionProfileForMetricsDir(metricsDir) {
304
+ try {
305
+ // metricsDir is `<repoRoot>/.orchestrator/metrics` by construction (every
306
+ // caller builds it that way); two levels up is the repo root.
307
+ const repoRoot = path.resolve(metricsDir, '..', '..');
308
+ return readSessionProfile(fs.readFileSync(resolveStateMdPath(repoRoot), 'utf8'));
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Reconstruct the session facts a ping needs (`session_type`, `started_at`,
316
+ * `completed_at`) from `<metricsDir>/events.jsonl` when `sessions.jsonl` has no
317
+ * usable record.
318
+ *
319
+ * WHY THIS EXISTS: `buildBatch` keyed exclusively on `sessions.jsonl`, which is
320
+ * written by `/close`. Measured 2026-09-06 (d8): the fleet's real close rate is
321
+ * 21,3 % (429 clean closes / 2.016 distinct `session.started` ids over 90 days),
322
+ * and THIS repo has no `sessions.jsonl` at all — so `sessionForPing` was `{}` and
323
+ * every ping reported `session_type: "other"` / `duration_bucket: "<15m"` as if
324
+ * measured. `events.jsonl` is written on every SessionStart, independent of
325
+ * `/close`, so it is the source that survives a killed session.
326
+ *
327
+ * NEVER FABRICATES: when no `orchestrator.session.started` record carries a mode,
328
+ * `session_type` is returned absent, and the caller emits `'unknown'`.
329
+ *
330
+ * Deliberate simplification (named ceiling): the LAST `session.started` record
331
+ * wins and the LAST record of any kind supplies `completed_at`. That conflates a
332
+ * session with the tail of a peer's events in a shared working copy. It is the
333
+ * same precision the ledger path already offers (`mostRecentSession`), it costs
334
+ * one linear pass, and the `session_record: 'derived'` marker tells the reader it
335
+ * is a reconstruction. Revisit if events.jsonl ever carries interleaved sessions
336
+ * that must be told apart — the `session_id` field is already there for it.
337
+ *
338
+ * Never throws.
339
+ *
340
+ * @param {string} metricsDir
341
+ * @returns {{ session: object, source: 'derived'|'absent' }}
342
+ */
343
+ export function deriveSessionFromEvents(metricsDir) {
344
+ try {
345
+ const events = readJsonlFile(path.join(metricsDir, 'events.jsonl'), { skipInvalid: true });
346
+ if (!Array.isArray(events) || events.length === 0) return { session: {}, source: 'absent' };
347
+
348
+ let startedAt = null;
349
+ let sessionType = null;
350
+ let lastTs = null;
351
+
352
+ for (const ev of events) {
353
+ if (!ev || typeof ev !== 'object') continue;
354
+ const ts = typeof ev.timestamp === 'string' && !Number.isNaN(Date.parse(ev.timestamp)) ? ev.timestamp : null;
355
+ if (ts && (lastTs === null || ts > lastTs)) lastTs = ts;
356
+ if (ev.event !== 'orchestrator.session.started') continue;
357
+ // `mode` is what session-start writes; `session_type` is the ledger's own
358
+ // name for the same fact. Read both — neither is guaranteed present.
359
+ const mode = typeof ev.session_type === 'string' ? ev.session_type : ev.mode;
360
+ const started = typeof ev.started_at === 'string' ? ev.started_at : ts;
361
+ if (started && (startedAt === null || started >= startedAt)) {
362
+ startedAt = started;
363
+ sessionType = typeof mode === 'string' && mode.trim() !== '' ? mode.trim() : null;
364
+ }
365
+ }
366
+
367
+ if (startedAt === null && sessionType === null) return { session: {}, source: 'absent' };
368
+
369
+ const session = {};
370
+ if (sessionType !== null) session.session_type = sessionType;
371
+ if (startedAt !== null) session.started_at = startedAt;
372
+ // completed_at is the last life-sign, never the wall clock — the same
373
+ // omit-never-fabricate contract session-close-backfill.mjs uses (#914 R1).
374
+ if (lastTs !== null && startedAt !== null && lastTs >= startedAt) session.completed_at = lastTs;
375
+
376
+ return { session, source: 'derived' };
377
+ } catch {
378
+ return { session: {}, source: 'absent' };
379
+ }
380
+ }
381
+
96
382
  /**
97
383
  * Build ONE whitelist-projected usage-ping record from the local JSONL streams.
98
384
  *
99
- * Reads `<metricsDir>/sessions.jsonl` + `<metricsDir>/skill-invocations.jsonl`
100
- * (metricsDir defaults to `<cwd>/.orchestrator/metrics`). The LAST sessions.jsonl
101
- * record defines the session window: skill-invocations whose `timestamp >=` its
102
- * `started_at` are included. When no session record exists, the ping falls back
103
- * to `session_type: 'other'`, `duration_bucket: '<15m'`, and the invocations of
104
- * the last 24 hours.
385
+ * Reads `<metricsDir>/sessions.jsonl` (via `readCanonicalSessions`, #1186 — the
386
+ * #1167 newest-wins-per-`session_id` / `supersedes` collapse) +
387
+ * `<metricsDir>/skill-invocations.jsonl`. The most-recently-written CANONICAL
388
+ * session record (`mostRecentSession`, above) defines the session window:
389
+ * skill-invocations whose `timestamp >=` its `started_at` are included. When no
390
+ * session record exists, the ping falls back to `session_type: 'other'`,
391
+ * `duration_bucket: '<15m'`, and the invocations of the last 24 hours.
105
392
  *
106
393
  * anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
107
394
  * telemetry.json record; a created/rotated ID is persisted via
@@ -132,18 +419,21 @@ export function buildBatch({
132
419
  now,
133
420
  statePath,
134
421
  persist = true,
422
+ consentState,
135
423
  } = {}) {
136
424
  try {
137
425
  const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
138
426
  const nowIso = now || new Date().toISOString();
139
427
 
140
- const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
428
+ const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
141
429
  const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
142
430
 
143
- const sessionRecord = sessions.length > 0 ? sessions[sessions.length - 1] : null;
431
+ const sessionRecord = mostRecentSession(sessions);
144
432
 
145
433
  let windowInvocations;
146
434
  let sessionForPing;
435
+ /** @type {'ledger'|'derived'|'absent'} */
436
+ let sessionRecordSource = 'ledger';
147
437
  if (sessionRecord && typeof sessionRecord.started_at === 'string' && !Number.isNaN(Date.parse(sessionRecord.started_at))) {
148
438
  const startMs = Date.parse(sessionRecord.started_at);
149
439
  windowInvocations = invocations.filter((rec) => {
@@ -151,15 +441,24 @@ export function buildBatch({
151
441
  return !Number.isNaN(t) && t >= startMs;
152
442
  });
153
443
  sessionForPing = sessionRecord;
444
+ sessionRecordSource = 'ledger';
154
445
  } else {
155
- // No usable session record → 24h window + synthetic session (schema
156
- // fallbacks yield session_type 'other' / duration_bucket '<15m').
446
+ // No usable LEDGER record → reconstruct from events.jsonl, which is
447
+ // written on every SessionStart and therefore survives a killed session
448
+ // (see deriveSessionFromEvents). Only when THAT also yields nothing does
449
+ // the ping fall back to `session_type: 'unknown'` — never to a
450
+ // measured-looking 'other'.
451
+ const derived = deriveSessionFromEvents(dir);
452
+ sessionForPing = derived.session;
453
+ sessionRecordSource = derived.source;
454
+
455
+ const derivedStartMs = Date.parse(sessionForPing.started_at);
157
456
  const cutoff = (Number.isNaN(Date.parse(nowIso)) ? Date.now() : Date.parse(nowIso)) - DAILY_FLUSH_MS;
457
+ const windowStart = Number.isNaN(derivedStartMs) ? cutoff : derivedStartMs;
158
458
  windowInvocations = invocations.filter((rec) => {
159
459
  const t = Date.parse(rec?.timestamp);
160
- return !Number.isNaN(t) && t >= cutoff;
460
+ return !Number.isNaN(t) && t >= windowStart;
161
461
  });
162
- sessionForPing = {};
163
462
  }
164
463
 
165
464
  const cfg = ownerConfig ?? loadOwnerConfig().config;
@@ -171,6 +470,9 @@ export function buildBatch({
171
470
  env,
172
471
  now: nowIso,
173
472
  roster,
473
+ consentState,
474
+ sessionRecordSource,
475
+ sessionProfile: readSessionProfileForMetricsDir(dir),
174
476
  });
175
477
 
176
478
  const target = statePath || TELEMETRY_JSON_PATH;
@@ -195,6 +497,61 @@ export function buildBatch({
195
497
  }
196
498
  }
197
499
 
500
+ // ---------------------------------------------------------------------------
501
+ // Transport-boundary normalisation (the queue is not a trusted producer)
502
+ // ---------------------------------------------------------------------------
503
+
504
+ /**
505
+ * THE BUG THIS EXISTS FOR: a record written to the offline queue by an OLDER
506
+ * client — one built before `session_profile` was whitelisted — carries whatever
507
+ * `session-profile` that host's STATE.md held, e.g. a private repo name. `flush`
508
+ * forwarded queued batches to the sender VERBATIM, so the builder-side whitelist
509
+ * (`normalizeSessionProfile`, applied in `buildUsagePing`) was bypassed for every
510
+ * record that had ever been queued. Two consequences, both live:
511
+ *
512
+ * (a) PRIVACY — the private string reaches the wire on every later flush.
513
+ * (b) POISON QUEUE — the ingest server validates a batch ALL-OR-NOTHING, so
514
+ * the unknown profile 400s the whole batch; the new record is queued and
515
+ * the queue grows 1 → 2 → 3 … and never drains again.
516
+ *
517
+ * The fix is a boundary invariant, not a one-off patch: **a queued record can
518
+ * never carry what a freshly built one cannot.** Every queued entry passes the
519
+ * SAME two steps the builder applies — `projectUsagePing` field projection, then
520
+ * the `normalizeSessionProfile` whitelist (unlisted ⇒ the key is DROPPED, per
521
+ * that function's omit-don't-degrade contract).
522
+ *
523
+ * @param {unknown} batch A record as read back from the offline queue.
524
+ * @returns {object} The projected + normalised record safe to hand to the sender.
525
+ */
526
+ export function sanitizeQueuedRecord(batch) {
527
+ const record = projectUsagePing(batch);
528
+ if ('session_profile' in record) {
529
+ const profile = normalizeSessionProfile(record.session_profile);
530
+ if (profile === null) delete record.session_profile;
531
+ else record.session_profile = profile;
532
+ }
533
+ return record;
534
+ }
535
+
536
+ /**
537
+ * Does this send failure mean "the server refused this PAYLOAD" (evict) rather
538
+ * than "the send did not get through" (re-queue)?
539
+ *
540
+ * `defaultSender` attaches `err.status`; an injected sender that throws a bare
541
+ * Error carries no status and therefore always routes to the re-queue branch —
542
+ * the pre-existing behaviour, unchanged.
543
+ *
544
+ * Only 400 (schema) and 422 (semantic) count. 408/429 and every 5xx are
545
+ * transport-class and MUST re-queue: a rate-limited batch is not a bad batch.
546
+ *
547
+ * @param {unknown} err
548
+ * @returns {boolean}
549
+ */
550
+ function isSchemaRejection(err) {
551
+ const status = Number(err?.status);
552
+ return status === 400 || status === 422;
553
+ }
554
+
198
555
  // ---------------------------------------------------------------------------
199
556
  // Flush
200
557
  // ---------------------------------------------------------------------------
@@ -214,6 +571,11 @@ export function buildBatch({
214
571
  * @param {string} [opts.now] ISO timestamp (sent_at, last_flush_at, rotation clock).
215
572
  * @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here). Inject to
216
573
  * isolate a test from the host's real owner.yaml fleet flag.
574
+ * `reason` values: `gated` (consent), `sandbox:*` (the sandbox guard refused —
575
+ * no network, no queue mutation, no anon-ID mint), `debug`, `queued`, `sent`,
576
+ * `rejected-evicted` (the server refused the payload with 400/422 — the batch is
577
+ * dropped instead of re-queued forever), `no-record`, `build-error: …`.
578
+ *
217
579
  * @returns {Promise<{ sent: boolean, queued: boolean, state: string, reason: string }>}
218
580
  */
219
581
  export async function flush({
@@ -243,11 +605,28 @@ export async function flush({
243
605
  return { sent: false, queued: false, state: consent.state, reason: 'gated' };
244
606
  }
245
607
 
608
+ // SANDBOX GUARD — runs strictly between the consent gate and buildBatch, so a
609
+ // refused send performs no network call, no queue write and no anon-ID mint.
610
+ // See detectSandbox for the six production pings this exists to prevent.
611
+ const sandbox = detectSandbox({ env, statePath });
612
+ if (sandbox.sandbox) {
613
+ return { sent: false, queued: false, state: consent.state, reason: sandbox.reason };
614
+ }
615
+
246
616
  const nowIso = now || new Date().toISOString();
247
617
 
248
618
  // Build the batch (this lazily mints + persists the anon-ID — only reachable
249
619
  // here, i.e. strictly after the gate).
250
- const { record, reason } = buildBatch({ metricsDir, env, ownerConfig: cfg, statePath, now: nowIso });
620
+ const { record, reason } = buildBatch({
621
+ metricsDir,
622
+ env,
623
+ ownerConfig: cfg,
624
+ statePath,
625
+ now: nowIso,
626
+ // The RESOLVED consent state is what `fleet` is derived from now — not a raw
627
+ // owner.yaml read. See buildUsagePing's fleet block (d8 root cause a).
628
+ consentState: consent.state,
629
+ });
251
630
  if (!record) {
252
631
  return { sent: false, queued: false, state: consent.state, reason: reason || 'no-record' };
253
632
  }
@@ -258,17 +637,31 @@ export async function flush({
258
637
  return { sent: false, queued: false, state: consent.state, reason: 'debug' };
259
638
  }
260
639
 
261
- // Drain the existing queue together with the new record in ONE send.
262
- const queuedBatches = peekAll({ path: queuePath }).map((entry) => entry.batch);
640
+ // Drain the existing queue together with the new record in ONE send. Every
641
+ // queued record is re-normalised at this boundary — see sanitizeQueuedRecord
642
+ // for the privacy + poison-queue defect that made this necessary.
643
+ const queuedBatches = peekAll({ path: queuePath }).map((entry) => sanitizeQueuedRecord(entry.batch));
263
644
  const batches = [...queuedBatches, record];
264
645
 
265
646
  const send = typeof sender === 'function' ? sender : defaultSender({ env, timeoutMs });
266
647
 
267
648
  try {
268
649
  await send(batches);
269
- } catch {
270
- // Send failed → only the NEW record joins the queue (queued batches remain
271
- // in place since the queue was not cleared).
650
+ } catch (err) {
651
+ if (isSchemaRejection(err)) {
652
+ // The server refused the PAYLOAD. Re-queueing would replay the identical
653
+ // batch forever, which is the poison-queue class itself. Drop it.
654
+ //
655
+ // Named ceiling (BV-004): the ingest API validates a batch all-or-nothing
656
+ // and returns no per-record index, so the rejected record cannot be
657
+ // identified — the only bounded choice is to evict the WHOLE batch (the
658
+ // queued records AND the new one). Revisit if the server ever reports
659
+ // which entries failed; then evict only those.
660
+ clear({ path: queuePath });
661
+ return { sent: false, queued: false, state: consent.state, reason: 'rejected-evicted' };
662
+ }
663
+ // Transport failure → only the NEW record joins the queue (queued batches
664
+ // remain in place since the queue was not cleared).
272
665
  enqueue(record, { path: queuePath, now: nowIso });
273
666
  return { sent: false, queued: true, state: consent.state, reason: 'queued' };
274
667
  }
@@ -331,8 +724,8 @@ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.
331
724
  if (count > 0) return true;
332
725
 
333
726
  // (b) Catch-up: a session completed after the last successful flush.
334
- // Reuses buildBatch's reader same file, same skipInvalid tolerance,
335
- // no second parser.
727
+ // Reuses buildBatch's reader (#1186: readCanonicalSessions +
728
+ // mostRecentSession) — same file, same #1167 dedupe, no second parser.
336
729
  //
337
730
  // Deliberate simplification (named ceiling): this parses the WHOLE
338
731
  // sessions.jsonl to look at its last record. At the observed ledger size
@@ -340,8 +733,8 @@ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.
340
733
  // and it only runs once the 24h gate above has already passed. Revisit
341
734
  // with a tail-read if any repo's sessions.jsonl passes ~10 MB.
342
735
  const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
343
- const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
344
- const last = sessions.length > 0 ? sessions[sessions.length - 1] : null;
736
+ const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
737
+ const last = mostRecentSession(sessions);
345
738
  const completedMs = Date.parse(last?.completed_at);
346
739
  return !Number.isNaN(completedMs) && completedMs > lastMs;
347
740
  } catch {
@@ -19,6 +19,7 @@ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
21
  import { findProjectRoot } from '../common.mjs';
22
+ import { stampEventSchemaVersion, validateEventRecord } from '../events-schema.mjs';
22
23
 
23
24
  /**
24
25
  * Path FRAGMENT joined against a resolved repo root at write time — NOT a
@@ -58,11 +59,22 @@ export function emit(eventType, payload = {}, { repoRoot } = {}) {
58
59
  const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
59
60
  const dir = path.dirname(eventsPath);
60
61
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
61
- const record = {
62
+ // `stampEventSchemaVersion()` rather than an inline `schema_version:` —
63
+ // one stamper for the whole ledger (#1177). It is additive (stamps only
64
+ // when absent), so a payload that carries its own version still wins, and
65
+ // it is pure (no fs), so the sync write path below is unaffected.
66
+ const record = stampEventSchemaVersion({
62
67
  event: eventType,
63
68
  timestamp: new Date().toISOString(),
64
69
  ...payload,
65
- };
70
+ });
71
+ // Same schema contract as emitEvent() (#1177), enforced here too because
72
+ // this is the ONE remaining raw writer of events.jsonl. Kept synchronous on
73
+ // purpose — withTelemetry() wraps sync layout code; validateEventRecord() is
74
+ // pure (no fs), so conformance costs no async hop. An invalid record is
75
+ // DROPPED rather than written: telemetry is best-effort by contract, and a
76
+ // malformed line would outlive this process in the shared ledger.
77
+ if (!validateEventRecord(record).valid) return;
66
78
  appendFileSync(eventsPath, JSON.stringify(record) + '\n');
67
79
  } catch {
68
80
  // Best-effort — swallow all errors. Telemetry must not block layout.