session-orchestrator 3.24.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +1 -1
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1125 -2
  81. package/NOTICE +11 -6
  82. package/README.md +127 -94
  83. package/agents/eval-judge.md +1 -1
  84. package/agents/skill-applied-judge.md +1 -1
  85. package/assets/wave-lifecycle.svg +98 -0
  86. package/commands/release.md +6 -3
  87. package/commands/session.md +18 -3
  88. package/docs/README.md +4 -0
  89. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  90. package/docs/baseline.md +67 -0
  91. package/docs/ci-setup.md +108 -62
  92. package/docs/codex-setup.md +65 -21
  93. package/docs/components.md +36 -15
  94. package/docs/cursor-setup.md +6 -2
  95. package/docs/events-schema.md +9 -6
  96. package/docs/instruction-delivery.md +62 -0
  97. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  98. package/docs/migration-v4.md +341 -0
  99. package/docs/pi-setup.md +6 -1
  100. package/docs/plugin-architecture-v3.md +1 -1
  101. package/docs/rule-authoring.md +85 -19
  102. package/docs/scope-collision-guard.md +5 -5
  103. package/docs/session-config-reference.md +57 -56
  104. package/docs/session-config-template.md +6 -29
  105. package/docs/telemetry.md +157 -3
  106. package/docs/vault-docs-architecture.md +50 -11
  107. package/hooks/_lib/hook-import-set.json +1487 -0
  108. package/hooks/_lib/subagent-transcript.mjs +562 -0
  109. package/hooks/config-protection.mjs +2 -2
  110. package/hooks/cwd-change-restore.mjs +2 -2
  111. package/hooks/enforce-commands.mjs +69 -0
  112. package/hooks/hooks-codex.json +1 -1
  113. package/hooks/hooks-cursor.json +10 -0
  114. package/hooks/hooks-pi.json +5 -0
  115. package/hooks/hooks.json +6 -1
  116. package/hooks/loop-guard.mjs +3 -3
  117. package/hooks/on-session-end.mjs +2 -2
  118. package/hooks/on-session-start.mjs +103 -2
  119. package/hooks/on-stop.mjs +36 -11
  120. package/hooks/operator-steer.mjs +2 -2
  121. package/hooks/post-bash-write-verify.mjs +85 -0
  122. package/hooks/post-edit-import-probe.mjs +344 -0
  123. package/hooks/post-subagent-discovery-validator.mjs +187 -431
  124. package/hooks/post-tool-batch-wave-signal.mjs +118 -4
  125. package/hooks/post-tool-failure-corrective-context.mjs +2 -2
  126. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  127. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  128. package/hooks/skill-invocation-telemetry.mjs +17 -5
  129. package/hooks/subagent-telemetry.mjs +13 -4
  130. package/monitors/monitors.json +3 -3
  131. package/package.json +9 -1
  132. package/pi/prompts/session.md +2 -2
  133. package/plugin.json +27 -0
  134. package/scripts/backfill-abandoned-sessions.mjs +50 -4
  135. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  136. package/scripts/dialectic-deriver.mjs +73 -8
  137. package/scripts/export-hw-learnings.mjs +113 -1
  138. package/scripts/generate-agents-skills.mjs +378 -0
  139. package/scripts/generate-cursor-adapter.mjs +45 -8
  140. package/scripts/generate-hook-import-set.mjs +249 -0
  141. package/scripts/lib/agent-status.mjs +13 -2
  142. package/scripts/lib/auto-dream.mjs +38 -36
  143. package/scripts/lib/autonomy/suitability.mjs +6 -0
  144. package/scripts/lib/autopilot/loop.mjs +2 -2
  145. package/scripts/lib/ci-status-banner.mjs +220 -75
  146. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  147. package/scripts/lib/config/auto-dream.mjs +2 -1
  148. package/scripts/lib/config/block-header.mjs +8 -0
  149. package/scripts/lib/config/block-preprocess.mjs +177 -0
  150. package/scripts/lib/config/broken-window.mjs +2 -1
  151. package/scripts/lib/config/cold-start.mjs +2 -1
  152. package/scripts/lib/config/config-protection.mjs +22 -2
  153. package/scripts/lib/config/context-coverage.mjs +2 -1
  154. package/scripts/lib/config/cross-repo.mjs +2 -1
  155. package/scripts/lib/config/custom-phases.mjs +2 -1
  156. package/scripts/lib/config/dialectic.mjs +2 -1
  157. package/scripts/lib/config/discovery-validator.mjs +2 -1
  158. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  159. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  160. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  161. package/scripts/lib/config/docs-staleness.mjs +2 -1
  162. package/scripts/lib/config/drift-check.mjs +2 -1
  163. package/scripts/lib/config/eval.mjs +2 -1
  164. package/scripts/lib/config/events-rotation.mjs +2 -1
  165. package/scripts/lib/config/evolve.mjs +8 -2
  166. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  167. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  168. package/scripts/lib/config/handover-gate.mjs +2 -1
  169. package/scripts/lib/config/health-endpoints.mjs +7 -2
  170. package/scripts/lib/config/issue-budget.mjs +2 -1
  171. package/scripts/lib/config/loop-guard.mjs +2 -1
  172. package/scripts/lib/config/memory.mjs +2 -1
  173. package/scripts/lib/config/moc-staleness.mjs +2 -1
  174. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  175. package/scripts/lib/config/private-config-dir.mjs +67 -0
  176. package/scripts/lib/config/reconcile.mjs +2 -1
  177. package/scripts/lib/config/remote-hosts.mjs +2 -1
  178. package/scripts/lib/config/section-extractor.mjs +7 -1
  179. package/scripts/lib/config/skill-evolution.mjs +2 -1
  180. package/scripts/lib/config/slopcheck.mjs +2 -1
  181. package/scripts/lib/config/state-md-lock.mjs +2 -1
  182. package/scripts/lib/config/templates-first.mjs +2 -1
  183. package/scripts/lib/config/test.mjs +2 -1
  184. package/scripts/lib/config/vault-integration.mjs +7 -1
  185. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  186. package/scripts/lib/config/vault-staleness.mjs +2 -1
  187. package/scripts/lib/config/vault-sync.mjs +2 -1
  188. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  189. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  190. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  191. package/scripts/lib/convergence-monitor.mjs +82 -16
  192. package/scripts/lib/dispatcher/rank.mjs +124 -48
  193. package/scripts/lib/ecosystem-health.mjs +16 -2
  194. package/scripts/lib/eval/engine.mjs +9 -1
  195. package/scripts/lib/eval/session-resolve.mjs +23 -4
  196. package/scripts/lib/events.mjs +22 -6
  197. package/scripts/lib/frontmatter-guard.mjs +131 -13
  198. package/scripts/lib/gates/gate-full.mjs +26 -0
  199. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  200. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  201. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  202. package/scripts/lib/host-identity.mjs +50 -11
  203. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  204. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  205. package/scripts/lib/learnings/io.mjs +60 -6
  206. package/scripts/lib/memory-proposals/store.mjs +30 -22
  207. package/scripts/lib/owner-config-banner.mjs +43 -6
  208. package/scripts/lib/owner-config-loader.mjs +21 -10
  209. package/scripts/lib/owner-interview.mjs +3 -3
  210. package/scripts/lib/owner-yaml.mjs +207 -14
  211. package/scripts/lib/platform.mjs +108 -15
  212. package/scripts/lib/plugin-update-banner.mjs +406 -0
  213. package/scripts/lib/project-hygiene.mjs +38 -2
  214. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  215. package/scripts/lib/quality-gate.mjs +133 -44
  216. package/scripts/lib/reconcile/emitter.mjs +68 -6
  217. package/scripts/lib/reconcile/engine.mjs +13 -4
  218. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  219. package/scripts/lib/reconcile/writer.mjs +40 -18
  220. package/scripts/lib/session-close-backfill.mjs +67 -9
  221. package/scripts/lib/session-id.mjs +12 -23
  222. package/scripts/lib/session-identity/own-session.mjs +125 -10
  223. package/scripts/lib/session-lock-shape.mjs +43 -0
  224. package/scripts/lib/session-lock.mjs +5 -10
  225. package/scripts/lib/session-registry.mjs +25 -9
  226. package/scripts/lib/session-schema/constants.mjs +36 -2
  227. package/scripts/lib/session-schema/validator.mjs +38 -4
  228. package/scripts/lib/session-start-probes.mjs +18 -1
  229. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  230. package/scripts/lib/skill-health/join.mjs +17 -4
  231. package/scripts/lib/state-md.mjs +78 -0
  232. package/scripts/lib/sunset/walker.mjs +6 -0
  233. package/scripts/lib/telemetry/schema.mjs +181 -9
  234. package/scripts/lib/telemetry/sync.mjs +368 -12
  235. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  236. package/scripts/lib/validate/check-agents.mjs +3 -3
  237. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  238. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  239. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  240. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  241. package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
  242. package/scripts/lib/validate/check-unwired-features.mjs +0 -2
  243. package/scripts/lib/validate/check-validator-registration.mjs +10 -4
  244. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  245. package/scripts/lib/vault-backfill/template.mjs +63 -6
  246. package/scripts/lib/vault-mirror/process.mjs +165 -42
  247. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  248. package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
  249. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  250. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  251. package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
  252. package/scripts/lib/wave-resource-gate.mjs +8 -2
  253. package/scripts/lib/wave-sizing.mjs +4 -1
  254. package/scripts/lib/wave-transcript-tail.mjs +118 -4
  255. package/scripts/materialize-wave-scope.mjs +12 -5
  256. package/scripts/memory-propose.mjs +19 -5
  257. package/scripts/migrate-cold-start-seed.mjs +4 -1
  258. package/scripts/parse-config.mjs +60 -3
  259. package/scripts/release.mjs +337 -29
  260. package/scripts/repair-invalid-sessions.mjs +3 -3
  261. package/scripts/run-quality-gate.mjs +128 -11
  262. package/scripts/sweep-expired-learnings.mjs +90 -0
  263. package/scripts/sync-vault-schema.mjs +3 -1
  264. package/scripts/telemetry.mjs +2 -2
  265. package/scripts/validate-plugin.mjs +161 -0
  266. package/scripts/validate-wave-scope.mjs +28 -8
  267. package/scripts/wave-scope-binding.mjs +215 -0
  268. package/skills/_shared/instruction-file-resolution.md +10 -0
  269. package/skills/_shared/parallel-aware-preamble.md +1 -0
  270. package/skills/_shared/platform-tools.md +1 -1
  271. package/skills/_shared/state-ownership.md +1 -1
  272. package/skills/architecture/SKILL.md +7 -5
  273. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  274. package/skills/autopilot/SKILL.md +4 -18
  275. package/skills/claude-md-drift-check/SKILL.md +5 -1
  276. package/skills/claude-md-drift-check/checker.mjs +62 -2
  277. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  278. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  279. package/skills/discovery/probes-arch.md +20 -18
  280. package/skills/dispatcher/SKILL.md +3 -2
  281. package/skills/evolve/SKILL.md +65 -26
  282. package/skills/frontmatter-guard/SKILL.md +11 -5
  283. package/skills/npm-publish/SKILL.md +1 -1
  284. package/skills/reconcile/SKILL.md +33 -0
  285. package/skills/remote-offload/SKILL.md +1 -1
  286. package/skills/session-end/SKILL.md +18 -905
  287. package/skills/session-end/phase-3-6-tail.md +10 -3
  288. package/skills/session-end/plan-verification.md +221 -155
  289. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  290. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  291. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  292. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  293. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  294. package/skills/session-end/references/session-summary-template.md +62 -0
  295. package/skills/session-plan/SKILL.md +49 -0
  296. package/skills/session-start/SKILL.md +22 -904
  297. package/skills/session-start/phase-8-5-express-path.md +1 -1
  298. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  299. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  300. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  301. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  302. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  303. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  304. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  305. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  306. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  307. package/skills/vault-sync/validator.mjs +21 -27
  308. package/skills/wave-executor/SKILL.md +15 -1
  309. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  310. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  311. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  312. package/skills/wave-executor/wave-loop.md +14 -1309
  313. package/templates/_shared/journey-manifest.md +10 -6
  314. package/.cursor/commands/autopilot-multi.md +0 -14
  315. package/.cursor/commands/contract-version-bump.md +0 -14
  316. package/.cursor/commands/journey-audit.md +0 -14
  317. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  318. package/.cursor/skills/daily/SKILL.md +0 -12
  319. package/.cursor/skills/domain-model/SKILL.md +0 -13
  320. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  321. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  322. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  323. package/commands/autopilot-multi.md +0 -74
  324. package/commands/contract-version-bump.md +0 -28
  325. package/commands/journey-audit.md +0 -43
  326. package/pi/prompts/autopilot-multi.md +0 -12
  327. package/pi/prompts/contract-version-bump.md +0 -12
  328. package/pi/prompts/journey-audit.md +0 -12
  329. package/scripts/autopilot-multi.mjs +0 -885
  330. package/scripts/backfill-learnings-expires.mjs +0 -196
  331. package/scripts/backfill-learnings.mjs +0 -203
  332. package/scripts/fleet-instruction-scan.mjs +0 -141
  333. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  334. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  335. package/scripts/lib/webhook-url.mjs +0 -105
  336. package/scripts/lifecycle-sim-v6.mjs +0 -347
  337. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  338. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  339. package/scripts/upload-social-preview.mjs +0 -316
  340. package/skills/_shared/model-selection.md +0 -64
  341. package/skills/contract-version-bump/SKILL.md +0 -219
  342. package/skills/daily/SKILL.md +0 -222
  343. package/skills/daily/generate.sh +0 -92
  344. package/skills/daily/templates/daily.md.tpl +0 -36
  345. package/skills/journey-audit/SKILL.md +0 -270
  346. package/skills/skill-creator/SKILL.md +0 -168
  347. package/skills/ubiquitous-language/SKILL.md +0 -97
  348. package/skills/vault-sync/package-lock.json +0 -40
  349. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  350. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -132,6 +132,7 @@ import path from 'node:path';
132
132
 
133
133
  import { readLock, DEFAULT_TTL_HOURS } from './session-lock.mjs';
134
134
  import { isRealSession } from './session-schema/filters.mjs';
135
+ import { readCanonicalSessions } from './sessions-canonical.mjs';
135
136
 
136
137
  /** Repo-relative path to the session ledger (one record per closed session). */
137
138
  const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
@@ -240,20 +241,21 @@ function keepNewer(current, candidate) {
240
241
  * result so the caller can say the anchor is a stub start, not a measured close;
241
242
  * the key is omitted (`undefined`) on the genuine path.
242
243
  *
243
- * @param {string[]} lines
244
+ * `records` is the CANONICAL (#1209b) record set — `readCanonicalSessions()`
245
+ * has already collapsed a duplicated `session_id` to its newest occurrence, so
246
+ * a since-corrected raw LINE for the same identity (e.g. one later marked
247
+ * `status: 'abandoned'`, or a stale `completed_at` a later record for the same
248
+ * id superseded) can no longer independently skew this max-reduce the way a
249
+ * raw-line scan over every append could.
250
+ *
251
+ * @param {object[]} records
244
252
  * @returns {{iso: string, ms: number, stubFallback?: true}|null}
245
253
  */
246
- function lastLedgerEntry(lines) {
254
+ function lastLedgerEntry(records) {
247
255
  let newestGenuine = null; // newest genuine completed_at (or started_at floor)
248
256
  let newestStub = null; // newest stub started_at
249
257
 
250
- for (const line of lines) {
251
- let record;
252
- try {
253
- record = JSON.parse(line);
254
- } catch {
255
- continue;
256
- }
258
+ for (const record of records) {
257
259
  if (!record || typeof record !== 'object') continue;
258
260
 
259
261
  if (isBackfillStub(record)) {
@@ -370,10 +372,15 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
370
372
 
371
373
  const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
372
374
 
373
- const sessionLines = readJsonlLines(path.join(repoRoot, SESSIONS_PATH));
375
+ const sessionsPath = path.join(repoRoot, SESSIONS_PATH);
376
+ const sessionLines = readJsonlLines(sessionsPath);
374
377
  if (sessionLines === null || sessionLines.length === 0) return null;
375
378
 
376
- const ledger = lastLedgerEntry(sessionLines);
379
+ // CANONICAL (#1209b) record set — readJsonlLines() above only decides
380
+ // missing-vs-empty (readCanonicalSessions() cannot tell those apart, see
381
+ // its own doc); the anchor scan itself now runs over the collapsed set.
382
+ const sessionRecords = readCanonicalSessions({ filePath: sessionsPath });
383
+ const ledger = lastLedgerEntry(sessionRecords);
377
384
  if (ledger === null) return null;
378
385
 
379
386
  const eventLines = readJsonlLines(path.join(repoRoot, EVENTS_PATH));
@@ -35,6 +35,7 @@ import path from 'node:path';
35
35
  import { fileURLToPath } from 'node:url';
36
36
 
37
37
  import { isRealSession } from '../session-schema/filters.mjs';
38
+ import { readCanonicalSessions } from '../sessions-canonical.mjs';
38
39
 
39
40
  const DEFAULT_INVOCATIONS_PATH = path.resolve(
40
41
  fileURLToPath(import.meta.url),
@@ -81,6 +82,15 @@ async function readJsonl(filePath) {
81
82
  * it to route the join to the `abandoned` outcome bucket instead of counting
82
83
  * a zero-signal join as `sessionsJoined`.
83
84
  *
85
+ * Callers pass `sessionRecords` from `readCanonicalSessions()` (#1209b) — one
86
+ * record per physical session (newest-wins per `session_id`, systemic
87
+ * double-stub twin dropped, superseded stubs removed). Without that upstream
88
+ * collapse, the two-writer double-stub class (a SEPARATE synthetic
89
+ * `session_id` for the SAME physical session) would enter this map as a
90
+ * second, independent `abandoned` entry — this function's own `map.set()`
91
+ * overwrite only dedupes an EXACT `session_id` repeat, never two different
92
+ * ids for one session.
93
+ *
84
94
  * @param {object[]} sessionRecords
85
95
  * @returns {Map<string, { agentSummary: { complete: number, partial: number, failed: number, spiral: number }, real: boolean }>}
86
96
  */
@@ -121,10 +131,13 @@ export async function joinSkillOutcomes({
121
131
  invocationsPath = DEFAULT_INVOCATIONS_PATH,
122
132
  sessionsPath = DEFAULT_SESSIONS_PATH,
123
133
  } = {}) {
124
- const [invocations, sessionRecords] = await Promise.all([
125
- readJsonl(invocationsPath),
126
- readJsonl(sessionsPath),
127
- ]);
134
+ // Invocations stay on the raw async reader (no identity-collapse concept
135
+ // applies to a selection-event stream). Sessions move to the CANONICAL
136
+ // (#1209b) reader — synchronous by contract (see sessions-canonical.mjs) —
137
+ // so a session_id counted twice under the two-writer double-stub bug no
138
+ // longer inflates `sessionsAbandoned` (see buildSessionMap doc above).
139
+ const invocations = await readJsonl(invocationsPath);
140
+ const sessionRecords = readCanonicalSessions({ filePath: sessionsPath });
128
141
 
129
142
  const sessionMap = buildSessionMap(sessionRecords);
130
143
 
@@ -9,8 +9,15 @@
9
9
  * @see scripts/lib/state-md/body-sections.mjs readCurrentTask, appendDeviation, markExpressPathComplete, appendWhatNotToRetry, readWhatNotToRetry, readOpenQuestions, appendOpenQuestion, markOpenQuestionAnswered
10
10
  * @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, parseMissionStatusStrict, MISSION_STATUS_VALUES, writeMissionStatus, setMissionStatus, setMissionStatusDetailed, readMissionStatus, recoverFrontmatterMissionStatusDetailed, writeMissionStatusOnDisk, setMissionStatusOnDisk
11
11
  * @see scripts/lib/state-md/recommendations.mjs parseRecommendations
12
+ *
13
+ * Plus ONE small non-re-export surface: the `session-profile` frontmatter
14
+ * accessors at the bottom of this file (see their docblock for why they are
15
+ * composed here rather than added as a fourth mutator module).
12
16
  */
13
17
 
18
+ import { parseStateMd as _parseStateMd } from './state-md/yaml-parser.mjs';
19
+ import { updateFrontmatterFields as _updateFrontmatterFields } from './state-md/frontmatter-mutators.mjs';
20
+
14
21
  export { parseStateMd, serializeStateMd } from './state-md/yaml-parser.mjs';
15
22
 
16
23
  export {
@@ -61,3 +68,74 @@ export {
61
68
  } from './state-md/mission-status.mjs';
62
69
 
63
70
  export { parseRecommendations } from './state-md/recommendations.mjs';
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Session profile (PRD docs/prd/2026-09-06-ultradeep-session-profile.md)
74
+ // ---------------------------------------------------------------------------
75
+ //
76
+ // `session-profile` is an OPTIONAL STATE.md frontmatter scalar that names a
77
+ // wave-shape variant on top of an unchanged `session-type`. Today exactly one
78
+ // value is defined — `ultradeep` (7 waves, coordinator-direct Synthesis-Gate at
79
+ // wave 2) — resolved from the `/session ultradeep` argument alias in
80
+ // `commands/session.md`.
81
+ //
82
+ // The vocabulary is deliberately NOT a closed set here. A profile changes only
83
+ // how the coordinator shapes waves; unlike `session_type` (a closed set in
84
+ // scripts/lib/session-schema/constants.mjs, telemetry and the close-backfill),
85
+ // no consumer branches on the value, so an unknown one degrades to "a profile
86
+ // this reader does not recognise" rather than to a silent mislabel. Revisit
87
+ // trigger: the first consumer that BRANCHES on a specific profile value — at
88
+ // that point the set becomes load-bearing and belongs in a shared constant.
89
+ //
90
+ // Composed from the two existing helpers above rather than reaching into the
91
+ // frontmatter with a second parser: `parseStateMd` for the read,
92
+ // `updateFrontmatterFields` for the write (whose null/undefined semantics
93
+ // already mean DELETE, which is exactly "no profile").
94
+
95
+ /** Frontmatter key holding the optional session profile. */
96
+ export const SESSION_PROFILE_FIELD = 'session-profile';
97
+
98
+ /**
99
+ * Read the session profile from STATE.md contents.
100
+ *
101
+ * ABSENCE IS NEVER COERCED. Returns `null` — not `''`, not `'none'` — when the
102
+ * document has no frontmatter, no `session-profile` key, or a value that is not
103
+ * a non-empty string. Callers test `=== null` for "no profile"; they must not
104
+ * test truthiness of a string they assumed was always present.
105
+ *
106
+ * @param {string} contents Full STATE.md text.
107
+ * @returns {string|null} The profile name, or null when no profile is set.
108
+ */
109
+ export function readSessionProfile(contents) {
110
+ if (typeof contents !== 'string') return null;
111
+ const parsed = _parseStateMd(contents);
112
+ if (parsed === null) return null;
113
+ const value = parsed.frontmatter[SESSION_PROFILE_FIELD];
114
+ if (typeof value !== 'string') return null;
115
+ const trimmed = value.trim();
116
+ return trimmed.length > 0 ? trimmed : null;
117
+ }
118
+
119
+ /**
120
+ * Set or clear the session profile in STATE.md contents.
121
+ *
122
+ * Passing `null` DELETES the key, restoring the absent (= no profile) state —
123
+ * it never writes a placeholder value. Every other frontmatter key, including
124
+ * unknown extensions, is preserved verbatim by `updateFrontmatterFields`.
125
+ * No-ops (returns the input unchanged) when `contents` has no frontmatter.
126
+ *
127
+ * @param {string} contents Full STATE.md text.
128
+ * @param {string|null} profile Profile name, or null to clear.
129
+ * @returns {string} The new STATE.md text.
130
+ * @throws {TypeError} when `profile` is neither a non-empty string nor null.
131
+ */
132
+ export function setSessionProfile(contents, profile) {
133
+ if (profile !== null && (typeof profile !== 'string' || profile.trim().length === 0)) {
134
+ throw new TypeError(
135
+ `setSessionProfile: profile must be a non-empty string or null, got: ${JSON.stringify(profile)}`
136
+ );
137
+ }
138
+ return _updateFrontmatterFields(contents, {
139
+ [SESSION_PROFILE_FIELD]: profile === null ? null : profile.trim(),
140
+ });
141
+ }
@@ -471,6 +471,12 @@ function collectFiles(dir) {
471
471
  function isBoilerplateSite(relPath, kind, name) {
472
472
  if (kind === 'agent') {
473
473
  if (relPath === `agents/${name}.md`) return true;
474
+ // Kept for repoRoots where an authoring-spec file still lives at this path
475
+ // (pre-4.0.0 checkouts, template/consumer repos) — see
476
+ // tests/lib/sunset-walker.test.mjs "boilerplate exclusion (agents)". The
477
+ // session-orchestrator repo itself moved the spec to docs/agent-authoring.md,
478
+ // which is outside SCAN_DIRS and can never appear as a relPath here, so
479
+ // there is no live path to repoint this exclusion to.
474
480
  if (relPath === 'agents/AGENTS.md') return true;
475
481
  if (relPath === `agents/schemas/${name}.schema.json`) return true;
476
482
  // Routing-table / validator boilerplate.
@@ -35,7 +35,7 @@
35
35
  * none and holds no mutable runtime state.
36
36
  */
37
37
 
38
- import { SO_PLATFORM, SO_PLUGIN_ROOT } from '../platform.mjs';
38
+ import { getPlatform, getPluginRoot } from '../platform.mjs';
39
39
  import { enumerateSurface } from '../sunset/walker.mjs';
40
40
  import { readPluginVersionFromPackageJson } from '../bootstrap-lock-freshness.mjs';
41
41
 
@@ -70,6 +70,52 @@ export const USAGE_PING_FIELDS = Object.freeze([
70
70
  'commands',
71
71
  ]);
72
72
 
73
+ /**
74
+ * ADDITIVE optional fields (schema v1, GitLab #1234). Deliberately a SECOND
75
+ * list rather than an extension of USAGE_PING_FIELDS above, for two reasons:
76
+ *
77
+ * 1. USAGE_PING_FIELDS is the REQUIRED v1 contract — `tests/telemetry/parity.mjs`
78
+ * asserts, field by field, that the server independently REQUIRES every
79
+ * member. An optional field is by definition not required, so putting it in
80
+ * that list would force the parity guard to be weakened for all 15 fields to
81
+ * accommodate one.
82
+ * 2. The frozen-list tripwire (`projectUsagePing` drops anything unlisted)
83
+ * stays intact: the projection whitelist is the UNION of both lists, so a
84
+ * leaky field still has to be added to a reviewed list before it can reach
85
+ * the wire.
86
+ *
87
+ * Server tolerance: `server/ingest/validate.mjs` accepts unknown top-level
88
+ * fields and preserves them verbatim in `raw_json` (see its module docblock), so
89
+ * every member here round-trips through a server that predates it.
90
+ */
91
+ export const USAGE_PING_OPTIONAL_FIELDS = Object.freeze([
92
+ 'fleet_self_declared',
93
+ 'session_record',
94
+ 'session_profile',
95
+ ]);
96
+
97
+ /**
98
+ * The full projection whitelist: required v1 fields + additive optional fields.
99
+ * `projectUsagePing` is driven from THIS, so neither list alone can leak a field.
100
+ */
101
+ const USAGE_PING_PROJECTED_FIELDS = Object.freeze([...USAGE_PING_FIELDS, ...USAGE_PING_OPTIONAL_FIELDS]);
102
+
103
+ /**
104
+ * `session_record` provenance tokens — WHICH source the session facts
105
+ * (`session_type`, `duration_bucket`) in this ping came from.
106
+ *
107
+ * - `ledger` — `.orchestrator/metrics/sessions.jsonl` had a matching record.
108
+ * - `derived` — no ledger record; the facts were reconstructed from
109
+ * `events.jsonl` (`orchestrator.session.started` + last event).
110
+ * - `absent` — neither source produced a session type. `session_type` is then
111
+ * `'unknown'` and `duration_bucket` is NOT a measurement.
112
+ *
113
+ * Measured 2026-09-06 (d8 audit): this repo has NO sessions.jsonl, so 100 % of
114
+ * its pings took the `absent` path and were indistinguishable on the wire from
115
+ * a genuinely-measured `other` / `<15m` session — 32 such pings on the server.
116
+ */
117
+ export const SESSION_RECORD_SOURCES = Object.freeze(['ledger', 'derived', 'absent']);
118
+
73
119
  /** Exact duration-bucket tokens (ASCII, stable wire values). */
74
120
  export const DURATION_BUCKETS = Object.freeze(['<15m', '15-60m', '1-3h', '>3h']);
75
121
 
@@ -83,6 +129,43 @@ const VALID_PLATFORMS = Object.freeze(['claude', 'codex', 'cursor', 'pi']);
83
129
  const VALID_SESSION_TYPES = Object.freeze(['housekeeping', 'feature', 'deep']);
84
130
  const PLATFORM_OTHER = 'other';
85
131
  const SESSION_TYPE_OTHER = 'other';
132
+ /**
133
+ * The type was never measured — distinct from `'other'`, which means "measured,
134
+ * but not one of the three known modes". Conflating the two is the defect this
135
+ * token fixes: before it existed, a ping built with NO session record silently
136
+ * reported `'other'`, so "we could not tell" and "we looked and it was unusual"
137
+ * were the same wire value.
138
+ *
139
+ * NOT an enum widening server-side: `session_type` is validated as a bounded
140
+ * STRING (`server/ingest/validate.mjs` `requireString` + `MAX_SESSION_TYPE`),
141
+ * never against a closed set, so `'unknown'` is accepted by today's server.
142
+ */
143
+ const SESSION_TYPE_UNKNOWN = 'unknown';
144
+
145
+ /**
146
+ * CLOSED whitelist of PUBLIC session-profile names that may reach the wire.
147
+ *
148
+ * WHY A WHITELIST AND NOT A REGEX: `session_profile` is copied from the STATE.md
149
+ * frontmatter key `session-profile`, which is written per repo by whoever runs
150
+ * `/session <alias>` — i.e. it is the only field on the usage-ping whose VALUE is
151
+ * repo-authored free text. Two independent Wave-1 reviewers reproduced the leak
152
+ * end-to-end (2026-09-06): `session-profile: client-acme-private-repo` travelled
153
+ * verbatim through `buildUsagePing` → `projectUsagePing` → the ingest server's
154
+ * `raw_json` column. A shape regex does NOT close it — that example string is
155
+ * already lowercase-and-hyphens and passes any such pattern. Only an
156
+ * enumeration of names that are public BY CONSTRUCTION does.
157
+ *
158
+ * Today exactly one profile exists: `ultradeep`, the 7-wave variant of a `deep`
159
+ * session (writer: `commands/session.md` § "Argument alias: ultradeep" → STATE.md
160
+ * frontmatter `session-profile`; wave shape: `skills/session-plan/SKILL.md`).
161
+ * Adding a profile there means adding it HERE and in the server's mirror
162
+ * (`server/ingest/validate.mjs` `SESSION_PROFILES`) — a deliberate two-line,
163
+ * reviewed edit, the same contract `ACCEPTED_VERSIONS` already uses server-side.
164
+ *
165
+ * NOT a member of VALID_SESSION_TYPES: the profile is a SECOND axis. An
166
+ * ultradeep session is `session_type: "deep"` PLUS `session_profile: "ultradeep"`.
167
+ */
168
+ export const VALID_SESSION_PROFILES = Object.freeze(['ultradeep']);
86
169
 
87
170
  /**
88
171
  * Closed sets for os/arch client-side normalization. A value outside the set —
@@ -121,7 +204,8 @@ function isNonEmptyString(v) {
121
204
  // ---------------------------------------------------------------------------
122
205
 
123
206
  /**
124
- * Project an arbitrary object onto the usage-ping whitelist (USAGE_PING_FIELDS).
207
+ * Project an arbitrary object onto the usage-ping whitelist (USAGE_PING_FIELDS
208
+ * PLUS USAGE_PING_OPTIONAL_FIELDS).
125
209
  * Fully data-driven: any key not on the whitelist — paths, repo names, prompts,
126
210
  * args, hostnames, rogue extras — is dropped. Array fields (skills, commands) are
127
211
  * copied as NEW arrays so no caller reference leaks into the projection.
@@ -132,7 +216,7 @@ function isNonEmptyString(v) {
132
216
  export function projectUsagePing(input) {
133
217
  if (!isPlainObject(input)) return {};
134
218
  const out = {};
135
- for (const key of USAGE_PING_FIELDS) {
219
+ for (const key of USAGE_PING_PROJECTED_FIELDS) {
136
220
  if (key in input) {
137
221
  const v = input[key];
138
222
  out[key] = Array.isArray(v) ? [...v] : v;
@@ -163,7 +247,7 @@ export function projectUsagePing(input) {
163
247
  export function loadRoster({ pluginRoot } = {}) {
164
248
  const root = (typeof pluginRoot === 'string' && pluginRoot.trim() !== '')
165
249
  ? pluginRoot
166
- : SO_PLUGIN_ROOT;
250
+ : getPluginRoot();
167
251
 
168
252
  if (!isNonEmptyString(root)) {
169
253
  process.stderr.write(
@@ -309,8 +393,9 @@ export function deriveDurationBucket(startedAtISO, completedAtISO) {
309
393
  * whose null return (missing/unparseable package.json or non-string version) maps to 'unknown'.
310
394
  */
311
395
  function resolvePluginVersion() {
312
- if (!isNonEmptyString(SO_PLUGIN_ROOT)) return 'unknown';
313
- return readPluginVersionFromPackageJson(SO_PLUGIN_ROOT) ?? 'unknown';
396
+ const root = getPluginRoot();
397
+ if (!isNonEmptyString(root)) return 'unknown';
398
+ return readPluginVersionFromPackageJson(root) ?? 'unknown';
314
399
  }
315
400
 
316
401
  /** Normalize the detected platform to the closed enum (+ 'other' fallback). */
@@ -320,7 +405,36 @@ function normalizePlatform(platform) {
320
405
 
321
406
  /** Normalize the session type to the closed enum (+ 'other' fallback). */
322
407
  function normalizeSessionType(sessionType) {
323
- return VALID_SESSION_TYPES.includes(sessionType) ? sessionType : SESSION_TYPE_OTHER;
408
+ if (VALID_SESSION_TYPES.includes(sessionType)) return sessionType;
409
+ // ABSENT ≠ UNRECOGNISED. A missing/blank value means nothing was measured
410
+ // (`unknown`); a present-but-unlisted value means something WAS measured and
411
+ // is not one of the three modes (`other`). The old single-branch version
412
+ // returned `other` for both, which is how 394 pings from a host with no
413
+ // sessions.jsonl arrived looking like measured `other` sessions (d8, 2026-09-06).
414
+ return isNonEmptyString(sessionType) ? SESSION_TYPE_OTHER : SESSION_TYPE_UNKNOWN;
415
+ }
416
+
417
+ /**
418
+ * Normalize a session PROFILE against the closed VALID_SESSION_PROFILES
419
+ * whitelist. Anything else — absent, blank, or an unlisted repo-authored value —
420
+ * returns `null`, and the caller OMITS the field.
421
+ *
422
+ * DELIBERATELY NOT MIRRORING normalizeSessionType's two-token report. That helper
423
+ * can distinguish ABSENT (`'unknown'`) from UNRECOGNISED (`'other'`) because both
424
+ * of its answers are public constants. Here the unrecognised VALUE is precisely
425
+ * what must not travel, and there is no third token that carries the distinction
426
+ * without carrying the string: an `'other'` profile would say "this host ran a
427
+ * profile we do not ship" — a fact of no product use, bought with a new wire
428
+ * value. So both cases collapse to omission, and the wire cannot tell them apart
429
+ * BY DESIGN. The local `session-profile` key is untouched either way.
430
+ *
431
+ * @param {unknown} profile
432
+ * @returns {string|null} a whitelisted profile name, or null (⇒ field omitted).
433
+ */
434
+ export function normalizeSessionProfile(profile) {
435
+ if (!isNonEmptyString(profile)) return null;
436
+ const trimmed = profile.trim();
437
+ return VALID_SESSION_PROFILES.includes(trimmed) ? trimmed : null;
324
438
  }
325
439
 
326
440
  /**
@@ -403,6 +517,9 @@ export function buildUsagePing({
403
517
  env = process.env,
404
518
  now = new Date().toISOString(),
405
519
  roster,
520
+ consentState,
521
+ sessionRecordSource,
522
+ sessionProfile,
406
523
  } = {}) {
407
524
  const session = isPlainObject(sessionRecord) ? sessionRecord : {};
408
525
  const invocations = Array.isArray(skillInvocations) ? skillInvocations : [];
@@ -431,19 +548,74 @@ export function buildUsagePing({
431
548
  (kind === 'command' ? commandNames : skillNames).push(name);
432
549
  }
433
550
 
551
+ // ── fleet: a statement about the OPERATOR, derived from the RESOLVED consent
552
+ // state, not from a raw owner.yaml read ────────────────────────────────────
553
+ // The old expression was `ownerConfig?.telemetry?.enabled === true` — a
554
+ // statement about a FILE. Measured 2026-09-06 (d8): the operator's second Mac
555
+ // has consent granted but no `telemetry:` block in owner.yaml, so 394 of 490
556
+ // server records (80,4 %) counted the operator as an external user and every
557
+ // week's `fleet_vs_external` was wrong.
558
+ //
559
+ // `resolveConsent()` already answers this question correctly: it returns
560
+ // `enabled-fleet` for the owner.yaml opt-in AND `enabled-env` for
561
+ // `SO_TELEMETRY=1`, both of which are operator-side postures. Callers that
562
+ // pass `consentState` get that answer; the ownerConfig fallback below keeps
563
+ // the two-argument callers (and the CLI preview) behaviourally identical.
564
+ //
565
+ // CEILING, named because the client cannot close it: this is still
566
+ // SELF-DECLARED — a sandbox or a host whose owner.yaml is unreachable declares
567
+ // `false` however honest it is. The authoritative classification is
568
+ // server-side (`SO_INGEST_FLEET_ANON_IDS`, server/ingest/config.mjs).
569
+ // `session_profile` (STATE.md frontmatter `session-profile`) is bounded by the
570
+ // CLOSED VALID_SESSION_PROFILES whitelist — it was the one free-text value on
571
+ // the wire until 2026-09-06, and a repo-authored one at that (see the
572
+ // whitelist's own docblock for the reproduced leak). It is still deliberately
573
+ // NOT routed through normalizeSessionType: that helper degrades anything
574
+ // outside ['housekeeping','feature','deep'] to 'other', which is exactly the
575
+ // silent loss the profile exists to prevent. The contract is
576
+ // `session_type: "deep"` PLUS `session_profile: "ultradeep"` — never
577
+ // `session_type: "ultradeep"`, and never a profile flattened to 'other'.
578
+ //
579
+ // ABSENT IS NOT EMPTY: with no profile — and with an UNLISTED one — the KEY IS
580
+ // OMITTED, matching every other optional ping field. `projectUsagePing` copies
581
+ // only keys that are `in` the input, so an omitted key never reaches the wire
582
+ // as `null`, and an unlisted profile never reaches it at all.
583
+ //
584
+ // SOURCE PRECEDENCE is resolved BEFORE normalization (explicit argument beats
585
+ // the session record), so an unlisted explicit profile is dropped rather than
586
+ // silently replaced by the record's value.
587
+ const rawProfile =
588
+ isNonEmptyString(sessionProfile) ? sessionProfile
589
+ : isNonEmptyString(session.session_profile) ? session.session_profile
590
+ : null;
591
+ const profile = normalizeSessionProfile(rawProfile);
592
+
593
+ const fleetSelfDeclared =
594
+ consentState === undefined || consentState === null
595
+ ? ownerConfig?.telemetry?.enabled === true
596
+ : consentState === 'enabled-fleet' || consentState === 'enabled-env';
597
+
434
598
  return {
435
599
  record_kind: 'usage-ping',
436
600
  schema_version: USAGE_PING_SCHEMA_VERSION,
437
601
  sent_at: now,
438
602
  plugin_version: resolvePluginVersion(),
439
- platform: normalizePlatform(SO_PLATFORM),
603
+ platform: normalizePlatform(getPlatform()),
440
604
  os: normalizeOs(process.platform),
441
605
  arch: normalizeArch(process.arch),
442
606
  node_major: parseInt(process.versions.node, 10),
443
607
  ci: deriveCi(env),
444
- fleet: ownerConfig?.telemetry?.enabled === true,
608
+ fleet: fleetSelfDeclared,
609
+ // DEPRECATED alias of `fleet_self_declared`, kept for one schema generation
610
+ // (removal: 2027-03-06 — see docs/telemetry.md § Schema evolution) so the
611
+ // server's existing `fleet` column stays comparable across the rename.
612
+ fleet_self_declared: fleetSelfDeclared,
445
613
  session_type: normalizeSessionType(session.session_type),
614
+ ...(profile !== null ? { session_profile: profile } : {}),
446
615
  duration_bucket: deriveDurationBucket(session.started_at, session.completed_at),
616
+ session_record: SESSION_RECORD_SOURCES.includes(sessionRecordSource)
617
+ ? sessionRecordSource
618
+ : 'absent',
447
619
  skills: filterRosterNames(skillNames, rosterSkills),
448
620
  commands: filterRosterNames(commandNames, rosterCommands),
449
621
  };