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
@@ -33,10 +33,16 @@
33
33
  * the engine performs is into the reconcile-candidates sidecar (via
34
34
  * idempotency.mjs), and even that is skipped under `dryRun`.
35
35
  *
36
- * ── never-throws contract ────────────────────────────────────────────────────
37
- * `runReconcile` NEVER throws to its caller. A per-learning emit/render failure
38
- * degrades to a recorded rejection (never a crash); any unexpected top-level
39
- * error returns a zeroed result with an `error` field.
36
+ * ── never-throws contract (PIPELINE errors only) ─────────────────────────────
37
+ * Once the pipeline is running, `runReconcile` NEVER throws to its caller: a
38
+ * per-learning emit/render failure degrades to a recorded rejection (never a
39
+ * crash), and any unexpected top-level pipeline error returns a zeroed result
40
+ * with an `error` field. The ONE exception is a caller-INPUT validation error
41
+ * at the very top of `runReconcile` — an explicitly-passed `trigger` outside
42
+ * `KNOWN_TRIGGERS` (issue #1201 Part A) THROWS synchronously before the
43
+ * pipeline ever starts, the same class of failure as passing a malformed
44
+ * `params` object to any other function in this codebase; see
45
+ * `assertKnownTrigger` below.
40
46
  *
41
47
  * Plain Node ESM, no external deps — Node 20+ stdlib + the four siblings only.
42
48
  *
@@ -70,6 +76,16 @@
70
76
  * `capped` is a diagnostic sub-count that lets a report distinguish
71
77
  * "genuinely ineligible" rejections from "eligible but cut by the volume
72
78
  * brake" ones at a glance.
79
+ * @property {number} alreadyMaterialized - count of eligible learnings that were
80
+ * NOT proposed this run because they are already terminal — either the
81
+ * idempotency sidecar already carries a `processed_at` stamp for their
82
+ * `learning_key` (`isProcessed`), or a `.claude/rules/*.md` file already
83
+ * carries a matching `learning-key`/`learning-id` provenance marker
84
+ * (issue #484: 9 of 10 proposals in one run were exactly this). Runs
85
+ * BEFORE the `maxProposalsPerRun` volume brake so an already-materialized
86
+ * learning never consumes a new learning's quota. Same accounting pattern
87
+ * as `capped`: each one is ALSO counted inside `rejected`, and
88
+ * `totalLearnings === proposed + rejected` still holds unchanged.
73
89
  * @property {boolean} written
74
90
  * @property {number} [skipped] - how many persisted sidecar lines the store's
75
91
  * read-side shape guard rejected and this run therefore DROPPED from disk
@@ -88,9 +104,10 @@
88
104
  * @property {string} [error] - present only when the never-throws top-level guard fired.
89
105
  */
90
106
 
91
- import { readFileSync } from 'node:fs';
107
+ import { readFileSync, readdirSync } from 'node:fs';
92
108
  import { isAbsolute, join } from 'node:path';
93
109
 
110
+ import { expandTilde } from '../common.mjs';
94
111
  import { learningKeyOf } from '../learnings/kebab.mjs';
95
112
  import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
96
113
  import { filterEligible } from './eligibility.mjs';
@@ -99,7 +116,9 @@ import { renderRule } from './renderer.mjs';
99
116
  import {
100
117
  DEFAULT_STORE_PATH,
101
118
  buildCandidate,
119
+ isProcessed,
102
120
  makeCandidateId,
121
+ loadCandidates as realLoadCandidates,
103
122
  mergeCandidates as realMergeCandidates,
104
123
  } from './idempotency.mjs';
105
124
 
@@ -130,6 +149,7 @@ function zeroedResult(error) {
130
149
  proposed: 0,
131
150
  rejected: 0,
132
151
  capped: 0,
152
+ alreadyMaterialized: 0,
133
153
  written: false,
134
154
  },
135
155
  };
@@ -238,6 +258,172 @@ function warnDroppedStoreRecords(skipped) {
238
258
  }
239
259
  }
240
260
 
261
+ /**
262
+ * Session Config placeholder convention: a path key whose committed value is a
263
+ * marker that MUST be overridden host-locally (this repo ships
264
+ * `plan-baseline-path: OVERRIDE-IN-owner.yaml` in CLAUDE.md — or AGENTS.md on
265
+ * Codex CLI — § Session Config).
266
+ * Treating the marker as a real path would create `./OVERRIDE-IN-owner.yaml/`.
267
+ */
268
+ const PLACEHOLDER_PATH_RE = /^OVERRIDE-IN-/;
269
+
270
+ /**
271
+ * Report that the `baseline` write-target was dropped for this run.
272
+ *
273
+ * stderr, never stdout — `scripts/lib/config.mjs` consumers parse stdout as JSON.
274
+ * Same posture as {@link warnDroppedStoreRecords}: an attributable drop must be
275
+ * VISIBLE, and a diagnostic must never become the failure it reports on.
276
+ *
277
+ * @param {string} reason
278
+ */
279
+ function warnBaselineTargetDropped(reason) {
280
+ try {
281
+ console.warn(
282
+ `⚠️ reconcile: target "baseline" DROPPED for this run — ${reason}. ` +
283
+ `Resolution order is SO_BASELINE_PATH env > owner.yaml paths.baseline-path > ` +
284
+ `committed plan-baseline-path. No baseline proposal is surfaced in the approval ` +
285
+ `AUQ and nothing is written outside this repo; every other target is unaffected.`,
286
+ );
287
+ } catch {
288
+ // A diagnostic must never become the failure it reports on.
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Decide which reconcile targets can ACTUALLY be written this run (issue #1099).
294
+ *
295
+ * Two of the three no-op checks live here, upstream of both the approval AUQ and
296
+ * the writer, because the important half is not the refused write — it is that
297
+ * **the operator must never be asked to approve a write to a destination that
298
+ * cannot exist.** A `baseline` target whose root is unresolvable, is still the
299
+ * committed placeholder, or is not absolute is therefore dropped from the
300
+ * effective list BEFORE proposals are surfaced. The third check (the root does
301
+ * not exist on disk) belongs to `writer.mjs`, the only layer holding the
302
+ * filesystem at write time.
303
+ *
304
+ * Pure and never-throws: it resolves nothing from disk and reads no env — the
305
+ * caller passes the already-resolved `baselineRoot` (`config['plan-baseline-path']`,
306
+ * which `scripts/lib/config.mjs` has already run through the full 3-tier chain).
307
+ *
308
+ * @param {{targets?: unknown, baselineRoot?: unknown}} [opts]
309
+ * @returns {{targets: string[], baselineRoot: string|null, dropped: string[], reason: string|null}}
310
+ * `targets` — the effective list; `baselineRoot` — the ~-expanded root, or null
311
+ * when `baseline` is not in play; `dropped`/`reason` — the audit trail.
312
+ */
313
+ export function resolveEffectiveTargets({ targets, baselineRoot } = {}) {
314
+ const declared = Array.isArray(targets) ? targets.filter((t) => typeof t === 'string' && t.length > 0) : [];
315
+ const list = [...new Set(declared.length > 0 ? declared : ['repo-local'])];
316
+
317
+ if (!list.includes('baseline')) {
318
+ return { targets: list, baselineRoot: null, dropped: [], reason: null };
319
+ }
320
+
321
+ const raw = typeof baselineRoot === 'string' ? baselineRoot.trim() : '';
322
+ const expanded = raw === '' ? '' : expandTilde(raw);
323
+
324
+ let reason = null;
325
+ if (raw === '') {
326
+ reason = 'no baseline path is configured on any tier';
327
+ } else if (PLACEHOLDER_PATH_RE.test(raw)) {
328
+ reason = `the committed placeholder "${raw}" was never overridden host-locally`;
329
+ } else if (!isAbsolute(expanded)) {
330
+ reason = `"${raw}" is not an absolute path after ~-expansion`;
331
+ }
332
+
333
+ if (reason === null) {
334
+ return { targets: list, baselineRoot: expanded, dropped: [], reason: null };
335
+ }
336
+
337
+ warnBaselineTargetDropped(reason);
338
+ return {
339
+ targets: list.filter((t) => t !== 'baseline'),
340
+ baselineRoot: null,
341
+ dropped: ['baseline'],
342
+ reason,
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Default sidecar-candidate loader for the issue #484 idempotency dedupe
348
+ * check (below, step 3a). Deliberately gated on `repoRoot` being a
349
+ * caller-supplied, non-empty string — UNLIKE `defaultLoadLearnings` and
350
+ * `realMergeCandidates`, this does NOT fall back to `process.cwd()` when
351
+ * `repoRoot` is absent. Every existing engine test exercises this module via
352
+ * `opts.learnings` with no `repoRoot`, precisely to avoid touching this repo's
353
+ * OWN `.orchestrator/runtime/reconcile-candidates.jsonl`; a cwd fallback here
354
+ * would silently read it. The disk-touching default is reserved for callers
355
+ * that always pass an explicit `repoRoot` (the `/reconcile` skill resolves it
356
+ * via `git rev-parse --show-toplevel`).
357
+ * @param {string|undefined} repoRoot
358
+ * @returns {{ records: import('./idempotency.mjs').ReconcileCandidate[] }}
359
+ */
360
+ function defaultLoadCandidatesForDedupe(repoRoot) {
361
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { records: [] };
362
+ const { records } = realLoadCandidates({ repoRoot });
363
+ return { records };
364
+ }
365
+
366
+ /** Frontmatter form emitted by renderer.mjs: `learning-key: <value>` (no backticks, no leading dash). */
367
+ const FRONTMATTER_LEARNING_KEY_RE = /^learning-key:\s*(.+)$/gm;
368
+ /** Provenance-body form emitted by renderer.mjs: `` - learning-key: `<value>` ``. */
369
+ const BODY_LEARNING_KEY_RE = /-\s*learning-key:\s*`([^`]+)`/g;
370
+ /** Provenance-body form emitted by renderer.mjs: `` - learning-id: `<value>` ``. */
371
+ const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
372
+
373
+ /**
374
+ * Scan `<repoRoot>/.claude/rules/*.md` for the provenance markers the
375
+ * renderer stamps on every machine-generated rule — the frontmatter
376
+ * `learning-key:` line and the body `## Provenance` block's `learning-key`/
377
+ * `learning-id` bullets (`renderer.mjs`) — and return the two identity sets a
378
+ * learning can already be materialized under. A learning whose derived
379
+ * `learning_key` OR raw `.id` appears in either set already has a rule file
380
+ * on disk: re-proposing it is the issue #484 defect (9 of 10 proposals in one
381
+ * run were learnings a `.claude/rules/` file already covered).
382
+ *
383
+ * Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
384
+ * `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
385
+ * Never throws — a missing `.claude/rules/` dir or an unreadable file
386
+ * degrades to "nothing materialized" for that source, never a crash.
387
+ * @param {string|undefined} repoRoot
388
+ * @returns {{ keys: Set<string>, ids: Set<string> }}
389
+ */
390
+ function defaultReadMaterializedProvenance(repoRoot) {
391
+ const keys = new Set();
392
+ const ids = new Set();
393
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { keys, ids };
394
+
395
+ const rulesDir = join(repoRoot, '.claude', 'rules');
396
+ let entries;
397
+ try {
398
+ entries = readdirSync(rulesDir);
399
+ } catch {
400
+ return { keys, ids }; // no rules dir yet → nothing materialized
401
+ }
402
+
403
+ for (const entry of entries) {
404
+ if (!entry.endsWith('.md')) continue;
405
+ let content;
406
+ try {
407
+ content = readFileSync(join(rulesDir, entry), 'utf8');
408
+ } catch {
409
+ continue; // unreadable file — skip it, do not fail the whole scan
410
+ }
411
+ for (const m of content.matchAll(FRONTMATTER_LEARNING_KEY_RE)) {
412
+ const v = m[1].trim();
413
+ if (v) keys.add(v);
414
+ }
415
+ for (const m of content.matchAll(BODY_LEARNING_KEY_RE)) {
416
+ const v = m[1].trim();
417
+ if (v) keys.add(v);
418
+ }
419
+ for (const m of content.matchAll(BODY_LEARNING_ID_RE)) {
420
+ const v = m[1].trim();
421
+ if (v && v !== 'n/a') ids.add(v);
422
+ }
423
+ }
424
+ return { keys, ids };
425
+ }
426
+
241
427
  /**
242
428
  * Run the reconciliation engine.
243
429
  *
@@ -273,10 +459,17 @@ function warnDroppedStoreRecords(skipped) {
273
459
  * direct learnings injection (takes precedence over `loadLearnings`).
274
460
  * @param {typeof realMergeCandidates} [opts.merge]
275
461
  * override the sidecar merge (so tests never touch real `.orchestrator/runtime/`).
462
+ * @param {(repoRoot?: string) => { records: import('./idempotency.mjs').ReconcileCandidate[] }} [opts.loadCandidates]
463
+ * override the idempotency-sidecar read used by the issue #484 dedupe step (below,
464
+ * step 3a) — defaults to {@link defaultLoadCandidatesForDedupe} (repoRoot-gated, no
465
+ * cwd fallback; see that function's doc for why).
466
+ * @param {(repoRoot?: string) => { keys: Set<string>, ids: Set<string> }} [opts.readMaterializedProvenance]
467
+ * override the `.claude/rules/` provenance scan used by the same dedupe step —
468
+ * defaults to {@link defaultReadMaterializedProvenance} (same repoRoot gate).
276
469
  * @param {boolean} [opts.dryRun] - when true, compute proposals but SKIP the merge entirely.
277
470
  * @returns {Promise<ReconcileResult>}
278
471
  */
279
- export async function runReconcile(
472
+ async function runReconcileInner(
280
473
  {
281
474
  repoRoot,
282
475
  ruleExpiryDays,
@@ -294,6 +487,12 @@ export async function runReconcile(
294
487
  // (the documented DI seam) — either location flips it on.
295
488
  const dryRun = dryRunParam === true || opts.dryRun === true;
296
489
  const merge = typeof opts.merge === 'function' ? opts.merge : realMergeCandidates;
490
+ const loadCandidatesForDedupe =
491
+ typeof opts.loadCandidates === 'function' ? opts.loadCandidates : defaultLoadCandidatesForDedupe;
492
+ const readMaterializedProvenance =
493
+ typeof opts.readMaterializedProvenance === 'function'
494
+ ? opts.readMaterializedProvenance
495
+ : defaultReadMaterializedProvenance;
297
496
  // Volume brake (#900 D) — always active; a missing/invalid override falls
298
497
  // back to the same default the Session Config parser uses.
299
498
  const maxProposalsPerRun =
@@ -331,20 +530,8 @@ export async function runReconcile(
331
530
  minInsightChars,
332
531
  });
333
532
 
334
- // --- Pipeline step 3b — volume brake (#900 D) ---------------------------
335
- // Sort eligible learnings by confidence DESC (ties keep their original,
336
- // stable relative order) and keep only the top `maxProposalsPerRun`. The
337
- // rest are cut BEFORE they ever reach the emitter — never proposed this
338
- // run — and recorded as `capped` rejections in step 4b below so a report
339
- // stays honest about the cut instead of silently dropping them.
340
533
  const confidenceOf = (l) =>
341
534
  l && typeof l === 'object' && typeof l.confidence === 'number' ? l.confidence : 0;
342
- const sortedEligible = eligible
343
- .map((learning, index) => ({ learning, index }))
344
- .sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
345
- .map(({ learning }) => learning);
346
- const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
347
- const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
348
535
 
349
536
  /** @type {ReconcileProposal[]} */
350
537
  const proposals = [];
@@ -353,6 +540,92 @@ export async function runReconcile(
353
540
  /** @type {import('./idempotency.mjs').ReconcileCandidate[]} */
354
541
  const candidates = [];
355
542
 
543
+ // --- Pipeline step 3a — idempotency + on-disk dedupe (issue #484) ------
544
+ // Runs BEFORE the volume brake (step 3b) so an already-materialized
545
+ // learning does not consume `maxProposalsPerRun` quota that a genuinely
546
+ // new learning could use — the #484 defect measured on a real repo was
547
+ // exactly this: 9 of 10 proposals in one run were learnings that already
548
+ // had a `.claude/rules/` file on disk, crowding out the tenth new one.
549
+ // Two independent sources both count as terminal, either is sufficient:
550
+ // - the idempotency sidecar already carries a `processed_at` stamp for
551
+ // this `learning_key` (`isProcessed`, previously computed but NEVER
552
+ // called from this module — the other half of #484);
553
+ // - a `.claude/rules/*.md` file already carries a matching
554
+ // `learning-key`/`learning-id` provenance marker, discovered by
555
+ // scanning disk directly (covers the case where a rule was written
556
+ // without ever going through this sidecar, e.g. hand-authored).
557
+ const { records: existingCandidates } = loadCandidatesForDedupe(repoRoot) ?? { records: [] };
558
+ const materialized = readMaterializedProvenance(repoRoot) ?? { keys: new Set(), ids: new Set() };
559
+
560
+ /** @type {Array<Record<string, unknown>>} */
561
+ const stillEligible = [];
562
+ let alreadyMaterialized = 0;
563
+
564
+ for (const learning of eligible) {
565
+ const learningKey = rejectedLearningKey(learning);
566
+ const learningId =
567
+ learning &&
568
+ typeof learning === 'object' &&
569
+ typeof learning.id === 'string' &&
570
+ learning.id.length > 0
571
+ ? learning.id
572
+ : null;
573
+
574
+ const sidecarTerminal =
575
+ learningKey !== null && isProcessed({ learning_key: learningKey }, existingCandidates);
576
+ const onDisk =
577
+ (learningKey !== null && materialized.keys.has(learningKey)) ||
578
+ (learningId !== null && materialized.ids.has(learningId));
579
+
580
+ if (!sidecarTerminal && !onDisk) {
581
+ stillEligible.push(learning);
582
+ continue;
583
+ }
584
+
585
+ alreadyMaterialized += 1;
586
+ const type = learningType(learning);
587
+ const reason = sidecarTerminal
588
+ ? 'already processed — the idempotency sidecar already carries a terminal verdict for this learning-key'
589
+ : 'already materialized — a .claude/rules/ file already carries this learning-key/learning-id';
590
+ rejected.push({ learningKey, type, reason, status: 'rejected' });
591
+
592
+ // Freshly discovered on-disk materialization (not yet reflected in the
593
+ // sidecar): stamp it terminal now so a FUTURE run's `isProcessed()`
594
+ // check catches it without re-scanning `.claude/rules/` every time.
595
+ // Routed through the SAME `candidates` array + `merge()` call as every
596
+ // other record produced this run (step 6 below), so — like everything
597
+ // else here — it is skipped entirely under `dryRun`; this is not a
598
+ // second write path.
599
+ if (onDisk && !sidecarTerminal && learningKey !== null) {
600
+ const stamped = buildCandidate({
601
+ id: makeCandidateId(learningKey, `materialized-${type}`),
602
+ learningKey,
603
+ slug: '',
604
+ status: 'proposed',
605
+ reason,
606
+ confidence: confidenceOf(learning),
607
+ createdAt,
608
+ });
609
+ stamped.processed_at = createdAt;
610
+ stamped.outcome = 'already-on-disk';
611
+ candidates.push(stamped);
612
+ }
613
+ }
614
+
615
+ // --- Pipeline step 3b — volume brake (#900 D) ---------------------------
616
+ // Sort STILL-eligible learnings (post-dedupe) by confidence DESC (ties
617
+ // keep their original, stable relative order) and keep only the top
618
+ // `maxProposalsPerRun`. The rest are cut BEFORE they ever reach the
619
+ // emitter — never proposed this run — and recorded as `capped`
620
+ // rejections in step 4b below so a report stays honest about the cut
621
+ // instead of silently dropping them.
622
+ const sortedEligible = stillEligible
623
+ .map((learning, index) => ({ learning, index }))
624
+ .sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
625
+ .map(({ learning }) => learning);
626
+ const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
627
+ const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
628
+
356
629
  // --- Pipeline step 4 — per eligible learning (wrapped per-item) ---------
357
630
  for (const learning of keptEligible) {
358
631
  try {
@@ -499,6 +772,7 @@ export async function runReconcile(
499
772
  proposed: proposals.length,
500
773
  rejected: rejected.length,
501
774
  capped: cappedEligible.length,
775
+ alreadyMaterialized,
502
776
  written,
503
777
  };
504
778
  // Additive + absence-preserving: the key exists ONLY when the store was
@@ -512,3 +786,228 @@ export async function runReconcile(
512
786
  return zeroedResult(msg);
513
787
  }
514
788
  }
789
+
790
+ /**
791
+ * Ledger name of the reconcile run event (issue #1192). Catalogued in
792
+ * `docs/events-schema.md`; `events-schema.mjs` needs no registration — it
793
+ * validates the NAME shape only, and this name already satisfies it.
794
+ */
795
+ export const RECONCILE_EVENT = 'orchestrator.reconcile.completed';
796
+
797
+ /**
798
+ * Closed enum of `trigger` values a caller may explicitly pass to
799
+ * {@link runReconcile} (issue #1201 Part A / Discovery D8). Two of the three
800
+ * real call sites pinned `trigger` by PROSE inside a `.md` skill file —
801
+ * `skill` (`skills/reconcile/SKILL.md`) and `session-end`
802
+ * (`skills/session-end/phase-3-6-tail.md`) — where nothing enforced the
803
+ * string ever matched this list; only `phase-skip`
804
+ * (`scripts/lib/session-end/phase-skip.mjs`) pinned it in code. The two `.md`
805
+ * callers are migrated onto {@link runReconcileFromSkill} /
806
+ * {@link runReconcileAtSessionEnd} in this same change, so "prose sets the
807
+ * trigger" stops being possible for them at all.
808
+ */
809
+ export const KNOWN_TRIGGERS = Object.freeze(['skill', 'session-end', 'phase-skip']);
810
+
811
+ /**
812
+ * Reject an explicitly-passed `trigger` that is not in {@link KNOWN_TRIGGERS}
813
+ * — including the literal `'unknown'`, which is a DEFAULT this module mints
814
+ * for an ABSENT trigger, never a value a caller should pass on purpose. An
815
+ * absent trigger (`undefined`) is deliberately let through unchanged: it is
816
+ * legacy behaviour for a caller that has not yet been migrated onto a pinned
817
+ * wrapper, and `buildReconcilePayload` already defaults it to `'unknown'` so
818
+ * the per-trigger denominator in the ledger stays honest about which runs are
819
+ * unattributed rather than silently breaking them.
820
+ *
821
+ * BV-004 revisit trigger: `scripts/lib/session-end/phase-skip.mjs` is the one
822
+ * remaining caller passing `trigger: 'phase-skip'` as a hand-written literal
823
+ * rather than through {@link runReconcileFromPhaseSkip} (out of this task's
824
+ * file scope) — once every caller is migrated onto a pinned wrapper, flip the
825
+ * absent-trigger default below from "let it run" to a throw, closing the last
826
+ * legacy path.
827
+ *
828
+ * @param {unknown} trigger
829
+ * @throws {Error} when `trigger` is defined but not in `KNOWN_TRIGGERS`.
830
+ * @returns {void}
831
+ */
832
+ export function assertKnownTrigger(trigger) {
833
+ if (trigger === undefined) return;
834
+ if (!KNOWN_TRIGGERS.includes(trigger)) {
835
+ throw new Error(
836
+ `runReconcile: invalid trigger ${JSON.stringify(trigger)} — must be one of: ` +
837
+ `${KNOWN_TRIGGERS.join(', ')} (or omitted entirely, for a not-yet-migrated caller).`,
838
+ );
839
+ }
840
+ }
841
+
842
+ /** Clamp for the `reason` string on the abort path — a message can be long. */
843
+ const REASON_MAX_CHARS = 300;
844
+
845
+ /** The closed target enum `resolveEffectiveTargets` recognises. */
846
+ const KNOWN_TARGETS = ['repo-local', 'baseline'];
847
+
848
+ /**
849
+ * Build the `orchestrator.reconcile.completed` payload from a finished run.
850
+ *
851
+ * Counter fields are written INCLUDING `0`: each was MEASURED over the whole
852
+ * run, so a written zero is the payload (same contract as the vault-mirror run
853
+ * event). The two absence-preserving exceptions are `store_records_dropped`
854
+ * (absent ⇒ the candidate store was never inspected — dryRun, empty
855
+ * short-circuit, error path) and `targets` (absent ⇒ the caller asserted no
856
+ * target list). `aborted`/`reason` appear only when the never-throws guard
857
+ * fired; their absence means "ran to the end", never "unknown".
858
+ *
859
+ * @param {ReconcileResult} result
860
+ * @param {{ trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
861
+ * @returns {Record<string, unknown>}
862
+ */
863
+ function buildReconcilePayload(result, ctx) {
864
+ const summary = (result && result.summary) || {};
865
+ /** @type {Record<string, unknown>} */
866
+ const payload = {
867
+ trigger: typeof ctx.trigger === 'string' && ctx.trigger.trim() !== '' ? ctx.trigger : 'unknown',
868
+ dry_run: ctx.dryRun === true,
869
+ learnings_total: summary.totalLearnings ?? 0,
870
+ eligible: summary.eligible ?? 0,
871
+ proposals: summary.proposed ?? 0,
872
+ rejected: summary.rejected ?? 0,
873
+ capped: summary.capped ?? 0,
874
+ already_materialized: summary.alreadyMaterialized ?? 0,
875
+ written: summary.written === true,
876
+ duration_ms: ctx.durationMs,
877
+ };
878
+ // `targets` originates in operator-authored Session Config (`reconcile.targets`)
879
+ // and is unbounded there. Allowlisted to the CLOSED enum `resolveEffectiveTargets`
880
+ // recognises before it enters the ledger and the optional Clank webhook (Q2-F4):
881
+ // anything else is not a target this engine can act on, so recording it would
882
+ // be a verbatim echo of untrusted text, never a measurement. Omitted when empty.
883
+ const targets = Array.isArray(ctx.targets)
884
+ ? [...new Set(ctx.targets.filter((t) => KNOWN_TARGETS.includes(t)))]
885
+ : [];
886
+ if (targets.length > 0) payload.targets = targets;
887
+ if (typeof summary.skipped === 'number') payload.store_records_dropped = summary.skipped;
888
+ if (typeof result?.error === 'string' && result.error !== '') {
889
+ payload.aborted = 'engine-error';
890
+ payload.reason = result.error.slice(0, REASON_MAX_CHARS);
891
+ }
892
+ return payload;
893
+ }
894
+
895
+ /**
896
+ * Record one reconcile run in the repo's event ledger — best-effort.
897
+ *
898
+ * Refuses the ambient `SO_PROJECT_DIR` destination when no `repoRoot` was
899
+ * given: most engine tests call `runReconcile` without one, and a fallback
900
+ * would append synthetic records to the operator's REAL fleet ledger on every
901
+ * `npm test` (#1119, `scripts/lib/express-path.mjs`). Diagnostics on stderr.
902
+ *
903
+ * @param {ReconcileResult} result
904
+ * @param {{ repoRoot?: string, trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
905
+ */
906
+ async function emitReconcileCompleted(result, ctx) {
907
+ const { repoRoot } = ctx;
908
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
909
+ process.stderr.write(
910
+ `reconcile: skipped ${RECONCILE_EVENT} — no repoRoot given; ` +
911
+ 'refusing the ambient SO_PROJECT_DIR destination (#1119).\n',
912
+ );
913
+ return;
914
+ }
915
+ const { emitEvent } = await import('../events.mjs');
916
+ await emitEvent(RECONCILE_EVENT, buildReconcilePayload(result, ctx), { repoRoot });
917
+ }
918
+
919
+ /**
920
+ * Public boundary: run the reconciliation pipeline and record the run.
921
+ *
922
+ * A thin WRAPPER, deliberately: the pipeline has three return points (empty
923
+ * short-circuit, normal tail, never-throws catch), and an inline emit would
924
+ * miss two of them — including the empty corpus and the error path, the two
925
+ * runs an operator most needs recorded (`.claude/rules/host-resources.md`
926
+ * § HR-105). Same shape as `runNarrativeMirror` + `mirrorNarrative` in
927
+ * `scripts/lib/vault-status/narrative-mirror.mjs`.
928
+ *
929
+ * The emit is wrapped in try/catch because `emitEvent` THROWS
930
+ * `EventValidationError` on an invalid record — without the catch, telemetry
931
+ * would break this function's never-throws contract. The pipeline's result is
932
+ * returned UNTOUCHED whether or not the ledger accepted the record.
933
+ *
934
+ * @param {Object} [params] - see {@link runReconcileInner}, plus:
935
+ * @param {'skill'|'session-end'|'phase-skip'} [params.trigger] - which caller
936
+ * invoked this run; recorded ALWAYS (default `'unknown'` when omitted) so
937
+ * the per-trigger denominator is complete. Not read by the pipeline.
938
+ * VALIDATED against {@link KNOWN_TRIGGERS} via {@link assertKnownTrigger}
939
+ * when explicitly passed — an unknown string, including the literal
940
+ * `'unknown'`, THROWS synchronously; an absent trigger is still let
941
+ * through unchanged (issue #1201 Part A — see `assertKnownTrigger`'s
942
+ * BV-004 revisit-trigger note for why the absent case is not also a
943
+ * throw yet). Prefer {@link runReconcileFromSkill},
944
+ * {@link runReconcileAtSessionEnd}, or {@link runReconcileFromPhaseSkip}
945
+ * over passing `trigger` here directly.
946
+ * @param {string[]} [params.targets] - the caller's effective target list
947
+ * (`resolveEffectiveTargets`); recorded when non-empty, omitted otherwise.
948
+ * Not read by the pipeline.
949
+ * @param {Object} [opts] - see {@link runReconcileInner}.
950
+ * @returns {Promise<ReconcileResult>}
951
+ */
952
+ export async function runReconcile(params = {}, opts = {}) {
953
+ assertKnownTrigger(params.trigger);
954
+ const t0 = Date.now();
955
+ const result = await runReconcileInner(params, opts);
956
+ try {
957
+ await emitReconcileCompleted(result, {
958
+ repoRoot: params.repoRoot,
959
+ trigger: params.trigger,
960
+ targets: params.targets,
961
+ dryRun: params.dryRun === true || opts.dryRun === true,
962
+ durationMs: Date.now() - t0,
963
+ });
964
+ } catch {
965
+ // Best-effort telemetry — never the reason a reconcile run fails.
966
+ }
967
+ return result;
968
+ }
969
+
970
+ /**
971
+ * Pin `trigger: 'skill'` in code for the `/reconcile` skill (Phase 2.3,
972
+ * `skills/reconcile/SKILL.md`) — the skill's own `.md` prose no longer sets
973
+ * the trigger string itself (issue #1201 Part A). Any `trigger` the caller
974
+ * passes in `params` is OVERWRITTEN; every other field forwards unchanged.
975
+ *
976
+ * @param {Object} [params] - see {@link runReconcile} `params`.
977
+ * @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
978
+ * @returns {Promise<ReconcileResult>}
979
+ */
980
+ export async function runReconcileFromSkill(params = {}, opts = {}) {
981
+ return runReconcile({ ...params, trigger: 'skill' }, opts);
982
+ }
983
+
984
+ /**
985
+ * Pin `trigger: 'session-end'` in code for session-end Phase 3.6.8
986
+ * (`skills/session-end/phase-3-6-tail.md`) — see
987
+ * {@link runReconcileFromSkill} for the rationale and contract.
988
+ *
989
+ * @param {Object} [params] - see {@link runReconcile} `params`.
990
+ * @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
991
+ * @returns {Promise<ReconcileResult>}
992
+ */
993
+ export async function runReconcileAtSessionEnd(params = {}, opts = {}) {
994
+ return runReconcile({ ...params, trigger: 'session-end' }, opts);
995
+ }
996
+
997
+ /**
998
+ * Pin `trigger: 'phase-skip'` in code for the session-end skip-plan
999
+ * aggregator (`scripts/lib/session-end/phase-skip.mjs`, the highest-volume
1000
+ * probe-only caller) — see {@link runReconcileFromSkill} for the rationale
1001
+ * and contract. NOT YET wired into `phase-skip.mjs` itself, which is out of
1002
+ * this task's file scope and still passes `trigger: 'phase-skip'` as a
1003
+ * hand-written literal directly to `runReconcile`; exported here so that
1004
+ * migration is a one-line import swap (see `assertKnownTrigger`'s BV-004
1005
+ * revisit-trigger note).
1006
+ *
1007
+ * @param {Object} [params] - see {@link runReconcile} `params`.
1008
+ * @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
1009
+ * @returns {Promise<ReconcileResult>}
1010
+ */
1011
+ export async function runReconcileFromPhaseSkip(params = {}, opts = {}) {
1012
+ return runReconcile({ ...params, trigger: 'phase-skip' }, opts);
1013
+ }