session-orchestrator 3.22.0 → 3.24.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 (316) 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 +8 -0
  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/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -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 +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -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 +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -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';
@@ -49,6 +58,8 @@ import { readConfigFile, parseSessionConfig } from '../config.mjs';
49
58
  import { validatePathInsideProject } from '../path-utils.mjs';
50
59
  import { enumerateCandidates } from '../dispatcher/enumerate.mjs';
51
60
  import { atomicWriteWithBackup } from '../io.mjs';
61
+ import { withBoardLock } from './board-lock.mjs';
62
+ import { expandTilde } from '../common.mjs';
52
63
 
53
64
  /** Frontmatter sentinel that identifies generator-owned board files. */
54
65
  export const GENERATOR_MARKER = 'session-orchestrator-active-sessions@1';
@@ -171,22 +182,6 @@ const nameSlot = (repo) => `n:${foldKey(repo)}`;
171
182
 
172
183
  // ── Path helpers ────────────────────────────────────────────────────────────────
173
184
 
174
- /**
175
- * Expand a leading `~` to the current user's home directory. Inlined here on
176
- * purpose — the shared helper is private elsewhere, and a shared
177
- * `vault-write-guard.mjs` extraction is deferred to a later epic (W2 forbids a
178
- * new shared file in this slice).
179
- *
180
- * @param {string} p
181
- * @returns {string}
182
- */
183
- function expandHome(p) {
184
- if (typeof p !== 'string' || p.length === 0) return p;
185
- if (p === '~') return os.homedir();
186
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
187
- return p;
188
- }
189
-
190
185
  /**
191
186
  * Resolve the board file path from a vault directory.
192
187
  *
@@ -194,7 +189,7 @@ function expandHome(p) {
194
189
  * @returns {string} `<vaultDir>/01-projects/_active-sessions.md`
195
190
  */
196
191
  export function resolveBoardPath(vaultDir) {
197
- return path.join(expandHome(vaultDir), '01-projects', '_active-sessions.md');
192
+ return path.join(expandTilde(vaultDir), '01-projects', '_active-sessions.md');
198
193
  }
199
194
 
200
195
  // ── Formatting helpers ───────────────────────────────────────────────────────────
@@ -681,6 +676,141 @@ export function writeBoard(opts) {
681
676
  return { action: 'written', path: outputPath };
682
677
  }
683
678
 
679
+ // ── Telemetry ────────────────────────────────────────────────────────────────────
680
+
681
+ /**
682
+ * Canonical event name for a board-write attempt.
683
+ *
684
+ * ONE event per {@link mirrorBoard} call — including every no-op path. The
685
+ * no-op paths are the point: a vault-disabled config, a hand-edited board, or a
686
+ * failed write returned silently before this existed, so an outage of this
687
+ * writer was indistinguishable from a healthy skip. Measured 2026-08-23 over
688
+ * 28 387 ledger records: ZERO board/mirror events, because this module did not
689
+ * import {@link emitEvent} at all.
690
+ */
691
+ export const BOARD_EVENT = 'orchestrator.vault.board_written';
692
+
693
+ /**
694
+ * Emit the board-write telemetry record. Best-effort: never throws, never
695
+ * alters the board result.
696
+ *
697
+ * ABSENT IS NOT ZERO (`docs/events-schema.md`): every optional field is spread
698
+ * conditionally, so an UNMEASURED field is missing from the record rather than
699
+ * written as `0`. A present `repos_swept: 0` therefore means "enumeration ran
700
+ * and surfaced nothing" (the documented silent-enumeration failure mode), while
701
+ * an absent `repos_swept` means "not a sweep, or enumeration threw" — reading
702
+ * the missing key as `0` would conflate the two in both directions.
703
+ *
704
+ * @param {object} opts
705
+ * @param {string} [opts.repoRoot] — pins the ledger to THIS repo's
706
+ * `.orchestrator/metrics/events.jsonl` (#941). Omitted only when the caller
707
+ * supplied no usable root, where `SO_PROJECT_DIR` is the sole destination left.
708
+ * @param {'sweepBoard'|'mirrorBoard'} opts.caller — which entry point ran.
709
+ * @param {string} opts.action — the `action` the board write returned.
710
+ * @param {string} [opts.path] — resolved board path, when one was resolved.
711
+ * @param {number} [opts.rows] — rows in the board content THIS call rendered.
712
+ * Present whenever the render was reached (so also on `skipped-noop` /
713
+ * `dry-run`, where the content was built but not written — `action` is what
714
+ * says whether it landed); absent on the early no-op paths that never render.
715
+ * @param {number} [opts.reposSwept] — candidates {@link enumerateCandidates}
716
+ * returned, on the {@link sweepBoard} path only.
717
+ * @param {number} [opts.durationMs]
718
+ * @param {{ locked: boolean, reason?: string, stale_override?: string, waited_ms: number }} [opts.lock]
719
+ * — board-lock outcome, present only when the lock was actually attempted
720
+ * (i.e. not on the early no-op guards, not on dry-run). Never carries the lock
721
+ * PATH — that is a `$HOME`-rooted string, the CP1 shape this payload keeps out.
722
+ * @returns {Promise<void>}
723
+ */
724
+ /**
725
+ * Reduce an absolute vault path to its LAST TWO segments for telemetry.
726
+ *
727
+ * The full path is the module's public return contract and stays untouched.
728
+ * What must not travel is the path in the EMITTED payload: on a real host it
729
+ * reads `/Users/<name>/Projects/<vault>/01-projects/<private-slug>/…`, i.e. an
730
+ * OS username plus a private project slug. Those are exactly the two shapes
731
+ * `scripts/lib/validate/check-owner-leakage.mjs` blocks as CP1 and CP6 — and
732
+ * that scanner structurally cannot see this one, because it walks `git ls-files`
733
+ * and `.orchestrator/metrics/*.jsonl` is gitignored (`.gitignore:40`).
734
+ * The record is invisible to the pre-commit guard and visible to the optional
735
+ * Clank webhook (`scripts/lib/events.mjs`, `CLANK_EVENT_URL`), which posts the
736
+ * payload verbatim with no redaction.
737
+ *
738
+ * The BASENAME is the deliberate ceiling — one segment, not two. Two segments
739
+ * would keep the parent directory, and under `01-projects/` that directory IS
740
+ * the private project slug, i.e. exactly the CP6 shape this is meant to drop.
741
+ * The diagnostic value lives in the filename alone: it says WHICH writer ran
742
+ * (`_session-narrative.md` vs `_active-sessions.md`), which is the question the
743
+ * event exists to answer. Which project it was is already answerable from the
744
+ * record's own `session_id` / repo-scoped ledger location.
745
+ * Revisit trigger: a consumer that needs more than the filename — then it
746
+ * belongs in the RETURN value, which already carries the absolute path, never
747
+ * in the event.
748
+ *
749
+ * @param {unknown} outputPath
750
+ * @returns {string|undefined} `undefined` when there is nothing measured to report.
751
+ */
752
+ function telemetrySafePath(outputPath) {
753
+ if (typeof outputPath !== 'string' || outputPath.length === 0) return undefined;
754
+ const base = path.basename(outputPath);
755
+ return base.length > 0 ? base : undefined;
756
+ }
757
+
758
+ async function emitBoardEvent({ repoRoot, caller, action, path: outputPath, rows, reposSwept, durationMs, lock }) {
759
+ // Refuse the SO_PROJECT_DIR fallback instead of guessing a destination.
760
+ // Without an explicit repoRoot, `emitEvent` resolves `eventsFilePath(undefined)`
761
+ // and writes into whatever tree the ambient env points at — so `mirrorBoard()`
762
+ // called with no argument used to append a record to an UNRELATED repo's ledger.
763
+ // Measured 2026-08-23: a review agent reproduced it and put a second, byte-identical
764
+ // record into this repo's live events.jsonl doing so. Two sibling emitters in the
765
+ // same commit arc already refuse it (`express-path.mjs` with a WARN,
766
+ // `narrative-mirror.mjs` silently); this one was the odd one out, and it was the
767
+ // unsafe one. A stderr WARN, not silence: a telemetry record that goes missing
768
+ // should say so, or it becomes the very blind spot this event was added to close.
769
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
770
+ process.stderr.write(
771
+ `[board-writer] ${BOARD_EVENT} not emitted: no repoRoot given, and the ambient ` +
772
+ `SO_PROJECT_DIR fallback would write to an unrelated repo's ledger.\n`,
773
+ );
774
+ return;
775
+ }
776
+ try {
777
+ await emitEvent(
778
+ BOARD_EVENT,
779
+ {
780
+ action,
781
+ caller,
782
+ ...(telemetrySafePath(outputPath) !== undefined ? { path_tail: telemetrySafePath(outputPath) } : {}),
783
+ // Number.isFinite — NOT truthiness — is what keeps a MEASURED zero in
784
+ // the record (`repos_swept: 0` = "enumeration ran, found nothing") while
785
+ // still omitting an unmeasured field. `x || undefined` would silently
786
+ // delete exactly the zero the field exists to report. (`!= null` is the
787
+ // idiom elsewhere but this repo's eqeqeq rule forbids it.)
788
+ ...(Number.isFinite(rows) ? { rows } : {}),
789
+ ...(Number.isFinite(reposSwept) ? { repos_swept: reposSwept } : {}),
790
+ ...(Number.isFinite(durationMs) ? { duration_ms: durationMs } : {}),
791
+ // Lock diagnostics (absent on every path that never took the lock: the
792
+ // early no-op guards and dry-run). `lock.locked === false` marks a
793
+ // fail-open unlocked write; `lock.stale_override` marks an acquire that
794
+ // aged out someone else's lock — the observable behind board-lock's
795
+ // DEFAULT_STALE_MS revisit trigger.
796
+ ...(lock && typeof lock === 'object' ? { lock } : {}),
797
+ // #1147: join key parity with the sibling `narrative_mirrored` event,
798
+ // which has carried attribution since #1073. Without it a board record
799
+ // cannot be joined to the session that wrote it. Both keys are OMITTED
800
+ // (never fabricated) when no session.lock is readable at `repoRoot` —
801
+ // and `repoRoot` is the SAME root the ledger line is pinned to below,
802
+ // so the attribution can never name a different tree than the record.
803
+ ...sessionAttribution(repoRoot),
804
+ },
805
+ { repoRoot },
806
+ );
807
+ } catch {
808
+ /* Best-effort telemetry. emitEvent does real file I/O (mkdir + append), so a
809
+ read-only or occupied ledger path WILL throw — and a broken ledger must
810
+ never fail a board write. The board result is authoritative. */
811
+ }
812
+ }
813
+
684
814
  // ── Convenience: config-read + resolve + write ───────────────────────────────────
685
815
 
686
816
  /**
@@ -717,11 +847,17 @@ export function writeBoard(opts) {
717
847
  * `owner.yaml`, whose `paths.vault-dir` override (if set) wins over the fixture value
718
848
  * and bleeds into the assertion (issue #783). Production callers omit this — the
719
849
  * default (real owner.yaml resolution) is the correct host-local behavior there.
720
- * @returns {Promise<{ action: string, path?: string }>}
850
+ * @returns {Promise<{ result: { action: string, path?: string }, rows?: number,
851
+ * lock?: { locked: boolean, reason?: string, stale_override?: string, waited_ms: number } }>}
852
+ * `lock` is present only on the locked path (absent on the early no-op guards
853
+ * and on dry-run, which deliberately takes no lock).
854
+ * `rows` is present only once the render was reached — see
855
+ * {@link emitBoardEvent}'s `rows` contract. The public {@link mirrorBoard}
856
+ * wrapper unwraps `result` so the caller-visible return shape is unchanged.
721
857
  */
722
- export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new Date(), dryRun = false, fs, hostPaths } = {}) {
858
+ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Date(), dryRun = false, fs, hostPaths } = {}) {
723
859
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
724
- return { action: 'skipped-vault-disabled' };
860
+ return { result: { action: 'skipped-vault-disabled' } };
725
861
  }
726
862
 
727
863
  // Read + parse Session Config. Any failure → silent no-op.
@@ -730,24 +866,24 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
730
866
  const text = await readConfigFile(repoRoot);
731
867
  config = parseSessionConfig(text, { hostPaths });
732
868
  } catch {
733
- return { action: 'skipped-vault-disabled' };
869
+ return { result: { action: 'skipped-vault-disabled' } };
734
870
  }
735
871
 
736
872
  const vault = config?.['vault-integration'];
737
873
  if (!vault || vault.enabled !== true) {
738
- return { action: 'skipped-vault-disabled' };
874
+ return { result: { action: 'skipped-vault-disabled' } };
739
875
  }
740
876
  const vaultDir = vault['vault-dir'];
741
877
  if (typeof vaultDir !== 'string' || vaultDir.length === 0) {
742
- return { action: 'skipped-vault-disabled' };
878
+ return { result: { action: 'skipped-vault-disabled' } };
743
879
  }
744
880
 
745
881
  // Safety: the resolved vault dir must live under $HOME.
746
- const expandedVault = expandHome(vaultDir);
882
+ const expandedVault = expandTilde(vaultDir);
747
883
  const home = os.homedir();
748
884
  const inHome = validatePathInsideProject(expandedVault, home);
749
885
  if (!inHome.ok) {
750
- return { action: 'skipped-vault-disabled' };
886
+ return { result: { action: 'skipped-vault-disabled' } };
751
887
  }
752
888
 
753
889
  // `vault-name` (#660) overrides the git-derived repo slug for per-project
@@ -797,132 +933,220 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
797
933
 
798
934
  const outputPath = resolveBoardPath(vaultDir);
799
935
 
800
- // Read the EXISTING generator-owned board (if any) to:
801
- // 1. preserve its `created:` otherwise every render differs on `created:`
802
- // and the noop-skip in writeBoard would never fire.
803
- // 2. recover the prior per-repo status drives the `closed` derivation for
804
- // repos NOT in this update (idempotent merge: their rows are re-derived).
805
- // Both maps are keyed by {@link foldKey}(repo) case-insensitively folded
806
- // (issue #719) so two prior rows differing only by case (e.g.
807
- // `some-repo` vs `Some-Repo`, the same physical directory on a
808
- // case-insensitive-preserving filesystem like APFS) collapse to ONE entry
809
- // instead of coexisting as duplicates. The row OBJECTS keep their original
810
- // `repo` string untouched, so `renderBoard` still displays true casing.
811
- const fsReadFile = fs?.readFileSync ?? readFileSync;
812
- const fsExists = fs?.existsSync ?? existsSync;
813
- let createdIso;
814
- const priorStatusByRepo = new Map(); // LEGACY rows only see collectRows contract
815
- const priorStatusByKey = new Map(); // boardKey status (authoritative since #871)
816
- const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) prior row
817
- if (fsExists(outputPath)) {
818
- let existing;
819
- try {
820
- existing = fsReadFile(outputPath, 'utf8');
821
- } catch {
822
- existing = null;
823
- }
824
- if (existing) {
825
- const fm = parseFrontmatter(existing);
826
- if (fm && fm['_generator'] === GENERATOR_MARKER) {
827
- if (fm['created']) createdIso = fm['created'];
828
- for (const prior of parseBoardRows(existing)) {
829
- // Dual-key slotting (#871): a keyed row owns its own hash slot; a
830
- // legacy (6-column) row falls back to its folded display name. Two
831
- // keyed rows can only collide when they resolve to the SAME path, so
832
- // the heartbeat-preference resolution below is now reached almost
833
- // exclusively by legacy rows which is precisely the case it was
834
- // written for (#719).
835
- const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
836
- const collidingPrior = preservedRows.get(key);
837
- if (collidingPrior) {
838
- // Collision WITHIN parseBoardRows output two prior rows fold to
839
- // the same key with no fresh row in play yet (that upsert happens
840
- // below). Prefer the row with the most-recent `heartbeat` rather
841
- // than silently last-in-file-order. Guard: if either heartbeat is
842
- // unparsable, fall through to last-written-wins (the pre-#719
843
- // default) by NOT skipping the overwrite below.
844
- const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
845
- const priorTs = Date.parse(prior.heartbeat ?? '');
846
- if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
847
- // The already-preserved row is strictly newer keep it, skip
848
- // this older colliding row entirely.
849
- continue;
936
+ // Everything below — the two reads of the existing board, the merge, and the
937
+ // write is ONE read-modify-write over a file shared by every repo on the
938
+ // host (issue #1180). Serialise it on the vault-scoped board lock so a
939
+ // concurrent sweepBoard() from another repo cannot compute its merge from a
940
+ // base we are about to replace. Fail-open: withBoardLock runs the closure
941
+ // unlocked (with a WARN) rather than let a contended lock abort the phase.
942
+ const mergeAndWrite = async () => {
943
+ // Read the EXISTING generator-owned board (if any) to:
944
+ // 1. preserve its `created:` otherwise every render differs on `created:`
945
+ // and the noop-skip in writeBoard would never fire.
946
+ // 2. recover the prior per-repo status drives the `closed` derivation for
947
+ // repos NOT in this update (idempotent merge: their rows are re-derived).
948
+ // Both maps are keyed by {@link foldKey}(repo) — case-insensitively folded
949
+ // (issue #719) — so two prior rows differing only by case (e.g.
950
+ // `some-repo` vs `Some-Repo`, the same physical directory on a
951
+ // case-insensitive-preserving filesystem like APFS) collapse to ONE entry
952
+ // instead of coexisting as duplicates. The row OBJECTS keep their original
953
+ // `repo` string untouched, so `renderBoard` still displays true casing.
954
+ const fsReadFile = fs?.readFileSync ?? readFileSync;
955
+ const fsExists = fs?.existsSync ?? existsSync;
956
+ let createdIso;
957
+ const priorStatusByRepo = new Map(); // LEGACY rows only — see collectRows contract
958
+ const priorStatusByKey = new Map(); // boardKey → status (authoritative since #871)
959
+ const preservedRows = new Map(); // merge slot (see hashSlot/nameSlot) → prior row
960
+ if (fsExists(outputPath)) {
961
+ let existing;
962
+ try {
963
+ existing = fsReadFile(outputPath, 'utf8');
964
+ } catch {
965
+ existing = null;
966
+ }
967
+ if (existing) {
968
+ const fm = parseFrontmatter(existing);
969
+ if (fm && fm['_generator'] === GENERATOR_MARKER) {
970
+ if (fm['created']) createdIso = fm['created'];
971
+ for (const prior of parseBoardRows(existing)) {
972
+ // Dual-key slotting (#871): a keyed row owns its own hash slot; a
973
+ // legacy (6-column) row falls back to its folded display name. Two
974
+ // keyed rows can only collide when they resolve to the SAME path, so
975
+ // the heartbeat-preference resolution below is now reached almost
976
+ // exclusively by legacy rows which is precisely the case it was
977
+ // written for (#719).
978
+ const key = prior.key ? hashSlot(prior.key) : nameSlot(prior.repo);
979
+ const collidingPrior = preservedRows.get(key);
980
+ if (collidingPrior) {
981
+ // Collision WITHIN parseBoardRows output — two prior rows fold to
982
+ // the same key with no fresh row in play yet (that upsert happens
983
+ // below). Prefer the row with the most-recent `heartbeat` rather
984
+ // than silently last-in-file-order. Guard: if either heartbeat is
985
+ // unparsable, fall through to last-written-wins (the pre-#719
986
+ // default) by NOT skipping the overwrite below.
987
+ const collidingTs = Date.parse(collidingPrior.heartbeat ?? '');
988
+ const priorTs = Date.parse(prior.heartbeat ?? '');
989
+ if (Number.isFinite(collidingTs) && Number.isFinite(priorTs) && collidingTs > priorTs) {
990
+ // The already-preserved row is strictly newer — keep it, skip
991
+ // this older colliding row entirely.
992
+ continue;
993
+ }
850
994
  }
995
+ if (prior.key) {
996
+ priorStatusByKey.set(prior.key, prior.status);
997
+ } else {
998
+ // LEGACY rows only. Seeding this map from keyed rows too would let
999
+ // repo B (never seen, same basename) inherit repo A's terminal
1000
+ // status through the name fallback in collectRows — reintroducing
1001
+ // the identity collision #871 exists to remove, one layer down.
1002
+ priorStatusByRepo.set(foldKey(prior.repo), prior.status);
1003
+ }
1004
+ preservedRows.set(key, prior);
851
1005
  }
852
- if (prior.key) {
853
- priorStatusByKey.set(prior.key, prior.status);
854
- } else {
855
- // LEGACY rows only. Seeding this map from keyed rows too would let
856
- // repo B (never seen, same basename) inherit repo A's terminal
857
- // status through the name fallback in collectRows — reintroducing
858
- // the identity collision #871 exists to remove, one layer down.
859
- priorStatusByRepo.set(foldKey(prior.repo), prior.status);
860
- }
861
- preservedRows.set(key, prior);
862
1006
  }
863
1007
  }
864
1008
  }
865
- }
866
1009
 
867
- const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
868
-
869
- // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
870
- // Without this pass, a preserved `in-progress` row (a repo NOT in this
871
- // update) is copied forward FOREVER — a crashed/never-closed session's row
872
- // never flips even after its heartbeat has aged well past the lock's TTL,
873
- // because `collectRows` only re-derives status for repos actually IN
874
- // `repoList`. Re-derive staleness for every preserved row here, BEFORE the
875
- // freshly-derived `rows` are upserted over it below (fresh data always
876
- // wins regardless of this pass — a live lock or an explicit-closed update
877
- // always takes precedence over the TTL flip).
878
- //
879
- // Board rows carry only a raw `heartbeat` string, never the lock's own
880
- // `ttl_hours` (that field is not part of the rendered board) — so this
881
- // reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
882
- // per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
883
- // Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
884
- // never crash on a malformed prior board).
885
- const nowMs = now instanceof Date ? now.getTime() : Date.now();
886
- const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
887
- const staleRederivedRows = new Map();
888
- for (const [key, row] of preservedRows) {
889
- if (row.status === STATUS_IN_PROGRESS) {
890
- const heartbeatMs = Date.parse(row.heartbeat ?? '');
891
- if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
892
- staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
893
- continue;
1010
+ const rows = await collectRows({ repos: repoList, now, priorStatusByRepo, priorStatusByKey });
1011
+
1012
+ // TTL-staleness re-derivation for PRESERVED rows (issue #829 Finding 2).
1013
+ // Without this pass, a preserved `in-progress` row (a repo NOT in this
1014
+ // update) is copied forward FOREVER — a crashed/never-closed session's row
1015
+ // never flips even after its heartbeat has aged well past the lock's TTL,
1016
+ // because `collectRows` only re-derives status for repos actually IN
1017
+ // `repoList`. Re-derive staleness for every preserved row here, BEFORE the
1018
+ // freshly-derived `rows` are upserted over it below (fresh data always
1019
+ // wins regardless of this pass — a live lock or an explicit-closed update
1020
+ // always takes precedence over the TTL flip).
1021
+ //
1022
+ // Board rows carry only a raw `heartbeat` string, never the lock's own
1023
+ // `ttl_hours` (that field is not part of the rendered board) — so this
1024
+ // reuses the shared {@link DEFAULT_TTL_HOURS} constant rather than the
1025
+ // per-lock TTL {@link isLockLive} uses when a live lock object is in hand.
1026
+ // Rows with an unparseable/absent heartbeat are left UNCHANGED (fail-open,
1027
+ // never crash on a malformed prior board).
1028
+ const nowMs = now instanceof Date ? now.getTime() : Date.now();
1029
+ const ttlMs = DEFAULT_TTL_HOURS * 3600 * 1000;
1030
+ const staleRederivedRows = new Map();
1031
+ for (const [key, row] of preservedRows) {
1032
+ if (row.status === STATUS_IN_PROGRESS) {
1033
+ const heartbeatMs = Date.parse(row.heartbeat ?? '');
1034
+ if (Number.isFinite(heartbeatMs) && (nowMs - heartbeatMs) >= ttlMs) {
1035
+ staleRederivedRows.set(key, { ...row, status: STATUS_FORCE_CLOSED });
1036
+ continue;
1037
+ }
894
1038
  }
1039
+ staleRederivedRows.set(key, row);
895
1040
  }
896
- staleRederivedRows.set(key, row);
897
- }
898
1041
 
899
- // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
900
- // update, then upsert the freshly-derived rows over them so repeated writes
901
- // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
902
- // same slot — that is what collapses a live row over a stale preserved one.
903
- //
904
- // Dual-key upsert (#871). A naive switch from the folded name to the path
905
- // key would make the two key spaces DISJOINT: the fresh row would never
906
- // overwrite the legacy row, the legacy row would become immortal (the sweep
907
- // skips `frei` candidates and the TTL pass only rewrites `status`, never
908
- // removes a row), and the board would grow a permanent duplicate per repo.
909
- // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
910
- // the legacy name slot for the same folded name — one board write converts
911
- // the row, and the migration is complete for that repo.
912
- const merged = new Map(staleRederivedRows);
913
- for (const row of rows) {
914
- const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
915
- if (row.key && !merged.has(slot)) {
916
- // First keyed write for this repo — take over its legacy row rather than
917
- // rendering a second one beside it.
918
- merged.delete(nameSlot(row.repo));
1042
+ // Idempotent merge: keep prior (TTL-rederived) rows for repos NOT in this
1043
+ // update, then upsert the freshly-derived rows over them so repeated writes
1044
+ // stay stable. A freshly-derived row ALWAYS wins over a preserved row in the
1045
+ // same slot — that is what collapses a live row over a stale preserved one.
1046
+ //
1047
+ // Dual-key upsert (#871). A naive switch from the folded name to the path
1048
+ // key would make the two key spaces DISJOINT: the fresh row would never
1049
+ // overwrite the legacy row, the legacy row would become immortal (the sweep
1050
+ // skips `frei` candidates and the TTL pass only rewrites `status`, never
1051
+ // removes a row), and the board would grow a permanent duplicate per repo.
1052
+ // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1053
+ // the legacy name slot for the same folded name — one board write converts
1054
+ // the row, and the migration is complete for that repo.
1055
+ const merged = new Map(staleRederivedRows);
1056
+ for (const row of rows) {
1057
+ const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
1058
+ if (row.key && !merged.has(slot)) {
1059
+ // First keyed write for this repo — take over its legacy row rather than
1060
+ // rendering a second one beside it.
1061
+ merged.delete(nameSlot(row.repo));
1062
+ }
1063
+ merged.set(slot, row);
919
1064
  }
920
- merged.set(slot, row);
921
- }
922
1065
 
923
- const content = renderBoard([...merged.values()], { now, createdIso });
1066
+ const content = renderBoard([...merged.values()], { now, createdIso });
1067
+
1068
+ return { result: writeBoard({ outputPath, content, dryRun, fs }), rows: merged.size };
1069
+ };
1070
+
1071
+ // dry-run never touches disk (writeBoard guard 1) — so it must not create a
1072
+ // lock file in the operator's vault either. Nothing to serialise.
1073
+ if (dryRun) return await mergeAndWrite();
1074
+
1075
+ // The lock outcome is diagnostic, and until now it went nowhere: `withBoardLock`
1076
+ // has exposed `onLockOutcome` since #1180, and NO production caller passed one
1077
+ // (measured 2026-09-02: `grep -rn onLockOutcome scripts hooks` → board-lock.mjs
1078
+ // and its test, nothing else). So the two states that silently weaken the mutex —
1079
+ // a fail-open unlocked write, and a stale-override that can override a LIVE
1080
+ // writer (see board-lock's DEFAULT_STALE_MS § CEILING) — were unobservable in
1081
+ // aggregate, which is exactly what the revisit trigger needs. Capture it here and
1082
+ // ride it out on the ONE board_written event rather than adding a second event.
1083
+ let lockOutcome;
1084
+ const acquireStartedAt = Date.now();
1085
+ const inner = await withBoardLock(expandedVault, mergeAndWrite, {
1086
+ onLockOutcome: (o) => {
1087
+ // Called exactly once, strictly BEFORE `fn` — so the elapsed time is the
1088
+ // acquire wait, not the merge. `lockPath` is deliberately DROPPED: it is
1089
+ // `<vault>/.orchestrator/board.lock` under $HOME, i.e. the CP1 (OS username)
1090
+ // shape `telemetrySafePath` exists to keep out of the payload.
1091
+ lockOutcome = {
1092
+ locked: o?.locked === true,
1093
+ ...(typeof o?.reason === 'string' ? { reason: o.reason } : {}),
1094
+ ...(typeof o?.staleOverride === 'string' ? { stale_override: o.staleOverride } : {}),
1095
+ waited_ms: Date.now() - acquireStartedAt,
1096
+ };
1097
+ },
1098
+ });
924
1099
 
925
- return writeBoard({ outputPath, content, dryRun, fs });
1100
+ return lockOutcome === undefined ? inner : { ...inner, lock: lockOutcome };
1101
+ }
1102
+
1103
+ /**
1104
+ * Public {@link mirrorBoardInner} wrapper that emits exactly ONE
1105
+ * {@link BOARD_EVENT} per call — on EVERY path, no-ops included.
1106
+ *
1107
+ * The wrapper exists so the emit cannot be forgotten: the six return points
1108
+ * inside {@link mirrorBoardInner} (five `skipped-vault-disabled` guards plus
1109
+ * whatever {@link writeBoard} decides) all funnel through here, and so does the
1110
+ * seventh someone adds next. Emitting per-return instead would leave each new
1111
+ * early return silent by default — which is the exact defect being fixed.
1112
+ *
1113
+ * A THROW from the inner function is deliberately NOT converted into an event:
1114
+ * `action` is mandatory in the payload and a throw has no action the code knows,
1115
+ * so inventing one would put a fictional state in the ledger. The throw is not
1116
+ * silent either — it propagates to the caller, and on the {@link sweepBoard}
1117
+ * path the fallback write emits its own record.
1118
+ *
1119
+ * Return shape is byte-identical to the pre-telemetry contract.
1120
+ *
1121
+ * @param {Parameters<typeof mirrorBoardInner>[0] & {
1122
+ * caller?: 'sweepBoard'|'mirrorBoard', reposSwept?: number }} [opts]
1123
+ * — full parameter contract (`repoRoot`, `repos`, `explicitStatus`, `now`,
1124
+ * `dryRun`, `fs`, `hostPaths`) is documented on {@link mirrorBoardInner}.
1125
+ * `caller` / `reposSwept` are telemetry attribution only and never reach the
1126
+ * board content. `caller` defaults to `'mirrorBoard'`; {@link sweepBoard}
1127
+ * overrides it so the two entry points stay separable in the ledger.
1128
+ * @returns {Promise<{ action: string, path?: string }>}
1129
+ */
1130
+ export async function mirrorBoard(opts = {}) {
1131
+ const startedAt = Date.now();
1132
+ // Destructured directly (not via `opts ?? {}`) so a `null` argument still
1133
+ // throws exactly as it did before this wrapper existed.
1134
+ const { repoRoot, caller = 'mirrorBoard', reposSwept } = opts;
1135
+
1136
+ const { result, rows, lock } = await mirrorBoardInner(opts);
1137
+
1138
+ await emitBoardEvent({
1139
+ repoRoot,
1140
+ caller,
1141
+ action: result?.action,
1142
+ path: result?.path,
1143
+ rows,
1144
+ reposSwept,
1145
+ durationMs: Date.now() - startedAt,
1146
+ lock,
1147
+ });
1148
+
1149
+ return result;
926
1150
  }
927
1151
 
928
1152
  // ── Host-wide sweep (issue #716) ─────────────────────────────────────────────────
@@ -1022,6 +1246,12 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
1022
1246
  * write still happens. `mirrorBoard`'s own internal guards (vault disabled,
1023
1247
  * `_overview.md` refusal, noop-skip, …) are untouched and still apply.
1024
1248
  *
1249
+ * Telemetry: BOTH paths emit one {@link BOARD_EVENT} with `caller: 'sweepBoard'`
1250
+ * (the wrapping {@link mirrorBoard} call does the emitting, so a sweep never
1251
+ * produces two records). The happy path carries `repos_swept`; the fallback
1252
+ * omits it, which is what distinguishes "enumeration ran and found nothing"
1253
+ * (`repos_swept: 0`) from "enumeration threw" (key absent).
1254
+ *
1025
1255
  * @param {object} [opts]
1026
1256
  * @param {string} opts.repoRoot — the calling repo (always included in the sweep).
1027
1257
  * @param {string} [opts.startDir] — enumeration root; omitted in production so
@@ -1061,11 +1291,21 @@ export async function sweepBoard({ repoRoot, startDir, now = new Date(), dryRun
1061
1291
  try {
1062
1292
  const candidates = await enumerateCandidates({ startDir, now: nowMs, deps });
1063
1293
  const repos = buildSweepRepos(candidates, { thisRepoRoot: repoRoot });
1064
- return await mirrorBoard({ repoRoot, repos, now: nowForMirror, dryRun, fs, hostPaths });
1294
+ // Telemetry counts what ENUMERATION surfaced, not what we sweep: `repos`
1295
+ // always contains at least `thisRepoRoot` (buildSweepRepos unions it in),
1296
+ // so a silently-empty enumeration — the documented macOS realpath-hop
1297
+ // failure, 0 candidates and no error — would be invisible in `repos.length`
1298
+ // and plainly visible as `repos_swept: 0`. Guarded with Array.isArray so a
1299
+ // non-array return keeps flowing into buildSweepRepos exactly as before
1300
+ // rather than throwing us into the fallback branch below.
1301
+ const reposSwept = Array.isArray(candidates) ? candidates.length : undefined;
1302
+ return await mirrorBoard({ repoRoot, repos, now: nowForMirror, dryRun, fs, hostPaths, caller: 'sweepBoard', reposSwept });
1065
1303
  } catch (err) {
1066
1304
  console.warn('[sweepBoard] host-wide enumeration failed — degraded to single-repo board write:', err?.message ?? err);
1067
1305
  // Best-effort fallback: enumeration failed for any reason — degrade to the
1068
1306
  // 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 });
1307
+ // `reposSwept` is deliberately NOT passed: nothing was enumerated, so the
1308
+ // field is omitted rather than reported as 0 (absent is not zero).
1309
+ return mirrorBoard({ repoRoot, explicitStatus: 'in-progress', now: nowForMirror, dryRun, fs, hostPaths, caller: 'sweepBoard' });
1070
1310
  }
1071
1311
  }