session-orchestrator 3.22.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 (268) 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/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. 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';
@@ -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
  }
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * Exports:
20
20
  * GENERATOR_MARKER — frontmatter sentinel identifying generator-owned files
21
+ * NARRATIVE_EVENT — canonical telemetry event name for a mirror attempt
21
22
  * extractNarrative — pure: STATE.md contents → { waveHistory, deviations, whatNotToRetry, missionStatus }
22
23
  * renderNarrative — pure: narrative + repo + now → full markdown (frontmatter + body)
23
24
  * writeNarrative — idempotent write with skip-handwritten / skip-noop / dry-run + _overview refusal
@@ -30,6 +31,7 @@ import { readFile } from 'node:fs/promises';
30
31
  import path from 'node:path';
31
32
  import os from 'node:os';
32
33
 
34
+ import { emitEvent, sessionAttribution } from '../events.mjs';
33
35
  import { parseStateMd, parseMissionStatus } from '../state-md.mjs';
34
36
  import {
35
37
  parseFrontmatter,
@@ -557,6 +559,128 @@ function resolveLooseSlug(vaultDir, candidateSlug, fsSeam = {}) {
557
559
  return matches.length === 1 ? matches[0] : candidateSlug;
558
560
  }
559
561
 
562
+ // ── Telemetry ────────────────────────────────────────────────────────────────────
563
+
564
+ /**
565
+ * Canonical event name for a narrative-mirror attempt (issue #1129).
566
+ *
567
+ * ONE event per {@link mirrorNarrative} call — the REJECTION and NO-OP paths
568
+ * included, because those are the point. Before this existed, a vault-disabled
569
+ * config, a missing STATE.md, a hand-authored target file and a healthy write
570
+ * were all indistinguishable from the ledger's side: measured 2026-08-23 over
571
+ * 28 387 records in `.orchestrator/metrics/events.jsonl`, ZERO board/mirror
572
+ * events, because this module did not import {@link emitEvent} at all. Its only
573
+ * caller is shell prose in `skills/session-end/session-metrics-write.md`, whose
574
+ * `mode: warn` degradation prints a WARNING and closes the session anyway — so
575
+ * an outage of this writer left no durable trace whatsoever.
576
+ *
577
+ * Name deliberately NOT minted here: it is the one the wave assigned, so the
578
+ * fact "a narrative mirror ran" has exactly one address in the ledger.
579
+ */
580
+ export const NARRATIVE_EVENT = 'orchestrator.vault.narrative_mirrored';
581
+
582
+ /**
583
+ * Emit the narrative-mirror telemetry record. Best-effort: never throws, never
584
+ * alters the mirror result.
585
+ *
586
+ * ABSENT IS NOT ZERO (`docs/events-schema.md`): every optional field is spread
587
+ * conditionally, so an UNMEASURED field is MISSING from the record rather than
588
+ * written as `0`. `chars: 0` would then honestly mean "the narrative rendered to
589
+ * an empty document"; an absent `chars` means "no render was reached on this
590
+ * path" — reading the missing key as `0` conflates the two in both directions.
591
+ *
592
+ * NAMED CEILING (build-value BV-004): when `repoRoot` is absent or empty this
593
+ * emits NOTHING. `emitEvent` without `opts.repoRoot` falls back to
594
+ * `SO_PROJECT_DIR` (#941), so the only destination left for a rootless call is
595
+ * whichever repo the process happens to sit in — i.e. a caller that named no
596
+ * repo would silently append to a FOREIGN ledger, and the two call shapes that
597
+ * reach this branch today are the `repoRoot: ''` / omitted-`repoRoot` unit
598
+ * tests, which would then write synthetic records into this repo's real fleet
599
+ * telemetry on every suite run. REVISIT TRIGGER: if a production caller ever
600
+ * legitimately invokes `mirrorNarrative` without a `repoRoot`, that call needs a
601
+ * destination decision of its own — not this silent skip.
602
+ *
603
+ * @param {object} opts
604
+ * @param {string} [opts.repoRoot] — pins the ledger to THIS repo's
605
+ * `.orchestrator/metrics/events.jsonl` (#941), and supplies session attribution.
606
+ * @param {string} opts.action — the `action` the mirror returned, or `'error'`
607
+ * when the writer threw. ALWAYS present.
608
+ * @param {string} [opts.path] — resolved narrative path, when one was resolved.
609
+ * @param {number} [opts.chars] — length of the rendered narrative document this
610
+ * call produced. Present whenever the render was reached (so also on
611
+ * `skipped-noop` / `dry-run`, where the document was built but not written —
612
+ * `action` is what says whether it landed); absent on the earlier no-op paths,
613
+ * which return before anything is rendered.
614
+ * @param {string} [opts.errorCode] — `err.code` on the throw path (a bounded
615
+ * token such as `EACCES`). The error MESSAGE is deliberately NOT recorded: it
616
+ * can quote a path or STATE.md content, and this module's whole reason for
617
+ * masking (#1025) is that such prose carries secrets.
618
+ * @returns {Promise<void>}
619
+ */
620
+ /**
621
+ * Reduce an absolute vault path to its LAST TWO segments for telemetry.
622
+ *
623
+ * The full path is the module's public return contract and stays untouched.
624
+ * What must not travel is the path in the EMITTED payload: on a real host it
625
+ * reads `/Users/<name>/Projects/<vault>/01-projects/<private-slug>/…`, i.e. an
626
+ * OS username plus a private project slug. Those are exactly the two shapes
627
+ * `scripts/lib/validate/check-owner-leakage.mjs` blocks as CP1 and CP6 — and
628
+ * that scanner structurally cannot see this one, because it walks `git ls-files`
629
+ * and `.orchestrator/metrics/*.jsonl` is gitignored (`.gitignore:40`).
630
+ * The record is invisible to the pre-commit guard and visible to the optional
631
+ * Clank webhook (`scripts/lib/events.mjs`, `CLANK_EVENT_URL`), which posts the
632
+ * payload verbatim with no redaction.
633
+ *
634
+ * The BASENAME is the deliberate ceiling — one segment, not two. Two segments
635
+ * would keep the parent directory, and under `01-projects/` that directory IS
636
+ * the private project slug, i.e. exactly the CP6 shape this is meant to drop.
637
+ * The diagnostic value lives in the filename alone: it says WHICH writer ran
638
+ * (`_session-narrative.md` vs `_active-sessions.md`), which is the question the
639
+ * event exists to answer. Which project it was is already answerable from the
640
+ * record's own `session_id` / repo-scoped ledger location.
641
+ * Revisit trigger: a consumer that needs more than the filename — then it
642
+ * belongs in the RETURN value, which already carries the absolute path, never
643
+ * in the event.
644
+ *
645
+ * @param {unknown} outputPath
646
+ * @returns {string|undefined} `undefined` when there is nothing measured to report.
647
+ */
648
+ function telemetrySafePath(outputPath) {
649
+ if (typeof outputPath !== 'string' || outputPath.length === 0) return undefined;
650
+ const base = path.basename(outputPath);
651
+ return base.length > 0 ? base : undefined;
652
+ }
653
+
654
+ async function emitNarrativeEvent({ repoRoot, action, path: outputPath, chars, errorCode }) {
655
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
656
+ try {
657
+ await emitEvent(
658
+ NARRATIVE_EVENT,
659
+ {
660
+ action,
661
+ ...(telemetrySafePath(outputPath) !== undefined ? { path_tail: telemetrySafePath(outputPath) } : {}),
662
+ // `typeof … === 'number'` rather than `!= null`: the repo's eslint
663
+ // `eqeqeq: always` forbids the loose form, and this shape additionally
664
+ // refuses a non-numeric `chars` outright. A measured `0` still lands.
665
+ ...(typeof chars === 'number' ? { chars } : {}),
666
+ // `typeof === 'string'`, not truthiness: an `err.code` of '' is a measured
667
+ // empty code, and the sibling `chars` field four lines up already states
668
+ // why this file rejects the loose form.
669
+ ...(typeof errorCode === 'string' && errorCode.length > 0 ? { error_code: errorCode } : {}),
670
+ // session_id / semantic_session_id — omitted entirely when no session
671
+ // lock is readable (CI, ad-hoc runs). See sessionAttribution's contract:
672
+ // a fabricated id would read as a real session.
673
+ ...sessionAttribution(repoRoot),
674
+ },
675
+ { repoRoot },
676
+ );
677
+ } catch {
678
+ /* Best-effort telemetry. emitEvent does real file I/O (mkdir + append), so a
679
+ read-only or occupied ledger path WILL throw — and a broken ledger must
680
+ never fail a narrative write. The mirror result is authoritative. */
681
+ }
682
+ }
683
+
560
684
  // ── Convenience orchestration ────────────────────────────────────────────────────
561
685
 
562
686
  /**
@@ -594,10 +718,50 @@ function resolveLooseSlug(vaultDir, candidateSlug, fsSeam = {}) {
594
718
  * @returns {Promise<{ action: string, path?: string }>}
595
719
  */
596
720
  export async function mirrorNarrative(opts) {
721
+ let outcome;
722
+ try {
723
+ outcome = await runNarrativeMirror(opts);
724
+ } catch (err) {
725
+ // The harshest silent-failure case: `session-metrics-write.md` catches this
726
+ // throw, prints a WARNING under `mode: warn` and closes the session anyway.
727
+ // Record it, then re-throw — telemetry observes, it never swallows.
728
+ await emitNarrativeEvent({
729
+ repoRoot: opts?.repoRoot,
730
+ action: 'error',
731
+ errorCode: typeof err?.code === 'string' ? err.code : undefined,
732
+ });
733
+ throw err;
734
+ }
735
+
736
+ await emitNarrativeEvent({
737
+ repoRoot: opts?.repoRoot,
738
+ action: outcome.result.action,
739
+ path: outcome.result.path,
740
+ chars: outcome.chars,
741
+ });
742
+
743
+ return outcome.result;
744
+ }
745
+
746
+ /**
747
+ * The mirror itself — every early return of {@link mirrorNarrative} lives here.
748
+ *
749
+ * Split out so that exactly ONE emit site covers EVERY outcome: a future early
750
+ * return added inside this function is telemetered by construction, whereas
751
+ * hand-placing an emit beside each of the seven `return`s makes "forgot the new
752
+ * one" the default failure. The `chars` companion travels beside the result
753
+ * rather than inside it because the returned object is a PUBLIC shape that
754
+ * callers (and tests) compare with `toEqual` — adding a key there would be an
755
+ * observable contract change for a purely internal measurement.
756
+ *
757
+ * @param {Parameters<typeof mirrorNarrative>[0]} opts
758
+ * @returns {Promise<{ result: { action: string, path?: string }, chars?: number }>}
759
+ */
760
+ async function runNarrativeMirror(opts) {
597
761
  const { repoRoot, repo, now = new Date(), dryRun = false, fs: injectedFs, hostPaths } = opts;
598
762
 
599
763
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
600
- return { action: 'skipped-vault-disabled' };
764
+ return { result: { action: 'skipped-vault-disabled' } };
601
765
  }
602
766
 
603
767
  // Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
@@ -606,12 +770,12 @@ export async function mirrorNarrative(opts) {
606
770
  const configText = await readConfigFile(repoRoot);
607
771
  config = parseSessionConfig(configText, { hostPaths });
608
772
  } catch {
609
- return { action: 'skipped-vault-disabled' };
773
+ return { result: { action: 'skipped-vault-disabled' } };
610
774
  }
611
775
 
612
776
  const vaultIntegration = config?.['vault-integration'];
613
777
  if (!vaultIntegration || vaultIntegration.enabled !== true) {
614
- return { action: 'skipped-vault-disabled' };
778
+ return { result: { action: 'skipped-vault-disabled' } };
615
779
  }
616
780
 
617
781
  // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
@@ -628,7 +792,7 @@ export async function mirrorNarrative(opts) {
628
792
 
629
793
  const rawVaultDir = vaultIntegration['vault-dir'];
630
794
  if (!rawVaultDir || typeof rawVaultDir !== 'string') {
631
- return { action: 'skipped-vault-disabled' };
795
+ return { result: { action: 'skipped-vault-disabled' } };
632
796
  }
633
797
 
634
798
  const vaultDir = path.resolve(expandHome(rawVaultDir));
@@ -640,9 +804,19 @@ export async function mirrorNarrative(opts) {
640
804
  const outputPath = resolveNarrativePath(vaultDir, repoSlug);
641
805
 
642
806
  // Defense-in-depth: ensure the resolved file stays inside the vault root.
643
- const inside = validatePathInsideProject(path.relative(vaultDir, outputPath), vaultDir);
807
+ //
808
+ // `canonicalizeRoot: true` (#1033) is load-bearing, not hygiene. The guard's
809
+ // realpath phase is skipped while the target is ABSENT (ENOENT) and fires once
810
+ // it EXISTS — so on a vault root reached through a symlink, run 1 writes and
811
+ // run 2 resolves the file to the canonical path, reads it as outside the
812
+ // LEXICAL root, and returns `skipped-invalid-path` forever after. Canonicalizing
813
+ // the root makes both sides of the comparison the same kind of path. Same call
814
+ // shape as scripts/lib/reconcile/writer.mjs and scripts/lib/memory-proposals/store.mjs.
815
+ const inside = validatePathInsideProject(path.relative(vaultDir, outputPath), vaultDir, {
816
+ canonicalizeRoot: true,
817
+ });
644
818
  if (!inside.ok) {
645
- return { action: 'skipped-invalid-path', path: outputPath };
819
+ return { result: { action: 'skipped-invalid-path', path: outputPath } };
646
820
  }
647
821
 
648
822
  // Read STATE.md (best-effort; absent STATE.md → nothing to mirror).
@@ -651,7 +825,7 @@ export async function mirrorNarrative(opts) {
651
825
  try {
652
826
  stateContents = await readFile(stateMdPath, 'utf8');
653
827
  } catch {
654
- return { action: 'skipped-no-statemd', path: outputPath };
828
+ return { result: { action: 'skipped-no-statemd', path: outputPath } };
655
829
  }
656
830
 
657
831
  // #1025: the ONE masking site for the narrative mirror — after the frontmatter
@@ -660,5 +834,11 @@ export async function mirrorNarrative(opts) {
660
834
  const narrative = maskNarrative(extractNarrative(stateContents));
661
835
  const content = renderNarrative({ repo: repoName, narrative, now });
662
836
 
663
- return writeNarrative({ outputPath, content, dryRun, fs: injectedFs });
837
+ // `chars` measures the document THIS call rendered — so it is present on
838
+ // `skipped-noop` and `dry-run` too, where the render happened but nothing
839
+ // landed. `action` is the field that says whether it landed.
840
+ return {
841
+ result: writeNarrative({ outputPath, content, dryRun, fs: injectedFs }),
842
+ chars: content.length,
843
+ };
664
844
  }