session-orchestrator 3.21.0 → 3.23.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 (345) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +11 -2
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/040-discovery.mdc +6 -6
  38. package/.cursor/rules/050-plan.mdc +10 -10
  39. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  40. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  41. package/.cursor/skills/architecture/SKILL.md +13 -0
  42. package/.cursor/skills/autopilot/SKILL.md +12 -0
  43. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  44. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  45. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  46. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  47. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  48. package/.cursor/skills/daily/SKILL.md +12 -0
  49. package/.cursor/skills/debug/SKILL.md +13 -0
  50. package/.cursor/skills/discovery/SKILL.md +13 -0
  51. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  52. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  53. package/.cursor/skills/domain-model/SKILL.md +13 -0
  54. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  55. package/.cursor/skills/eli5/SKILL.md +13 -0
  56. package/.cursor/skills/eval/SKILL.md +12 -0
  57. package/.cursor/skills/evolve/SKILL.md +13 -0
  58. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  60. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  61. package/.cursor/skills/grill/SKILL.md +13 -0
  62. package/.cursor/skills/hook-development/SKILL.md +13 -0
  63. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  64. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  65. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  66. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  67. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  68. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  69. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  70. package/.cursor/skills/plan/SKILL.md +13 -0
  71. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  72. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  73. package/.cursor/skills/reconcile/SKILL.md +12 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +269 -0
  91. package/README.md +18 -15
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -1
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/memory-proposal-collector.md +6 -4
  98. package/agents/session-reviewer.md +7 -1
  99. package/agents/test-writer.md +2 -1
  100. package/agents/ui-developer.md +2 -1
  101. package/commands/bootstrap.md +2 -2
  102. package/commands/close.md +3 -1
  103. package/commands/eli5.md +33 -0
  104. package/commands/go.md +1 -1
  105. package/commands/journey-audit.md +43 -0
  106. package/commands/release.md +5 -3
  107. package/commands/test.md +2 -2
  108. package/docs/USER-GUIDE.md +2 -2
  109. package/docs/ci-setup.md +14 -0
  110. package/docs/codex-setup.md +64 -0
  111. package/docs/components.md +7 -6
  112. package/docs/cursor-setup.md +26 -47
  113. package/docs/events-schema.md +76 -4
  114. package/docs/github-mirror-protection.md +197 -0
  115. package/docs/pi-setup.md +2 -0
  116. package/docs/rule-authoring.md +3 -1
  117. package/docs/scope-collision-guard.md +52 -5
  118. package/docs/session-config-reference.md +57 -12
  119. package/docs/session-config-template.md +4 -3
  120. package/docs/telemetry.md +22 -0
  121. package/hooks/_lib/lock-bootstrap.mjs +26 -16
  122. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  123. package/hooks/enforce-scope.mjs +64 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +12 -2
  128. package/hooks/on-session-end.mjs +237 -104
  129. package/hooks/on-session-start.mjs +404 -110
  130. package/hooks/on-stop.mjs +48 -9
  131. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  132. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  133. package/hooks/pre-auq-clarity.mjs +787 -0
  134. package/hooks/pre-bash-issue-budget.mjs +133 -21
  135. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  136. package/hooks/pre-bash-staging-fence.mjs +4 -0
  137. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  138. package/hooks/skill-invocation-telemetry.mjs +21 -10
  139. package/monitors/monitors.json +6 -0
  140. package/package.json +3 -1
  141. package/pi/prompts/eli5.md +12 -0
  142. package/pi/prompts/journey-audit.md +12 -0
  143. package/rules/_index.md +9 -1
  144. package/rules/always-on/ask-via-tool.md +62 -0
  145. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  146. package/rules/always-on/build-value.md +47 -0
  147. package/rules/always-on/cross-session-messaging.md +59 -0
  148. package/rules/always-on/loop-and-monitor.md +221 -0
  149. package/rules/always-on/parallel-sessions.md +142 -12
  150. package/rules/always-on/receiving-review.md +108 -0
  151. package/rules/always-on/test-value.md +40 -0
  152. package/rules/always-on/verification-before-completion.md +77 -0
  153. package/scripts/archive-closed-prds.mjs +258 -18
  154. package/scripts/auq-audit.mjs +825 -0
  155. package/scripts/autopilot.mjs +12 -8
  156. package/scripts/backfill-evidence-digest.mjs +376 -0
  157. package/scripts/cursor-install.mjs +89 -48
  158. package/scripts/export-hw-learnings.mjs +143 -2
  159. package/scripts/express-path.mjs +299 -0
  160. package/scripts/generate-cursor-adapter.mjs +253 -0
  161. package/scripts/github-protection-audit.mjs +358 -0
  162. package/scripts/lib/auq/clarity.mjs +1314 -0
  163. package/scripts/lib/auq/parse.mjs +1006 -0
  164. package/scripts/lib/auq/schema.mjs +1457 -0
  165. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  166. package/scripts/lib/build-live-signals.mjs +24 -5
  167. package/scripts/lib/ci-status-banner.mjs +221 -68
  168. package/scripts/lib/command-blocker.mjs +70 -0
  169. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  170. package/scripts/lib/config/reconcile.mjs +79 -4
  171. package/scripts/lib/config/section-extractor.mjs +235 -36
  172. package/scripts/lib/config/vault-integration.mjs +12 -1
  173. package/scripts/lib/config-schema.mjs +9 -1
  174. package/scripts/lib/config.mjs +57 -6
  175. package/scripts/lib/convergence-monitor.mjs +13 -2
  176. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  177. package/scripts/lib/dispatcher/cli.mjs +2 -2
  178. package/scripts/lib/dispatcher/rank.mjs +4 -7
  179. package/scripts/lib/express-path.mjs +327 -0
  180. package/scripts/lib/file-lock.mjs +22 -4
  181. package/scripts/lib/gates/gate-full.mjs +84 -11
  182. package/scripts/lib/gates/gate-helpers.mjs +93 -21
  183. package/scripts/lib/git-config-drift.mjs +134 -5
  184. package/scripts/lib/host-identity.mjs +247 -2
  185. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  186. package/scripts/lib/io.mjs +239 -0
  187. package/scripts/lib/issue-budget.mjs +284 -31
  188. package/scripts/lib/learnings/io.mjs +55 -10
  189. package/scripts/lib/learnings/schema.mjs +95 -28
  190. package/scripts/lib/lock-reaper.mjs +7 -1
  191. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  192. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  193. package/scripts/lib/memory-banner.mjs +5 -2
  194. package/scripts/lib/memory-paths.mjs +15 -6
  195. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  196. package/scripts/lib/owner-interview.mjs +78 -32
  197. package/scripts/lib/peer-discovery.mjs +73 -22
  198. package/scripts/lib/platform.mjs +72 -9
  199. package/scripts/lib/plugin-root.mjs +143 -19
  200. package/scripts/lib/project-hygiene.mjs +104 -4
  201. package/scripts/lib/quality-gate.mjs +271 -13
  202. package/scripts/lib/reconcile/emitter.mjs +87 -19
  203. package/scripts/lib/reconcile/engine.mjs +281 -13
  204. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  205. package/scripts/lib/reconcile/renderer.mjs +165 -7
  206. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  207. package/scripts/lib/reconcile/writer.mjs +415 -84
  208. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  209. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  210. package/scripts/lib/resource-probe.mjs +18 -2
  211. package/scripts/lib/rule-loader.mjs +37 -2
  212. package/scripts/lib/rules-sync.mjs +51 -8
  213. package/scripts/lib/scope-gate.mjs +90 -0
  214. package/scripts/lib/session-close-backfill.mjs +369 -28
  215. package/scripts/lib/session-discovery.mjs +13 -3
  216. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  217. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  218. package/scripts/lib/session-id.mjs +30 -14
  219. package/scripts/lib/session-identity/own-session.mjs +159 -0
  220. package/scripts/lib/session-lock.mjs +85 -30
  221. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  222. package/scripts/lib/session-schema/validator.mjs +40 -0
  223. package/scripts/lib/session-start-probes.mjs +608 -0
  224. package/scripts/lib/session-transition.mjs +277 -0
  225. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  226. package/scripts/lib/spiral-carryover.mjs +111 -9
  227. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  228. package/scripts/lib/state-md/mission-status.mjs +467 -72
  229. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  230. package/scripts/lib/state-md.mjs +12 -2
  231. package/scripts/lib/telemetry/sync.mjs +46 -8
  232. package/scripts/lib/validate/check-agents.mjs +66 -0
  233. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  234. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  235. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  236. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  237. package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
  238. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  239. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  240. package/scripts/lib/validate/check-rules.mjs +153 -9
  241. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  242. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  243. package/scripts/lib/validate/repo-files.mjs +275 -0
  244. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  245. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  246. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  247. package/scripts/lib/vault-mirror/process.mjs +99 -43
  248. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  249. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  250. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  251. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  252. package/scripts/lib/vcs-repo-spec.mjs +182 -13
  253. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  254. package/scripts/lib/wave-resource-gate.mjs +67 -73
  255. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  256. package/scripts/materialize-wave-scope.mjs +478 -0
  257. package/scripts/mcp-server.sh +11 -2
  258. package/scripts/parse-config.mjs +65 -0
  259. package/scripts/release.mjs +443 -122
  260. package/scripts/run-quality-gate.mjs +14 -0
  261. package/scripts/token-audit.sh +9 -2
  262. package/scripts/validate-plugin.mjs +6 -0
  263. package/scripts/validate-wave-scope.mjs +73 -1
  264. package/scripts/vault-backfill.mjs +32 -5
  265. package/scripts/vault-mirror.mjs +203 -34
  266. package/skills/_shared/monitor-patterns.md +31 -5
  267. package/skills/_shared/parallel-aware-auq.md +31 -25
  268. package/skills/_shared/parallel-aware-preamble.md +35 -4
  269. package/skills/_shared/platform-tools.md +11 -5
  270. package/skills/_shared/state-ownership.md +61 -8
  271. package/skills/autopilot/SKILL.md +5 -1
  272. package/skills/bootstrap/SKILL.md +5 -4
  273. package/skills/bootstrap/_shared-template.md +18 -10
  274. package/skills/bootstrap/deep-template.md +10 -6
  275. package/skills/bootstrap/fast-template.md +15 -8
  276. package/skills/bootstrap/standard-template.md +10 -6
  277. package/skills/brainstorm/SKILL.md +18 -18
  278. package/skills/brainstorm/soul.md +12 -0
  279. package/skills/claude-md-drift-check/checker.mjs +39 -11
  280. package/skills/discovery/SKILL.md +28 -24
  281. package/skills/dispatcher/SKILL.md +1 -1
  282. package/skills/eli5/SKILL.md +43 -0
  283. package/skills/evolve/SKILL.md +8 -9
  284. package/skills/gitlab-ops/SKILL.md +30 -26
  285. package/skills/grill/SKILL.md +6 -6
  286. package/skills/grill/soul.md +16 -0
  287. package/skills/journey-audit/SKILL.md +269 -0
  288. package/skills/memory-cleanup/SKILL.md +2 -2
  289. package/skills/npm-publish/SKILL.md +4 -4
  290. package/skills/peekaboo-driver/SKILL.md +18 -6
  291. package/skills/persona-panel/SKILL.md +1 -1
  292. package/skills/plan/SKILL.md +18 -16
  293. package/skills/plan/mode-feature.md +1 -1
  294. package/skills/plan/mode-new.md +35 -23
  295. package/skills/plan/soul.md +12 -0
  296. package/skills/reconcile/SKILL.md +44 -4
  297. package/skills/session-end/SKILL.md +70 -24
  298. package/skills/session-end/metrics-collection.md +7 -4
  299. package/skills/session-end/phase-3-6-tail.md +48 -5
  300. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  301. package/skills/session-plan/SKILL.md +6 -1
  302. package/skills/session-plan/wave-template.md +1 -0
  303. package/skills/session-start/SKILL.md +96 -48
  304. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  305. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  306. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  307. package/skills/session-start/phase-8-5-express-path.md +77 -12
  308. package/skills/session-start/soul.md +110 -0
  309. package/skills/test-runner/SKILL.md +2 -2
  310. package/skills/using-orchestrator/SKILL.md +1 -1
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +4 -2
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +129 -24
  315. package/skills/write-executable-plan/SKILL.md +6 -6
  316. package/templates/_shared/journey-manifest.md +110 -0
  317. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  318. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  319. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  320. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  321. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  322. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  323. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  324. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  325. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  326. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  327. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  328. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  329. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  330. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  331. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  332. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  333. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  334. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  335. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  336. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  337. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  338. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  339. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  340. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  341. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  342. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  343. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  344. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
  345. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,210 @@
1
+ /**
2
+ * telemetry.mjs — vault-mirror telemetry emitters (#1116, #1073, #1147).
3
+ *
4
+ * Two events, deliberately BOTH:
5
+ *
6
+ * - `orchestrator.vault.mirror_completed` — ONE record per JSONL entry
7
+ * processed (created / updated / every `skipped-*` EXCEPT `skipped-noop`),
8
+ * carrying the same `action` the CLI wrote to stdout for that entry.
9
+ * `skipped-noop` is excluded because it is the steady state of a populated
10
+ * vault, not an event: emitting it made a single run write hundreds of
11
+ * records that between them said nothing happened (#1151). Its count is
12
+ * carried by the run event's `skipped` total and `action_breakdown`, so the
13
+ * class stays measured — only its per-line locators are gone.
14
+ * - `orchestrator.vault.mirror_run_completed` — ONE record per CLI run,
15
+ * carrying the DENOMINATOR (`total` plus the per-class counts).
16
+ *
17
+ * The per-entry event alone is not enough, and that is the whole point of the
18
+ * pairing: a healthy run over an empty source emits zero per-entry records, and
19
+ * a mirror whose emitter is broken also emits zero — the two are
20
+ * indistinguishable from the ledger (`.claude/rules/host-resources.md` § HR-105,
21
+ * "a rule you cannot falsify is not a rule"). The run event is emitted
22
+ * unconditionally — including on every abort path, where it carries an
23
+ * `aborted` discriminator (`missing-vault-dir` | `vault-not-canonical` |
24
+ * `missing-source` | `malformed-json` | `filesystem-error` |
25
+ * `unexpected-error`). So `total: 0` is a MEASURED zero, a partial count is a
26
+ * LABELLED partial rather than a silent one, and the record's absence — and
27
+ * nothing else — is the broken-emitter signal.
28
+ *
29
+ * Both emitters are best-effort: a telemetry failure must never fail a mirror
30
+ * run, so every emit is wrapped and its rejection swallowed (same posture as the
31
+ * `orchestrator.secret_masker.applied` emit in `scripts/vault-mirror.mjs`).
32
+ *
33
+ * Ledger destination: `emitEvent` is called 2-arg, so every event from one run
34
+ * resolves the SAME destination (`SO_PROJECT_DIR`, i.e. `CLAUDE_PROJECT_DIR` or
35
+ * the CWD walk-up) as the masker emit. This CLI has no repo-root flag and
36
+ * deriving one from `--source` would split a single run's telemetry across two
37
+ * ledgers.
38
+ */
39
+
40
+ import { emitEvent, sessionAttribution } from '../events.mjs';
41
+ import { SO_PROJECT_DIR } from '../platform.mjs';
42
+
43
+ /** Canonical event name for a single vault-mirror JSONL entry. */
44
+ export const MIRROR_EVENT = 'orchestrator.vault.mirror_completed';
45
+
46
+ /** Canonical event name for the run-level roll-up (the denominator). */
47
+ export const MIRROR_RUN_EVENT = 'orchestrator.vault.mirror_run_completed';
48
+
49
+ /**
50
+ * Upper bound (characters) on the `reason` string written into the events
51
+ * ledger. Renderer validation messages are short and structured
52
+ * (`missing required field 'x' (session_id=…)`), quality-gate reasons are
53
+ * shorter still (`confidence:0.4 < min:0.5`), and native mapper-crash messages
54
+ * are bounded in practice — the clamp only stops a pathological message from
55
+ * bloating every ledger line. Revisit if a legitimate reason is ever observed
56
+ * truncated.
57
+ */
58
+ const MIRROR_REASON_MAX = 300;
59
+
60
+ /**
61
+ * "Absent is not zero" admission test (docs/events-schema.md): a field that was
62
+ * not measured is OMITTED, never written as `0`/`null`. `null` counts as
63
+ * not-measured here — `record_id` is explicitly `null` for a record carrying
64
+ * neither `id` nor `session_id`, and a null `record_id` in the ledger would read
65
+ * as a measured empty id rather than as "this record had none".
66
+ * @param {unknown} v
67
+ * @returns {boolean}
68
+ */
69
+ const present = (v) => v !== undefined && v !== null;
70
+
71
+ /**
72
+ * Session attribution for this CLI's events.
73
+ *
74
+ * The explicit `SO_PROJECT_DIR` argument is LOAD-BEARING, not decoration:
75
+ * `readLock()` (via `lockPathFor`) defaults to `process.cwd()`, NOT to
76
+ * `SO_PROJECT_DIR`. Calling `sessionAttribution()` bare would therefore read the
77
+ * lock of whatever directory the process happens to run in while the ledger line
78
+ * lands under `CLAUDE_PROJECT_DIR` — attributing a record to a session that
79
+ * never wrote it. Same root for both halves or neither.
80
+ *
81
+ * @returns {{session_id?: string, semantic_session_id?: string}}
82
+ */
83
+ function mirrorSessionAttribution() {
84
+ return sessionAttribution(SO_PROJECT_DIR);
85
+ }
86
+
87
+ /**
88
+ * Emit one `orchestrator.vault.mirror_completed` telemetry record for a single
89
+ * mirrored entry. Never throws — a rejected emit is swallowed silently.
90
+ *
91
+ * @param {object} opts
92
+ * @param {string} opts.action — the `action` value the CLI wrote to stdout for
93
+ * this same entry (`created` | `updated` | `skipped-*`).
94
+ * @param {string} opts.kind — `learning` | `session` (the `--kind` flag).
95
+ * @param {number} opts.line — 1-based JSONL line number of the entry. Always
96
+ * measured; the only locator available when the record has no id.
97
+ * @param {string|null} [opts.recordId] — the record's `id` / `session_id`.
98
+ * Omitted from the payload when absent.
99
+ * @param {string|null} [opts.path] — VAULT-RELATIVE target path, when one was
100
+ * resolved. Omitted on `skipped-invalid` and on the pre-path quality skips,
101
+ * which are reached before any target path exists. Relative, never absolute:
102
+ * this payload also travels over the optional Clank webhook with no
103
+ * redaction, and an absolute path leaks the operator's home directory.
104
+ * @param {string} [opts.skipClass] — failure-class discriminator on the invalid
105
+ * branches: `validation` | `mapper-crash`.
106
+ * @param {string} [opts.reason] — the renderer's error message, or the
107
+ * quality-gate `meta.reason` string. Clamped to {@link MIRROR_REASON_MAX}.
108
+ * @param {boolean} [opts.dryRun] — whether this run wrote anything at all.
109
+ * @returns {Promise<void>}
110
+ */
111
+ export async function emitMirrorEvent({
112
+ action,
113
+ kind,
114
+ line,
115
+ recordId,
116
+ path,
117
+ skipClass,
118
+ reason,
119
+ dryRun,
120
+ }) {
121
+ try {
122
+ await emitEvent(MIRROR_EVENT, {
123
+ action,
124
+ kind,
125
+ line,
126
+ ...(present(recordId) ? { record_id: recordId } : {}),
127
+ ...(present(path) ? { path } : {}),
128
+ ...(present(skipClass) ? { skip_class: skipClass } : {}),
129
+ ...(present(reason) ? { reason: String(reason).slice(0, MIRROR_REASON_MAX) } : {}),
130
+ ...(typeof dryRun === 'boolean' ? { dry_run: dryRun } : {}),
131
+ ...mirrorSessionAttribution(),
132
+ });
133
+ } catch {
134
+ // Silent no-op — telemetry must never be the reason a mirror run fails.
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Emit the single `orchestrator.vault.mirror_run_completed` roll-up for a
140
+ * whole CLI run. Never throws.
141
+ *
142
+ * The five counters (`total`, `created`, `updated`, `skipped`, `failed`) are
143
+ * ALWAYS present, INCLUDING when they are `0` — they are the denominator, and
144
+ * this is the one place where a written zero is the payload rather than a
145
+ * violation of "absent is not zero": each one was measured over the entire run.
146
+ * `action_breakdown` is the opposite: it enumerates only the actions that
147
+ * actually occurred, and a missing key there means zero occurrences (the
148
+ * always-present `total` makes that reading unambiguous).
149
+ *
150
+ * On an ABORTED run the same five counters are still written, and `aborted`
151
+ * is what keeps them honest: it says the run stopped before its tail, so the
152
+ * lines after the abort were never attempted and
153
+ * `created + updated + skipped + failed === total` no longer holds. Without
154
+ * that label a partial denominator would be indistinguishable from a complete
155
+ * one — the same "absent is not zero" failure this event exists to prevent,
156
+ * one level up.
157
+ *
158
+ * @param {object} opts
159
+ * @param {string} opts.kind — `learning` | `session` (the `--kind` flag).
160
+ * @param {number} opts.total — non-blank JSONL entries the run attempted.
161
+ * @param {number} opts.created — entries whose action was `created`.
162
+ * @param {number} opts.updated — entries whose action was `updated`.
163
+ * @param {number} opts.skipped — entries skipped for a NON-failure reason
164
+ * (`skipped-noop`, `skipped-handwritten`, `skipped-quality-low`,
165
+ * `skipped-collision-resolved`, `skipped-abandoned`).
166
+ * @param {number} opts.failed — entries that produced `skipped-invalid`
167
+ * (validation error or mapper crash). Split out from `skipped` because these
168
+ * are the runs where a session silently ends up WITHOUT its vault note.
169
+ * @param {Record<string, number>} [opts.actionBreakdown] — per-action counts;
170
+ * only actions observed at least once appear.
171
+ * @param {boolean} opts.dryRun — whether this run wrote anything at all.
172
+ * @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'malformed-json'|'filesystem-error'|'unexpected-error'} [opts.aborted]
173
+ * Present ONLY when the run exited before its normal tail. OMITTED on a
174
+ * complete run — absent means "ran to the end", never "unknown". The first
175
+ * three values are PRE-LOOP aborts (#1151): the run never reached its first
176
+ * entry, so all five counters are `0` and that zero is the point — without a
177
+ * record, a mirror that never started is indistinguishable from one that was
178
+ * never invoked.
179
+ * @returns {Promise<void>}
180
+ */
181
+ export async function emitMirrorRunEvent({
182
+ kind,
183
+ total,
184
+ created,
185
+ updated,
186
+ skipped,
187
+ failed,
188
+ actionBreakdown,
189
+ dryRun,
190
+ aborted,
191
+ }) {
192
+ try {
193
+ await emitEvent(MIRROR_RUN_EVENT, {
194
+ kind,
195
+ total,
196
+ created,
197
+ updated,
198
+ skipped,
199
+ failed,
200
+ ...(actionBreakdown && Object.keys(actionBreakdown).length > 0
201
+ ? { action_breakdown: actionBreakdown }
202
+ : {}),
203
+ dry_run: dryRun,
204
+ ...(present(aborted) ? { aborted } : {}),
205
+ ...mirrorSessionAttribution(),
206
+ });
207
+ } catch {
208
+ // Silent no-op — telemetry must never be the reason a mirror run fails.
209
+ }
210
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * vault-staleness-banner.mjs — #319
2
+ * vault-staleness-banner.mjs — #319, record-age gate #1159
3
3
  * Reads the latest vault-staleness probe record and classifies a banner
4
4
  * severity (warn | alert) for surfacing stale projects in the Meta-Vault.
5
5
  *
@@ -9,11 +9,36 @@
9
9
  * {timestamp, probe, project_root, vault_dir, scanned_projects,
10
10
  * stale_count, errors, duration_ms,
11
11
  * findings: [{slug, severity, last_sync, delta_hours, flag}, ...]}
12
+ *
13
+ * **One vocabulary, not two (#1158/#1159 review, N3).** The record-age gate's
14
+ * first cut returned `severity: 'info'` for a stale probe record, and the
15
+ * `vault-staleness` registry entry in `scripts/lib/session-start-probes.mjs`
16
+ * then had to remap that to `'warn'` by hand so the banner would actually
17
+ * render (the module-level default there drops anything but warn/alert as
18
+ * `'ok'`, silent). Two files disagreeing about what severity the SAME record
19
+ * carries is the defect, not either value alone — this module now returns
20
+ * `severity: 'warn'` directly for the demoted case, and the registry needs no
21
+ * remap for it. `kind: 'probe-stale'` still carries the "this is a demotion,
22
+ * not a fresh finding" meaning for any consumer that cares.
12
23
  */
13
24
 
14
25
  import { existsSync, readFileSync } from 'node:fs';
15
26
  import path from 'node:path';
16
27
 
28
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
29
+
30
+ /**
31
+ * Max age (in days) of the last probe record before its stale-project
32
+ * findings are demoted to a `kind: 'probe-stale'` result (#1159; severity
33
+ * stays `'warn'` — see N3 in the header). The vault-sync bridge runs hourly,
34
+ * but the probe itself only runs per `/discovery` invocation or at
35
+ * session-end — so a week with no new record means the PROBE has stopped
36
+ * running, not that the projects it last saw are still stale today. A
37
+ * 47-day-old record was otherwise re-reported as a current finding on every
38
+ * session start.
39
+ */
40
+ export const MAX_RECORD_AGE_DAYS = 7;
41
+
17
42
  /**
18
43
  * Format a delta_hours number for the banner message.
19
44
  * Rounds to 1 decimal; integers render without a trailing ".0".
@@ -34,21 +59,39 @@ function formatDelta(hours) {
34
59
  * computes a banner severity classification. Never throws — graceful no-op
35
60
  * on any read error, schema mismatch, or empty/zero-stale state.
36
61
  *
37
- * Severity rules (issue #319):
62
+ * Severity rules (issue #319, record-age gate #1159, single-vocabulary N3):
38
63
  * - file absent / unreadable / malformed / stale_count === 0 → null (silent)
39
- * - stale_count > 0 AND maxDelta <= 48 → 'warn'
40
- * - stale_count > 0 AND maxDelta > 48 → 'alert' (cron likely broken)
64
+ * - stale_count > 0 AND record.timestamp older than MAX_RECORD_AGE_DAYS
65
+ * {severity: 'warn', kind: 'probe-stale', ...} (the probe stopped
66
+ * running; the recorded findings are not a current finding — `kind`
67
+ * carries the demotion meaning, `severity` is what every consumer reads)
68
+ * - stale_count > 0 AND missing/unparsable timestamp AND maxDelta <= 48
69
+ * → 'warn' (unchanged pre-#1159 behaviour)
70
+ * - stale_count > 0 AND missing/unparsable timestamp AND maxDelta > 48
71
+ * → 'alert' (cron likely broken; unchanged pre-#1159 behaviour)
72
+ * - stale_count > 0 AND record.timestamp within MAX_RECORD_AGE_DAYS AND
73
+ * maxDelta <= 48 → 'warn'
74
+ * - stale_count > 0 AND record.timestamp within MAX_RECORD_AGE_DAYS AND
75
+ * maxDelta > 48 → 'alert' (cron likely broken)
41
76
  *
42
- * @param {{repoRoot: string}} opts
77
+ * @param {{repoRoot: string, now?: number}} opts `now` is an optional clock
78
+ * seam (epoch ms, defaults to `Date.now()`) so callers can test the
79
+ * age gate deterministically without global fake timers.
43
80
  * @returns {null | {
44
81
  * severity: 'warn'|'alert',
45
82
  * message: string,
46
83
  * staleCount: number,
47
84
  * maxDeltaHours: number,
48
85
  * timestamp: string,
86
+ * } | {
87
+ * severity: 'warn',
88
+ * kind: 'probe-stale',
89
+ * message: string,
90
+ * ageDays: number,
91
+ * timestamp: string,
49
92
  * }}
50
93
  */
51
- export function checkVaultStaleness({ repoRoot } = {}) {
94
+ export function checkVaultStaleness({ repoRoot, now = Date.now() } = {}) {
52
95
  try {
53
96
  if (!repoRoot || typeof repoRoot !== 'string') return null;
54
97
 
@@ -91,6 +134,33 @@ export function checkVaultStaleness({ repoRoot } = {}) {
91
134
 
92
135
  if (staleCount === 0) return null;
93
136
 
137
+ // Record-age gate (#1159): a parseable timestamp older than
138
+ // MAX_RECORD_AGE_DAYS means the PROBE has not run since, not that the
139
+ // projects it last saw are still stale — demote the FINDING (via `kind`)
140
+ // rather than re-reporting a warn/alert every session, but keep
141
+ // `severity: 'warn'` (N3, #1158/#1159 review) so a caller reading only
142
+ // `severity` still renders it; a second `severity: 'info'` vocabulary
143
+ // that only the registry's remap understood was the two-files-disagree
144
+ // defect this shape replaces. A missing or unparsable timestamp falls
145
+ // through to today's behaviour unchanged.
146
+ const rawTimestamp = record.timestamp;
147
+ const parsedMs = typeof rawTimestamp === 'string' ? Date.parse(rawTimestamp) : NaN;
148
+ if (Number.isFinite(parsedMs)) {
149
+ const ageDays = Math.floor((now - parsedMs) / MS_PER_DAY);
150
+ if (ageDays > MAX_RECORD_AGE_DAYS) {
151
+ return {
152
+ severity: 'warn',
153
+ kind: 'probe-stale',
154
+ message:
155
+ `⚠ vault-staleness: last probe record is ${ageDays} days old ` +
156
+ `(${rawTimestamp}) — the probe has not run since; the recorded ` +
157
+ `${staleCount} stale projects are NOT a current finding.`,
158
+ ageDays,
159
+ timestamp: rawTimestamp,
160
+ };
161
+ }
162
+ }
163
+
94
164
  // Compute max delta_hours across findings; treat undefined/null/NaN as 0.
95
165
  let maxDelta = 0;
96
166
  for (const finding of record.findings) {
@@ -10,8 +10,16 @@
10
10
  * liveness via {@link isLockLive}) plus the host-wide session registry (the
11
11
  * registry entry is the ONLY source of `branch` — the lock lacks that field).
12
12
  *
13
+ * Telemetry: every {@link mirrorBoard} call — and therefore every
14
+ * {@link sweepBoard} call — emits exactly ONE {@link BOARD_EVENT} record,
15
+ * including the no-op paths (`skipped-vault-disabled`, `skipped-handwritten`,
16
+ * `skipped-noop`, `skipped-write-failed`). Those are the states that previously
17
+ * looked identical to a healthy write from outside the process. Emission is
18
+ * best-effort and can never fail a board write.
19
+ *
13
20
  * Exports:
14
21
  * GENERATOR_MARKER — frontmatter sentinel that identifies generator-owned files
22
+ * BOARD_EVENT — canonical event name for a board-write attempt
15
23
  * boardKey — repoRoot → stable path-derived row identity (issue #871)
16
24
  * resolveBoardPath — vaultDir → `<vaultDir>/01-projects/_active-sessions.md`
17
25
  * collectRows — per-repo status derivation (readLock + readRegistry)
@@ -42,6 +50,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
42
50
  import os from 'node:os';
43
51
  import path from 'node:path';
44
52
 
53
+ import { emitEvent, sessionAttribution } from '../events.mjs';
45
54
  import { isLockLive, readLock, DEFAULT_TTL_HOURS } from '../session-lock.mjs';
46
55
  import { readRegistry, repoPathHash, isRegistryEntryFresh } from '../session-registry.mjs';
47
56
  import { parseFrontmatter } from '../vault-mirror/utils.mjs';
@@ -681,6 +690,131 @@ export function writeBoard(opts) {
681
690
  return { action: 'written', path: outputPath };
682
691
  }
683
692
 
693
+ // ── Telemetry ────────────────────────────────────────────────────────────────────
694
+
695
+ /**
696
+ * Canonical event name for a board-write attempt.
697
+ *
698
+ * ONE event per {@link mirrorBoard} call — including every no-op path. The
699
+ * no-op paths are the point: a vault-disabled config, a hand-edited board, or a
700
+ * failed write returned silently before this existed, so an outage of this
701
+ * writer was indistinguishable from a healthy skip. Measured 2026-08-23 over
702
+ * 28 387 ledger records: ZERO board/mirror events, because this module did not
703
+ * import {@link emitEvent} at all.
704
+ */
705
+ export const BOARD_EVENT = 'orchestrator.vault.board_written';
706
+
707
+ /**
708
+ * Emit the board-write telemetry record. Best-effort: never throws, never
709
+ * alters the board result.
710
+ *
711
+ * ABSENT IS NOT ZERO (`docs/events-schema.md`): every optional field is spread
712
+ * conditionally, so an UNMEASURED field is missing from the record rather than
713
+ * written as `0`. A present `repos_swept: 0` therefore means "enumeration ran
714
+ * and surfaced nothing" (the documented silent-enumeration failure mode), while
715
+ * an absent `repos_swept` means "not a sweep, or enumeration threw" — reading
716
+ * the missing key as `0` would conflate the two in both directions.
717
+ *
718
+ * @param {object} opts
719
+ * @param {string} [opts.repoRoot] — pins the ledger to THIS repo's
720
+ * `.orchestrator/metrics/events.jsonl` (#941). Omitted only when the caller
721
+ * supplied no usable root, where `SO_PROJECT_DIR` is the sole destination left.
722
+ * @param {'sweepBoard'|'mirrorBoard'} opts.caller — which entry point ran.
723
+ * @param {string} opts.action — the `action` the board write returned.
724
+ * @param {string} [opts.path] — resolved board path, when one was resolved.
725
+ * @param {number} [opts.rows] — rows in the board content THIS call rendered.
726
+ * Present whenever the render was reached (so also on `skipped-noop` /
727
+ * `dry-run`, where the content was built but not written — `action` is what
728
+ * says whether it landed); absent on the early no-op paths that never render.
729
+ * @param {number} [opts.reposSwept] — candidates {@link enumerateCandidates}
730
+ * returned, on the {@link sweepBoard} path only.
731
+ * @param {number} [opts.durationMs]
732
+ * @returns {Promise<void>}
733
+ */
734
+ /**
735
+ * Reduce an absolute vault path to its LAST TWO segments for telemetry.
736
+ *
737
+ * The full path is the module's public return contract and stays untouched.
738
+ * What must not travel is the path in the EMITTED payload: on a real host it
739
+ * reads `/Users/<name>/Projects/<vault>/01-projects/<private-slug>/…`, i.e. an
740
+ * OS username plus a private project slug. Those are exactly the two shapes
741
+ * `scripts/lib/validate/check-owner-leakage.mjs` blocks as CP1 and CP6 — and
742
+ * that scanner structurally cannot see this one, because it walks `git ls-files`
743
+ * and `.orchestrator/metrics/*.jsonl` is gitignored (`.gitignore:40`).
744
+ * The record is invisible to the pre-commit guard and visible to the optional
745
+ * Clank webhook (`scripts/lib/events.mjs`, `CLANK_EVENT_URL`), which posts the
746
+ * payload verbatim with no redaction.
747
+ *
748
+ * The BASENAME is the deliberate ceiling — one segment, not two. Two segments
749
+ * would keep the parent directory, and under `01-projects/` that directory IS
750
+ * the private project slug, i.e. exactly the CP6 shape this is meant to drop.
751
+ * The diagnostic value lives in the filename alone: it says WHICH writer ran
752
+ * (`_session-narrative.md` vs `_active-sessions.md`), which is the question the
753
+ * event exists to answer. Which project it was is already answerable from the
754
+ * record's own `session_id` / repo-scoped ledger location.
755
+ * Revisit trigger: a consumer that needs more than the filename — then it
756
+ * belongs in the RETURN value, which already carries the absolute path, never
757
+ * in the event.
758
+ *
759
+ * @param {unknown} outputPath
760
+ * @returns {string|undefined} `undefined` when there is nothing measured to report.
761
+ */
762
+ function telemetrySafePath(outputPath) {
763
+ if (typeof outputPath !== 'string' || outputPath.length === 0) return undefined;
764
+ const base = path.basename(outputPath);
765
+ return base.length > 0 ? base : undefined;
766
+ }
767
+
768
+ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs }) {
769
+ // Refuse the SO_PROJECT_DIR fallback instead of guessing a destination.
770
+ // Without an explicit repoRoot, `emitEvent` resolves `eventsFilePath(undefined)`
771
+ // and writes into whatever tree the ambient env points at — so `mirrorBoard()`
772
+ // called with no argument used to append a record to an UNRELATED repo's ledger.
773
+ // Measured 2026-08-23: a review agent reproduced it and put a second, byte-identical
774
+ // record into this repo's live events.jsonl doing so. Two sibling emitters in the
775
+ // same commit arc already refuse it (`express-path.mjs` with a WARN,
776
+ // `narrative-mirror.mjs` silently); this one was the odd one out, and it was the
777
+ // unsafe one. A stderr WARN, not silence: a telemetry record that goes missing
778
+ // should say so, or it becomes the very blind spot this event was added to close.
779
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
780
+ process.stderr.write(
781
+ `[board-writer] ${BOARD_EVENT} not emitted: no repoRoot given, and the ambient ` +
782
+ `SO_PROJECT_DIR fallback would write to an unrelated repo's ledger.\n`,
783
+ );
784
+ return;
785
+ }
786
+ try {
787
+ await emitEvent(
788
+ BOARD_EVENT,
789
+ {
790
+ action,
791
+ caller,
792
+ ...(telemetrySafePath(outputPath) !== undefined ? { path_tail: telemetrySafePath(outputPath) } : {}),
793
+ // Number.isFinite — NOT truthiness — is what keeps a MEASURED zero in
794
+ // the record (`repos_swept: 0` = "enumeration ran, found nothing") while
795
+ // still omitting an unmeasured field. `x || undefined` would silently
796
+ // delete exactly the zero the field exists to report. (`!= null` is the
797
+ // idiom elsewhere but this repo's eqeqeq rule forbids it.)
798
+ ...(Number.isFinite(rows) ? { rows } : {}),
799
+ ...(Number.isFinite(reposSwept) ? { repos_swept: reposSwept } : {}),
800
+ ...(Number.isFinite(durationMs) ? { duration_ms: durationMs } : {}),
801
+ // #1147: join key parity with the sibling `narrative_mirrored` event,
802
+ // which has carried attribution since #1073. Without it a board record
803
+ // cannot be joined to the session that wrote it. Both keys are OMITTED
804
+ // (never fabricated) when no session.lock is readable at `repoRoot` —
805
+ // and `repoRoot` is the SAME root the ledger line is pinned to below,
806
+ // so the attribution can never name a different tree than the record.
807
+ ...sessionAttribution(repoRoot),
808
+ },
809
+ { repoRoot },
810
+ );
811
+ } catch {
812
+ /* Best-effort telemetry. emitEvent does real file I/O (mkdir + append), so a
813
+ read-only or occupied ledger path WILL throw — and a broken ledger must
814
+ never fail a board write. The board result is authoritative. */
815
+ }
816
+ }
817
+
684
818
  // ── Convenience: config-read + resolve + write ───────────────────────────────────
685
819
 
686
820
  /**
@@ -717,11 +851,14 @@ export function writeBoard(opts) {
717
851
  * `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
718
852
  * and bleeds into the assertion (issue #783). Production callers omit this — the
719
853
  * default (real owner.yaml resolution) is the correct host-local behavior there.
720
- * @returns {Promise<{ action: string, path?: string }>}
854
+ * @returns {Promise<{ result: { action: string, path?: string }, rows?: number }>}
855
+ * `rows` is present only once the render was reached — see
856
+ * {@link emitBoardEvent}'s `rows` contract. The public {@link mirrorBoard}
857
+ * wrapper unwraps `result` so the caller-visible return shape is unchanged.
721
858
  */
722
- export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new Date(), dryRun = false, fs, hostPaths } = {}) {
859
+ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Date(), dryRun = false, fs, hostPaths } = {}) {
723
860
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
724
- return { action: 'skipped-vault-disabled' };
861
+ return { result: { action: 'skipped-vault-disabled' } };
725
862
  }
726
863
 
727
864
  // Read + parse Session Config. Any failure → silent no-op.
@@ -730,16 +867,16 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
730
867
  const text = await readConfigFile(repoRoot);
731
868
  config = parseSessionConfig(text, { hostPaths });
732
869
  } catch {
733
- return { action: 'skipped-vault-disabled' };
870
+ return { result: { action: 'skipped-vault-disabled' } };
734
871
  }
735
872
 
736
873
  const vault = config?.['vault-integration'];
737
874
  if (!vault || vault.enabled !== true) {
738
- return { action: 'skipped-vault-disabled' };
875
+ return { result: { action: 'skipped-vault-disabled' } };
739
876
  }
740
877
  const vaultDir = vault['vault-dir'];
741
878
  if (typeof vaultDir !== 'string' || vaultDir.length === 0) {
742
- return { action: 'skipped-vault-disabled' };
879
+ return { result: { action: 'skipped-vault-disabled' } };
743
880
  }
744
881
 
745
882
  // Safety: the resolved vault dir must live under $HOME.
@@ -747,7 +884,7 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
747
884
  const home = os.homedir();
748
885
  const inHome = validatePathInsideProject(expandedVault, home);
749
886
  if (!inHome.ok) {
750
- return { action: 'skipped-vault-disabled' };
887
+ return { result: { action: 'skipped-vault-disabled' } };
751
888
  }
752
889
 
753
890
  // `vault-name` (#660) overrides the git-derived repo slug for per-project
@@ -922,7 +1059,55 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
922
1059
 
923
1060
  const content = renderBoard([...merged.values()], { now, createdIso });
924
1061
 
925
- return writeBoard({ outputPath, content, dryRun, fs });
1062
+ return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
1063
+ }
1064
+
1065
+ /**
1066
+ * Public {@link mirrorBoardInner} wrapper that emits exactly ONE
1067
+ * {@link BOARD_EVENT} per call — on EVERY path, no-ops included.
1068
+ *
1069
+ * The wrapper exists so the emit cannot be forgotten: the six return points
1070
+ * inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards plus
1071
+ * whatever {@link writeBoard} decides) all funnel through here, and so does the
1072
+ * seventh someone adds next. Emitting per-return instead would leave each new
1073
+ * early return silent by default — which is the exact defect being fixed.
1074
+ *
1075
+ * A THROW from the inner function is deliberately NOT converted into an event:
1076
+ * `action` is mandatory in the payload and a throw has no action the code knows,
1077
+ * so inventing one would put a fictional state in the ledger. The throw is not
1078
+ * silent either — it propagates to the caller, and on the {@link sweepBoard}
1079
+ * path the fallback write emits its own record.
1080
+ *
1081
+ * Return shape is byte-identical to the pre-telemetry contract.
1082
+ *
1083
+ * @param {Parameters<typeof mirrorBoardInner>[0] & {
1084
+ * caller?: 'sweepBoard'|'mirrorBoard', reposSwept?: number }} [opts]
1085
+ * — full parameter contract (`repoRoot`, `repos`, `explicitStatus`, `now`,
1086
+ * `dryRun`, `fs`, `hostPaths`) is documented on {@link mirrorBoardInner}.
1087
+ * `caller` / `reposSwept` are telemetry attribution only and never reach the
1088
+ * board content. `caller` defaults to `'mirrorBoard'`; {@link sweepBoard}
1089
+ * overrides it so the two entry points stay separable in the ledger.
1090
+ * @returns {Promise<{ action: string, path?: string }>}
1091
+ */
1092
+ export async function mirrorBoard(opts = {}) {
1093
+ const startedAt = Date.now();
1094
+ // Destructured directly (not via `opts ?? {}`) so a `null` argument still
1095
+ // throws exactly as it did before this wrapper existed.
1096
+ const { repoRoot, caller = 'mirrorBoard', reposSwept } = opts;
1097
+
1098
+ const { result, rows } = await mirrorBoardInner(opts);
1099
+
1100
+ await emitBoardEvent({
1101
+ repoRoot,
1102
+ caller,
1103
+ action: result?.action,
1104
+ path: result?.path,
1105
+ rows,
1106
+ reposSwept,
1107
+ durationMs: Date.now() - startedAt,
1108
+ });
1109
+
1110
+ return result;
926
1111
  }
927
1112
 
928
1113
  // ── Host-wide sweep (issue #716) ─────────────────────────────────────────────────
@@ -1022,6 +1207,12 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
1022
1207
  * write still happens. `mirrorBoard`'s own internal guards (vault disabled,
1023
1208
  * `_overview.md` refusal, noop-skip, …) are untouched and still apply.
1024
1209
  *
1210
+ * Telemetry: BOTH paths emit one {@link BOARD_EVENT} with `caller: 'sweepBoard'`
1211
+ * (the wrapping {@link mirrorBoard} call does the emitting, so a sweep never
1212
+ * produces two records). The happy path carries `repos_swept`; the fallback
1213
+ * omits it, which is what distinguishes "enumeration ran and found nothing"
1214
+ * (`repos_swept: 0`) from "enumeration threw" (key absent).
1215
+ *
1025
1216
  * @param {object} [opts]
1026
1217
  * @param {string} opts.repoRoot — the calling repo (always included in the sweep).
1027
1218
  * @param {string} [opts.startDir] — enumeration root; omitted in production so
@@ -1061,11 +1252,21 @@ export async function sweepBoard({ repoRoot, startDir, now = new Date(), dryRun
1061
1252
  try {
1062
1253
  const candidates = await enumerateCandidates({ startDir, now: nowMs, deps });
1063
1254
  const repos = buildSweepRepos(candidates, { thisRepoRoot: repoRoot });
1064
- return await mirrorBoard({ repoRoot, repos, now: nowForMirror, dryRun, fs, hostPaths });
1255
+ // Telemetry counts what ENUMERATION surfaced, not what we sweep: `repos`
1256
+ // always contains at least `thisRepoRoot` (buildSweepRepos unions it in),
1257
+ // so a silently-empty enumeration — the documented macOS realpath-hop
1258
+ // failure, 0 candidates and no error — would be invisible in `repos.length`
1259
+ // and plainly visible as `repos_swept: 0`. Guarded with Array.isArray so a
1260
+ // non-array return keeps flowing into buildSweepRepos exactly as before
1261
+ // rather than throwing us into the fallback branch below.
1262
+ const reposSwept = Array.isArray(candidates) ? candidates.length : undefined;
1263
+ return await mirrorBoard({ repoRoot, repos, now: nowForMirror, dryRun, fs, hostPaths, caller: 'sweepBoard', reposSwept });
1065
1264
  } catch (err) {
1066
1265
  console.warn('[sweepBoard] host-wide enumeration failed — degraded to single-repo board write:', err?.message ?? err);
1067
1266
  // Best-effort fallback: enumeration failed for any reason — degrade to the
1068
1267
  // pre-#716 single-repo write so the board is still updated for THIS repo.
1069
- return mirrorBoard({ repoRoot, explicitStatus: 'in-progress', now: nowForMirror, dryRun, fs, hostPaths });
1268
+ // `reposSwept` is deliberately NOT passed: nothing was enumerated, so the
1269
+ // field is omitted rather than reported as 0 (absent is not zero).
1270
+ return mirrorBoard({ repoRoot, explicitStatus: 'in-progress', now: nowForMirror, dryRun, fs, hostPaths, caller: 'sweepBoard' });
1070
1271
  }
1071
1272
  }