session-orchestrator 3.24.0 → 4.0.1

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 (435) 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 +3 -2
  47. package/.codex-plugin/skills/architecture/SKILL.md +20 -0
  48. package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
  49. package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
  50. package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
  51. package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
  52. package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
  53. package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
  54. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
  55. package/.codex-plugin/skills/close/SKILL.md +21 -0
  56. package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
  57. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
  58. package/.codex-plugin/skills/debug/SKILL.md +21 -0
  59. package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
  60. package/.codex-plugin/skills/discovery/SKILL.md +21 -0
  61. package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
  62. package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
  63. package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
  64. package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
  65. package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
  66. package/.codex-plugin/skills/eli5/SKILL.md +21 -0
  67. package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
  68. package/.codex-plugin/skills/eval/SKILL.md +21 -0
  69. package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
  70. package/.codex-plugin/skills/evolve/SKILL.md +21 -0
  71. package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
  72. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
  73. package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
  74. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
  75. package/.codex-plugin/skills/go/SKILL.md +22 -0
  76. package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
  77. package/.codex-plugin/skills/grill/SKILL.md +21 -0
  78. package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
  79. package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
  80. package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
  81. package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
  82. package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
  83. package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
  84. package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
  85. package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
  86. package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
  87. package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
  88. package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
  89. package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
  90. package/.codex-plugin/skills/plan/SKILL.md +22 -0
  91. package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
  92. package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
  93. package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
  94. package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
  95. package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
  96. package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
  97. package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
  98. package/.codex-plugin/skills/release/SKILL.md +22 -0
  99. package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
  100. package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
  101. package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
  102. package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
  103. package/.codex-plugin/skills/session/SKILL.md +21 -0
  104. package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
  105. package/.codex-plugin/skills/session-end/SKILL.md +22 -0
  106. package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
  107. package/.codex-plugin/skills/session-start/SKILL.md +22 -0
  108. package/.codex-plugin/skills/spinout/SKILL.md +21 -0
  109. package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
  110. package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
  111. package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
  112. package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
  113. package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
  114. package/.codex-plugin/skills/test/SKILL.md +21 -0
  115. package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
  116. package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
  117. package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
  118. package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
  119. package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
  120. package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
  121. package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
  122. package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
  123. package/.cursor/commands/autopilot.md +2 -2
  124. package/.cursor/commands/bootstrap.md +1 -1
  125. package/.cursor/commands/brainstorm.md +1 -1
  126. package/.cursor/commands/debug.md +1 -1
  127. package/.cursor/commands/discovery.md +1 -1
  128. package/.cursor/commands/dispatcher.md +2 -2
  129. package/.cursor/commands/eli5.md +2 -2
  130. package/.cursor/commands/eval.md +2 -2
  131. package/.cursor/commands/evolve.md +1 -1
  132. package/.cursor/commands/go.md +1 -1
  133. package/.cursor/commands/grill.md +2 -2
  134. package/.cursor/commands/memory-cleanup.md +2 -2
  135. package/.cursor/commands/persona-panel.md +1 -1
  136. package/.cursor/commands/plan.md +1 -1
  137. package/.cursor/commands/portfolio.md +1 -1
  138. package/.cursor/commands/reconcile.md +2 -2
  139. package/.cursor/commands/release.md +2 -2
  140. package/.cursor/commands/session.md +2 -2
  141. package/.cursor/commands/spinout.md +2 -2
  142. package/.cursor/commands/sunset-review.md +2 -2
  143. package/.cursor/commands/templates-ack.md +2 -2
  144. package/.cursor/commands/test.md +2 -2
  145. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  146. package/.cursor/skills/eval/SKILL.md +1 -1
  147. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  148. package/.cursor/skills/remote-offload/SKILL.md +1 -1
  149. package/.cursor-plugin/plugin.json +30 -0
  150. package/.orchestrator/policy/blocked-commands.json +121 -0
  151. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  152. package/.orchestrator/policy/quality-gates.example.json +16 -0
  153. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  154. package/.orchestrator/policy/templates-policy.json +27 -0
  155. package/.orchestrator/policy/test-profiles.json +47 -0
  156. package/AGENTS.md +225 -0
  157. package/CHANGELOG.md +1314 -2
  158. package/NOTICE +11 -6
  159. package/README.md +135 -94
  160. package/agents/eval-judge.md +1 -1
  161. package/agents/skill-applied-judge.md +1 -1
  162. package/assets/wave-lifecycle.svg +98 -0
  163. package/commands/release.md +6 -3
  164. package/commands/session.md +18 -3
  165. package/docs/README.md +4 -0
  166. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  167. package/docs/baseline.md +67 -0
  168. package/docs/ci-setup.md +108 -62
  169. package/docs/codex-setup.md +107 -29
  170. package/docs/components.md +38 -16
  171. package/docs/cursor-setup.md +6 -2
  172. package/docs/events-schema.md +9 -6
  173. package/docs/instruction-delivery.md +69 -0
  174. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  175. package/docs/migration-v4.md +365 -0
  176. package/docs/pi-setup.md +6 -1
  177. package/docs/plugin-architecture-v3.md +1 -1
  178. package/docs/rule-authoring.md +85 -19
  179. package/docs/scope-collision-guard.md +5 -5
  180. package/docs/session-config-reference.md +57 -56
  181. package/docs/session-config-template.md +6 -29
  182. package/docs/telemetry.md +157 -3
  183. package/docs/vault-docs-architecture.md +50 -11
  184. package/hooks/_lib/hook-import-set.json +1488 -0
  185. package/hooks/_lib/subagent-transcript.mjs +562 -0
  186. package/hooks/config-protection.mjs +2 -2
  187. package/hooks/cwd-change-restore.mjs +2 -2
  188. package/hooks/enforce-commands.mjs +69 -0
  189. package/hooks/hooks-codex.json +1 -1
  190. package/hooks/hooks-cursor.json +10 -0
  191. package/hooks/hooks-pi.json +5 -0
  192. package/hooks/hooks.json +6 -1
  193. package/hooks/loop-guard.mjs +3 -3
  194. package/hooks/on-session-end.mjs +2 -2
  195. package/hooks/on-session-start.mjs +103 -2
  196. package/hooks/on-stop.mjs +60 -14
  197. package/hooks/operator-steer.mjs +2 -2
  198. package/hooks/post-bash-write-verify.mjs +85 -0
  199. package/hooks/post-edit-import-probe.mjs +344 -0
  200. package/hooks/post-subagent-discovery-validator.mjs +187 -431
  201. package/hooks/post-tool-batch-wave-signal.mjs +118 -4
  202. package/hooks/post-tool-failure-corrective-context.mjs +2 -2
  203. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  204. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  205. package/hooks/skill-invocation-telemetry.mjs +17 -5
  206. package/hooks/subagent-telemetry.mjs +13 -4
  207. package/monitors/monitors.json +3 -3
  208. package/package.json +9 -1
  209. package/pi/prompts/session.md +2 -2
  210. package/scripts/backfill-abandoned-sessions.mjs +50 -4
  211. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  212. package/scripts/dialectic-deriver.mjs +73 -8
  213. package/scripts/export-hw-learnings.mjs +113 -1
  214. package/scripts/generate-agents-skills.mjs +378 -0
  215. package/scripts/generate-codex-skills.mjs +246 -0
  216. package/scripts/generate-cursor-adapter.mjs +45 -8
  217. package/scripts/generate-hook-import-set.mjs +292 -0
  218. package/scripts/lib/agent-status.mjs +13 -2
  219. package/scripts/lib/auto-dream.mjs +38 -36
  220. package/scripts/lib/autonomy/suitability.mjs +6 -0
  221. package/scripts/lib/autopilot/loop.mjs +2 -2
  222. package/scripts/lib/ci-status-banner.mjs +220 -75
  223. package/scripts/lib/codex/plugin-contract.mjs +88 -6
  224. package/scripts/lib/config/auto-dream.mjs +2 -1
  225. package/scripts/lib/config/block-header.mjs +8 -0
  226. package/scripts/lib/config/block-preprocess.mjs +177 -0
  227. package/scripts/lib/config/broken-window.mjs +2 -1
  228. package/scripts/lib/config/cold-start.mjs +2 -1
  229. package/scripts/lib/config/config-protection.mjs +22 -2
  230. package/scripts/lib/config/context-coverage.mjs +2 -1
  231. package/scripts/lib/config/cross-repo.mjs +2 -1
  232. package/scripts/lib/config/custom-phases.mjs +2 -1
  233. package/scripts/lib/config/dialectic.mjs +2 -1
  234. package/scripts/lib/config/discovery-validator.mjs +2 -1
  235. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  236. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  237. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  238. package/scripts/lib/config/docs-staleness.mjs +2 -1
  239. package/scripts/lib/config/drift-check.mjs +2 -1
  240. package/scripts/lib/config/eval.mjs +2 -1
  241. package/scripts/lib/config/events-rotation.mjs +2 -1
  242. package/scripts/lib/config/evolve.mjs +8 -2
  243. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  244. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  245. package/scripts/lib/config/handover-gate.mjs +2 -1
  246. package/scripts/lib/config/health-endpoints.mjs +7 -2
  247. package/scripts/lib/config/host-paths.mjs +20 -4
  248. package/scripts/lib/config/issue-budget.mjs +2 -1
  249. package/scripts/lib/config/loop-guard.mjs +2 -1
  250. package/scripts/lib/config/memory.mjs +2 -1
  251. package/scripts/lib/config/moc-staleness.mjs +2 -1
  252. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  253. package/scripts/lib/config/private-config-dir.mjs +67 -0
  254. package/scripts/lib/config/reconcile.mjs +2 -1
  255. package/scripts/lib/config/remote-hosts.mjs +2 -1
  256. package/scripts/lib/config/section-extractor.mjs +7 -1
  257. package/scripts/lib/config/skill-evolution.mjs +2 -1
  258. package/scripts/lib/config/slopcheck.mjs +2 -1
  259. package/scripts/lib/config/state-md-lock.mjs +2 -1
  260. package/scripts/lib/config/templates-first.mjs +2 -1
  261. package/scripts/lib/config/test.mjs +2 -1
  262. package/scripts/lib/config/vault-integration.mjs +7 -1
  263. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  264. package/scripts/lib/config/vault-staleness.mjs +2 -1
  265. package/scripts/lib/config/vault-sync.mjs +2 -1
  266. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  267. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  268. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  269. package/scripts/lib/convergence-monitor.mjs +82 -16
  270. package/scripts/lib/dispatcher/rank.mjs +124 -48
  271. package/scripts/lib/ecosystem-health.mjs +16 -2
  272. package/scripts/lib/eval/engine.mjs +9 -1
  273. package/scripts/lib/eval/session-resolve.mjs +23 -4
  274. package/scripts/lib/events.mjs +22 -6
  275. package/scripts/lib/frontmatter-guard.mjs +131 -13
  276. package/scripts/lib/gates/gate-full.mjs +30 -0
  277. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  278. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  279. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  280. package/scripts/lib/host-identity.mjs +50 -11
  281. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  282. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  283. package/scripts/lib/learnings/io.mjs +60 -6
  284. package/scripts/lib/memory-proposals/store.mjs +30 -22
  285. package/scripts/lib/owner-config-banner.mjs +41 -6
  286. package/scripts/lib/owner-config-loader.mjs +21 -10
  287. package/scripts/lib/owner-interview.mjs +3 -3
  288. package/scripts/lib/owner-yaml.mjs +215 -15
  289. package/scripts/lib/platform.mjs +108 -15
  290. package/scripts/lib/plugin-update-banner.mjs +414 -0
  291. package/scripts/lib/project-hygiene.mjs +38 -2
  292. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  293. package/scripts/lib/quality-gate.mjs +133 -44
  294. package/scripts/lib/reconcile/emitter.mjs +68 -6
  295. package/scripts/lib/reconcile/engine.mjs +51 -11
  296. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  297. package/scripts/lib/reconcile/writer.mjs +40 -18
  298. package/scripts/lib/session-close-backfill.mjs +67 -9
  299. package/scripts/lib/session-id.mjs +12 -23
  300. package/scripts/lib/session-identity/own-session.mjs +125 -10
  301. package/scripts/lib/session-lock-shape.mjs +43 -0
  302. package/scripts/lib/session-lock.mjs +5 -10
  303. package/scripts/lib/session-registry.mjs +25 -9
  304. package/scripts/lib/session-schema/constants.mjs +64 -3
  305. package/scripts/lib/session-schema/validator.mjs +38 -4
  306. package/scripts/lib/session-start-probes.mjs +30 -1
  307. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  308. package/scripts/lib/skill-health/join.mjs +17 -4
  309. package/scripts/lib/state-md.mjs +78 -0
  310. package/scripts/lib/sunset/walker.mjs +6 -0
  311. package/scripts/lib/telemetry/schema.mjs +202 -9
  312. package/scripts/lib/telemetry/sync.mjs +368 -12
  313. package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
  314. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  315. package/scripts/lib/validate/check-agents.mjs +3 -3
  316. package/scripts/lib/validate/check-codex-skills.mjs +191 -0
  317. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  318. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  319. package/scripts/lib/validate/check-owner-leakage.mjs +319 -22
  320. package/scripts/lib/validate/check-skill-links.mjs +193 -0
  321. package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
  322. package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
  323. package/scripts/lib/validate/check-unwired-features.mjs +163 -15
  324. package/scripts/lib/validate/check-validator-registration.mjs +10 -4
  325. package/scripts/lib/validate/confidential-names.mjs +95 -30
  326. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  327. package/scripts/lib/validate/repo-files.mjs +48 -14
  328. package/scripts/lib/vault-backfill/template.mjs +63 -6
  329. package/scripts/lib/vault-mirror/process.mjs +165 -42
  330. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  331. package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
  332. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  333. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  334. package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
  335. package/scripts/lib/wave-resource-gate.mjs +8 -2
  336. package/scripts/lib/wave-sizing.mjs +4 -1
  337. package/scripts/lib/wave-transcript-tail.mjs +118 -4
  338. package/scripts/materialize-wave-scope.mjs +12 -5
  339. package/scripts/memory-propose.mjs +19 -5
  340. package/scripts/migrate-cold-start-seed.mjs +4 -1
  341. package/scripts/parse-config.mjs +60 -3
  342. package/scripts/release.mjs +430 -31
  343. package/scripts/repair-invalid-sessions.mjs +3 -3
  344. package/scripts/run-quality-gate.mjs +128 -11
  345. package/scripts/site-numbers.mjs +344 -8
  346. package/scripts/sweep-expired-learnings.mjs +90 -0
  347. package/scripts/sync-vault-schema.mjs +3 -1
  348. package/scripts/telemetry.mjs +2 -2
  349. package/scripts/validate-plugin.mjs +164 -0
  350. package/scripts/validate-wave-scope.mjs +28 -8
  351. package/scripts/wave-scope-binding.mjs +215 -0
  352. package/skills/_shared/instruction-file-resolution.md +10 -0
  353. package/skills/_shared/parallel-aware-preamble.md +1 -0
  354. package/skills/_shared/platform-tools.md +1 -1
  355. package/skills/_shared/state-ownership.md +1 -1
  356. package/skills/architecture/SKILL.md +7 -5
  357. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  358. package/skills/autopilot/SKILL.md +4 -18
  359. package/skills/claude-md-drift-check/SKILL.md +5 -1
  360. package/skills/claude-md-drift-check/checker.mjs +62 -2
  361. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  362. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  363. package/skills/discovery/probes-arch.md +20 -18
  364. package/skills/dispatcher/SKILL.md +3 -2
  365. package/skills/evolve/SKILL.md +65 -26
  366. package/skills/frontmatter-guard/SKILL.md +11 -5
  367. package/skills/npm-publish/SKILL.md +1 -1
  368. package/skills/reconcile/SKILL.md +33 -0
  369. package/skills/remote-offload/SKILL.md +1 -1
  370. package/skills/session-end/SKILL.md +18 -905
  371. package/skills/session-end/phase-3-6-tail.md +10 -3
  372. package/skills/session-end/plan-verification.md +221 -155
  373. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  374. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  375. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  376. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  377. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  378. package/skills/session-end/references/session-summary-template.md +62 -0
  379. package/skills/session-plan/SKILL.md +49 -0
  380. package/skills/session-start/SKILL.md +22 -904
  381. package/skills/session-start/phase-8-5-express-path.md +1 -1
  382. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  383. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  384. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  385. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  386. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  387. package/skills/session-start/references/phase-4-ssot-environment-check.md +160 -0
  388. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  389. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  390. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  391. package/skills/vault-sync/SKILL.md +10 -0
  392. package/skills/vault-sync/validator.mjs +21 -27
  393. package/skills/wave-executor/SKILL.md +15 -1
  394. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  395. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  396. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  397. package/skills/wave-executor/wave-loop.md +14 -1309
  398. package/templates/_shared/journey-manifest.md +10 -6
  399. package/.cursor/commands/autopilot-multi.md +0 -14
  400. package/.cursor/commands/contract-version-bump.md +0 -14
  401. package/.cursor/commands/journey-audit.md +0 -14
  402. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  403. package/.cursor/skills/daily/SKILL.md +0 -12
  404. package/.cursor/skills/domain-model/SKILL.md +0 -13
  405. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  406. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  407. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  408. package/commands/autopilot-multi.md +0 -74
  409. package/commands/contract-version-bump.md +0 -28
  410. package/commands/journey-audit.md +0 -43
  411. package/pi/prompts/autopilot-multi.md +0 -12
  412. package/pi/prompts/contract-version-bump.md +0 -12
  413. package/pi/prompts/journey-audit.md +0 -12
  414. package/scripts/autopilot-multi.mjs +0 -885
  415. package/scripts/backfill-learnings-expires.mjs +0 -196
  416. package/scripts/backfill-learnings.mjs +0 -203
  417. package/scripts/fleet-instruction-scan.mjs +0 -141
  418. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  419. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  420. package/scripts/lib/webhook-url.mjs +0 -105
  421. package/scripts/lifecycle-sim-v6.mjs +0 -347
  422. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  423. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  424. package/scripts/upload-social-preview.mjs +0 -316
  425. package/skills/_shared/model-selection.md +0 -64
  426. package/skills/contract-version-bump/SKILL.md +0 -219
  427. package/skills/daily/SKILL.md +0 -222
  428. package/skills/daily/generate.sh +0 -92
  429. package/skills/daily/templates/daily.md.tpl +0 -36
  430. package/skills/journey-audit/SKILL.md +0 -270
  431. package/skills/skill-creator/SKILL.md +0 -168
  432. package/skills/ubiquitous-language/SKILL.md +0 -97
  433. package/skills/vault-sync/package-lock.json +0 -40
  434. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  435. /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,12 +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';
42
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';
43
48
 
44
49
  // ---------------------------------------------------------------------------
45
50
  // Constants
@@ -85,11 +90,158 @@ function defaultSender({ env, timeoutMs }) {
85
90
  signal: AbortSignal.timeout(timeoutMs),
86
91
  });
87
92
  if (!res.ok) {
88
- 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;
89
100
  }
90
101
  };
91
102
  }
92
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
+
93
245
  // ---------------------------------------------------------------------------
94
246
  // Batch build
95
247
  // ---------------------------------------------------------------------------
@@ -129,6 +281,104 @@ function mostRecentSession(records) {
129
281
  return best;
130
282
  }
131
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
+
132
382
  /**
133
383
  * Build ONE whitelist-projected usage-ping record from the local JSONL streams.
134
384
  *
@@ -169,6 +419,7 @@ export function buildBatch({
169
419
  now,
170
420
  statePath,
171
421
  persist = true,
422
+ consentState,
172
423
  } = {}) {
173
424
  try {
174
425
  const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
@@ -181,6 +432,8 @@ export function buildBatch({
181
432
 
182
433
  let windowInvocations;
183
434
  let sessionForPing;
435
+ /** @type {'ledger'|'derived'|'absent'} */
436
+ let sessionRecordSource = 'ledger';
184
437
  if (sessionRecord && typeof sessionRecord.started_at === 'string' && !Number.isNaN(Date.parse(sessionRecord.started_at))) {
185
438
  const startMs = Date.parse(sessionRecord.started_at);
186
439
  windowInvocations = invocations.filter((rec) => {
@@ -188,15 +441,24 @@ export function buildBatch({
188
441
  return !Number.isNaN(t) && t >= startMs;
189
442
  });
190
443
  sessionForPing = sessionRecord;
444
+ sessionRecordSource = 'ledger';
191
445
  } else {
192
- // No usable session record → 24h window + synthetic session (schema
193
- // 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);
194
456
  const cutoff = (Number.isNaN(Date.parse(nowIso)) ? Date.now() : Date.parse(nowIso)) - DAILY_FLUSH_MS;
457
+ const windowStart = Number.isNaN(derivedStartMs) ? cutoff : derivedStartMs;
195
458
  windowInvocations = invocations.filter((rec) => {
196
459
  const t = Date.parse(rec?.timestamp);
197
- return !Number.isNaN(t) && t >= cutoff;
460
+ return !Number.isNaN(t) && t >= windowStart;
198
461
  });
199
- sessionForPing = {};
200
462
  }
201
463
 
202
464
  const cfg = ownerConfig ?? loadOwnerConfig().config;
@@ -208,6 +470,9 @@ export function buildBatch({
208
470
  env,
209
471
  now: nowIso,
210
472
  roster,
473
+ consentState,
474
+ sessionRecordSource,
475
+ sessionProfile: readSessionProfileForMetricsDir(dir),
211
476
  });
212
477
 
213
478
  const target = statePath || TELEMETRY_JSON_PATH;
@@ -232,6 +497,61 @@ export function buildBatch({
232
497
  }
233
498
  }
234
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
+
235
555
  // ---------------------------------------------------------------------------
236
556
  // Flush
237
557
  // ---------------------------------------------------------------------------
@@ -251,6 +571,11 @@ export function buildBatch({
251
571
  * @param {string} [opts.now] ISO timestamp (sent_at, last_flush_at, rotation clock).
252
572
  * @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here). Inject to
253
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
+ *
254
579
  * @returns {Promise<{ sent: boolean, queued: boolean, state: string, reason: string }>}
255
580
  */
256
581
  export async function flush({
@@ -280,11 +605,28 @@ export async function flush({
280
605
  return { sent: false, queued: false, state: consent.state, reason: 'gated' };
281
606
  }
282
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
+
283
616
  const nowIso = now || new Date().toISOString();
284
617
 
285
618
  // Build the batch (this lazily mints + persists the anon-ID — only reachable
286
619
  // here, i.e. strictly after the gate).
287
- 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
+ });
288
630
  if (!record) {
289
631
  return { sent: false, queued: false, state: consent.state, reason: reason || 'no-record' };
290
632
  }
@@ -295,17 +637,31 @@ export async function flush({
295
637
  return { sent: false, queued: false, state: consent.state, reason: 'debug' };
296
638
  }
297
639
 
298
- // Drain the existing queue together with the new record in ONE send.
299
- 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));
300
644
  const batches = [...queuedBatches, record];
301
645
 
302
646
  const send = typeof sender === 'function' ? sender : defaultSender({ env, timeoutMs });
303
647
 
304
648
  try {
305
649
  await send(batches);
306
- } catch {
307
- // Send failed → only the NEW record joins the queue (queued batches remain
308
- // 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).
309
665
  enqueue(record, { path: queuePath, now: nowIso });
310
666
  return { sent: false, queued: true, state: consent.state, reason: 'queued' };
311
667
  }