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
@@ -70,6 +70,16 @@
70
70
  * `capped` is a diagnostic sub-count that lets a report distinguish
71
71
  * "genuinely ineligible" rejections from "eligible but cut by the volume
72
72
  * brake" ones at a glance.
73
+ * @property {number} alreadyMaterialized - count of eligible learnings that were
74
+ * NOT proposed this run because they are already terminal — either the
75
+ * idempotency sidecar already carries a `processed_at` stamp for their
76
+ * `learning_key` (`isProcessed`), or a `.claude/rules/*.md` file already
77
+ * carries a matching `learning-key`/`learning-id` provenance marker
78
+ * (issue #484: 9 of 10 proposals in one run were exactly this). Runs
79
+ * BEFORE the `maxProposalsPerRun` volume brake so an already-materialized
80
+ * learning never consumes a new learning's quota. Same accounting pattern
81
+ * as `capped`: each one is ALSO counted inside `rejected`, and
82
+ * `totalLearnings === proposed + rejected` still holds unchanged.
73
83
  * @property {boolean} written
74
84
  * @property {number} [skipped] - how many persisted sidecar lines the store's
75
85
  * read-side shape guard rejected and this run therefore DROPPED from disk
@@ -88,9 +98,10 @@
88
98
  * @property {string} [error] - present only when the never-throws top-level guard fired.
89
99
  */
90
100
 
91
- import { readFileSync } from 'node:fs';
101
+ import { readFileSync, readdirSync } from 'node:fs';
92
102
  import { isAbsolute, join } from 'node:path';
93
103
 
104
+ import { expandTilde } from '../common.mjs';
94
105
  import { learningKeyOf } from '../learnings/kebab.mjs';
95
106
  import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
96
107
  import { filterEligible } from './eligibility.mjs';
@@ -99,7 +110,9 @@ import { renderRule } from './renderer.mjs';
99
110
  import {
100
111
  DEFAULT_STORE_PATH,
101
112
  buildCandidate,
113
+ isProcessed,
102
114
  makeCandidateId,
115
+ loadCandidates as realLoadCandidates,
103
116
  mergeCandidates as realMergeCandidates,
104
117
  } from './idempotency.mjs';
105
118
 
@@ -130,6 +143,7 @@ function zeroedResult(error) {
130
143
  proposed: 0,
131
144
  rejected: 0,
132
145
  capped: 0,
146
+ alreadyMaterialized: 0,
133
147
  written: false,
134
148
  },
135
149
  };
@@ -238,6 +252,172 @@ function warnDroppedStoreRecords(skipped) {
238
252
  }
239
253
  }
240
254
 
255
+ /**
256
+ * Session Config placeholder convention: a path key whose committed value is a
257
+ * marker that MUST be overridden host-locally (this repo ships
258
+ * `plan-baseline-path: OVERRIDE-IN-owner.yaml` in CLAUDE.md — or AGENTS.md on
259
+ * Codex CLI — § Session Config).
260
+ * Treating the marker as a real path would create `./OVERRIDE-IN-owner.yaml/`.
261
+ */
262
+ const PLACEHOLDER_PATH_RE = /^OVERRIDE-IN-/;
263
+
264
+ /**
265
+ * Report that the `baseline` write-target was dropped for this run.
266
+ *
267
+ * stderr, never stdout — `scripts/lib/config.mjs` consumers parse stdout as JSON.
268
+ * Same posture as {@link warnDroppedStoreRecords}: an attributable drop must be
269
+ * VISIBLE, and a diagnostic must never become the failure it reports on.
270
+ *
271
+ * @param {string} reason
272
+ */
273
+ function warnBaselineTargetDropped(reason) {
274
+ try {
275
+ console.warn(
276
+ `⚠️ reconcile: target "baseline" DROPPED for this run — ${reason}. ` +
277
+ `Resolution order is SO_BASELINE_PATH env > owner.yaml paths.baseline-path > ` +
278
+ `committed plan-baseline-path. No baseline proposal is surfaced in the approval ` +
279
+ `AUQ and nothing is written outside this repo; every other target is unaffected.`,
280
+ );
281
+ } catch {
282
+ // A diagnostic must never become the failure it reports on.
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Decide which reconcile targets can ACTUALLY be written this run (issue #1099).
288
+ *
289
+ * Two of the three no-op checks live here, upstream of both the approval AUQ and
290
+ * the writer, because the important half is not the refused write — it is that
291
+ * **the operator must never be asked to approve a write to a destination that
292
+ * cannot exist.** A `baseline` target whose root is unresolvable, is still the
293
+ * committed placeholder, or is not absolute is therefore dropped from the
294
+ * effective list BEFORE proposals are surfaced. The third check (the root does
295
+ * not exist on disk) belongs to `writer.mjs`, the only layer holding the
296
+ * filesystem at write time.
297
+ *
298
+ * Pure and never-throws: it resolves nothing from disk and reads no env — the
299
+ * caller passes the already-resolved `baselineRoot` (`config['plan-baseline-path']`,
300
+ * which `scripts/lib/config.mjs` has already run through the full 3-tier chain).
301
+ *
302
+ * @param {{targets?: unknown, baselineRoot?: unknown}} [opts]
303
+ * @returns {{targets: string[], baselineRoot: string|null, dropped: string[], reason: string|null}}
304
+ * `targets` — the effective list; `baselineRoot` — the ~-expanded root, or null
305
+ * when `baseline` is not in play; `dropped`/`reason` — the audit trail.
306
+ */
307
+ export function resolveEffectiveTargets({ targets, baselineRoot } = {}) {
308
+ const declared = Array.isArray(targets) ? targets.filter((t) => typeof t === 'string' && t.length > 0) : [];
309
+ const list = [...new Set(declared.length > 0 ? declared : ['repo-local'])];
310
+
311
+ if (!list.includes('baseline')) {
312
+ return { targets: list, baselineRoot: null, dropped: [], reason: null };
313
+ }
314
+
315
+ const raw = typeof baselineRoot === 'string' ? baselineRoot.trim() : '';
316
+ const expanded = raw === '' ? '' : expandTilde(raw);
317
+
318
+ let reason = null;
319
+ if (raw === '') {
320
+ reason = 'no baseline path is configured on any tier';
321
+ } else if (PLACEHOLDER_PATH_RE.test(raw)) {
322
+ reason = `the committed placeholder "${raw}" was never overridden host-locally`;
323
+ } else if (!isAbsolute(expanded)) {
324
+ reason = `"${raw}" is not an absolute path after ~-expansion`;
325
+ }
326
+
327
+ if (reason === null) {
328
+ return { targets: list, baselineRoot: expanded, dropped: [], reason: null };
329
+ }
330
+
331
+ warnBaselineTargetDropped(reason);
332
+ return {
333
+ targets: list.filter((t) => t !== 'baseline'),
334
+ baselineRoot: null,
335
+ dropped: ['baseline'],
336
+ reason,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Default sidecar-candidate loader for the issue #484 idempotency dedupe
342
+ * check (below, step 3a). Deliberately gated on `repoRoot` being a
343
+ * caller-supplied, non-empty string — UNLIKE `defaultLoadLearnings` and
344
+ * `realMergeCandidates`, this does NOT fall back to `process.cwd()` when
345
+ * `repoRoot` is absent. Every existing engine test exercises this module via
346
+ * `opts.learnings` with no `repoRoot`, precisely to avoid touching this repo's
347
+ * OWN `.orchestrator/runtime/reconcile-candidates.jsonl`; a cwd fallback here
348
+ * would silently read it. The disk-touching default is reserved for callers
349
+ * that always pass an explicit `repoRoot` (the `/reconcile` skill resolves it
350
+ * via `git rev-parse --show-toplevel`).
351
+ * @param {string|undefined} repoRoot
352
+ * @returns {{ records: import('./idempotency.mjs').ReconcileCandidate[] }}
353
+ */
354
+ function defaultLoadCandidatesForDedupe(repoRoot) {
355
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { records: [] };
356
+ const { records } = realLoadCandidates({ repoRoot });
357
+ return { records };
358
+ }
359
+
360
+ /** Frontmatter form emitted by renderer.mjs: `learning-key: <value>` (no backticks, no leading dash). */
361
+ const FRONTMATTER_LEARNING_KEY_RE = /^learning-key:\s*(.+)$/gm;
362
+ /** Provenance-body form emitted by renderer.mjs: `` - learning-key: `<value>` ``. */
363
+ const BODY_LEARNING_KEY_RE = /-\s*learning-key:\s*`([^`]+)`/g;
364
+ /** Provenance-body form emitted by renderer.mjs: `` - learning-id: `<value>` ``. */
365
+ const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
366
+
367
+ /**
368
+ * Scan `<repoRoot>/.claude/rules/*.md` for the provenance markers the
369
+ * renderer stamps on every machine-generated rule — the frontmatter
370
+ * `learning-key:` line and the body `## Provenance` block's `learning-key`/
371
+ * `learning-id` bullets (`renderer.mjs`) — and return the two identity sets a
372
+ * learning can already be materialized under. A learning whose derived
373
+ * `learning_key` OR raw `.id` appears in either set already has a rule file
374
+ * on disk: re-proposing it is the issue #484 defect (9 of 10 proposals in one
375
+ * run were learnings a `.claude/rules/` file already covered).
376
+ *
377
+ * Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
378
+ * `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
379
+ * Never throws — a missing `.claude/rules/` dir or an unreadable file
380
+ * degrades to "nothing materialized" for that source, never a crash.
381
+ * @param {string|undefined} repoRoot
382
+ * @returns {{ keys: Set<string>, ids: Set<string> }}
383
+ */
384
+ function defaultReadMaterializedProvenance(repoRoot) {
385
+ const keys = new Set();
386
+ const ids = new Set();
387
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { keys, ids };
388
+
389
+ const rulesDir = join(repoRoot, '.claude', 'rules');
390
+ let entries;
391
+ try {
392
+ entries = readdirSync(rulesDir);
393
+ } catch {
394
+ return { keys, ids }; // no rules dir yet → nothing materialized
395
+ }
396
+
397
+ for (const entry of entries) {
398
+ if (!entry.endsWith('.md')) continue;
399
+ let content;
400
+ try {
401
+ content = readFileSync(join(rulesDir, entry), 'utf8');
402
+ } catch {
403
+ continue; // unreadable file — skip it, do not fail the whole scan
404
+ }
405
+ for (const m of content.matchAll(FRONTMATTER_LEARNING_KEY_RE)) {
406
+ const v = m[1].trim();
407
+ if (v) keys.add(v);
408
+ }
409
+ for (const m of content.matchAll(BODY_LEARNING_KEY_RE)) {
410
+ const v = m[1].trim();
411
+ if (v) keys.add(v);
412
+ }
413
+ for (const m of content.matchAll(BODY_LEARNING_ID_RE)) {
414
+ const v = m[1].trim();
415
+ if (v && v !== 'n/a') ids.add(v);
416
+ }
417
+ }
418
+ return { keys, ids };
419
+ }
420
+
241
421
  /**
242
422
  * Run the reconciliation engine.
243
423
  *
@@ -273,6 +453,13 @@ function warnDroppedStoreRecords(skipped) {
273
453
  * direct learnings injection (takes precedence over `loadLearnings`).
274
454
  * @param {typeof realMergeCandidates} [opts.merge]
275
455
  * override the sidecar merge (so tests never touch real `.orchestrator/runtime/`).
456
+ * @param {(repoRoot?: string) => { records: import('./idempotency.mjs').ReconcileCandidate[] }} [opts.loadCandidates]
457
+ * override the idempotency-sidecar read used by the issue #484 dedupe step (below,
458
+ * step 3a) — defaults to {@link defaultLoadCandidatesForDedupe} (repoRoot-gated, no
459
+ * cwd fallback; see that function's doc for why).
460
+ * @param {(repoRoot?: string) => { keys: Set<string>, ids: Set<string> }} [opts.readMaterializedProvenance]
461
+ * override the `.claude/rules/` provenance scan used by the same dedupe step —
462
+ * defaults to {@link defaultReadMaterializedProvenance} (same repoRoot gate).
276
463
  * @param {boolean} [opts.dryRun] - when true, compute proposals but SKIP the merge entirely.
277
464
  * @returns {Promise<ReconcileResult>}
278
465
  */
@@ -294,6 +481,12 @@ export async function runReconcile(
294
481
  // (the documented DI seam) — either location flips it on.
295
482
  const dryRun = dryRunParam === true || opts.dryRun === true;
296
483
  const merge = typeof opts.merge === 'function' ? opts.merge : realMergeCandidates;
484
+ const loadCandidatesForDedupe =
485
+ typeof opts.loadCandidates === 'function' ? opts.loadCandidates : defaultLoadCandidatesForDedupe;
486
+ const readMaterializedProvenance =
487
+ typeof opts.readMaterializedProvenance === 'function'
488
+ ? opts.readMaterializedProvenance
489
+ : defaultReadMaterializedProvenance;
297
490
  // Volume brake (#900 D) — always active; a missing/invalid override falls
298
491
  // back to the same default the Session Config parser uses.
299
492
  const maxProposalsPerRun =
@@ -331,20 +524,8 @@ export async function runReconcile(
331
524
  minInsightChars,
332
525
  });
333
526
 
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
527
  const confidenceOf = (l) =>
341
528
  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
529
 
349
530
  /** @type {ReconcileProposal[]} */
350
531
  const proposals = [];
@@ -353,6 +534,92 @@ export async function runReconcile(
353
534
  /** @type {import('./idempotency.mjs').ReconcileCandidate[]} */
354
535
  const candidates = [];
355
536
 
537
+ // --- Pipeline step 3a — idempotency + on-disk dedupe (issue #484) ------
538
+ // Runs BEFORE the volume brake (step 3b) so an already-materialized
539
+ // learning does not consume `maxProposalsPerRun` quota that a genuinely
540
+ // new learning could use — the #484 defect measured on a real repo was
541
+ // exactly this: 9 of 10 proposals in one run were learnings that already
542
+ // had a `.claude/rules/` file on disk, crowding out the tenth new one.
543
+ // Two independent sources both count as terminal, either is sufficient:
544
+ // - the idempotency sidecar already carries a `processed_at` stamp for
545
+ // this `learning_key` (`isProcessed`, previously computed but NEVER
546
+ // called from this module — the other half of #484);
547
+ // - a `.claude/rules/*.md` file already carries a matching
548
+ // `learning-key`/`learning-id` provenance marker, discovered by
549
+ // scanning disk directly (covers the case where a rule was written
550
+ // without ever going through this sidecar, e.g. hand-authored).
551
+ const { records: existingCandidates } = loadCandidatesForDedupe(repoRoot) ?? { records: [] };
552
+ const materialized = readMaterializedProvenance(repoRoot) ?? { keys: new Set(), ids: new Set() };
553
+
554
+ /** @type {Array<Record<string, unknown>>} */
555
+ const stillEligible = [];
556
+ let alreadyMaterialized = 0;
557
+
558
+ for (const learning of eligible) {
559
+ const learningKey = rejectedLearningKey(learning);
560
+ const learningId =
561
+ learning &&
562
+ typeof learning === 'object' &&
563
+ typeof learning.id === 'string' &&
564
+ learning.id.length > 0
565
+ ? learning.id
566
+ : null;
567
+
568
+ const sidecarTerminal =
569
+ learningKey !== null && isProcessed({ learning_key: learningKey }, existingCandidates);
570
+ const onDisk =
571
+ (learningKey !== null && materialized.keys.has(learningKey)) ||
572
+ (learningId !== null && materialized.ids.has(learningId));
573
+
574
+ if (!sidecarTerminal && !onDisk) {
575
+ stillEligible.push(learning);
576
+ continue;
577
+ }
578
+
579
+ alreadyMaterialized += 1;
580
+ const type = learningType(learning);
581
+ const reason = sidecarTerminal
582
+ ? 'already processed — the idempotency sidecar already carries a terminal verdict for this learning-key'
583
+ : 'already materialized — a .claude/rules/ file already carries this learning-key/learning-id';
584
+ rejected.push({ learningKey, type, reason, status: 'rejected' });
585
+
586
+ // Freshly discovered on-disk materialization (not yet reflected in the
587
+ // sidecar): stamp it terminal now so a FUTURE run's `isProcessed()`
588
+ // check catches it without re-scanning `.claude/rules/` every time.
589
+ // Routed through the SAME `candidates` array + `merge()` call as every
590
+ // other record produced this run (step 6 below), so — like everything
591
+ // else here — it is skipped entirely under `dryRun`; this is not a
592
+ // second write path.
593
+ if (onDisk && !sidecarTerminal && learningKey !== null) {
594
+ const stamped = buildCandidate({
595
+ id: makeCandidateId(learningKey, `materialized-${type}`),
596
+ learningKey,
597
+ slug: '',
598
+ status: 'proposed',
599
+ reason,
600
+ confidence: confidenceOf(learning),
601
+ createdAt,
602
+ });
603
+ stamped.processed_at = createdAt;
604
+ stamped.outcome = 'already-on-disk';
605
+ candidates.push(stamped);
606
+ }
607
+ }
608
+
609
+ // --- Pipeline step 3b — volume brake (#900 D) ---------------------------
610
+ // Sort STILL-eligible learnings (post-dedupe) by confidence DESC (ties
611
+ // keep their original, stable relative order) and keep only the top
612
+ // `maxProposalsPerRun`. The rest are cut BEFORE they ever reach the
613
+ // emitter — never proposed this run — and recorded as `capped`
614
+ // rejections in step 4b below so a report stays honest about the cut
615
+ // instead of silently dropping them.
616
+ const sortedEligible = stillEligible
617
+ .map((learning, index) => ({ learning, index }))
618
+ .sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
619
+ .map(({ learning }) => learning);
620
+ const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
621
+ const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
622
+
356
623
  // --- Pipeline step 4 — per eligible learning (wrapped per-item) ---------
357
624
  for (const learning of keptEligible) {
358
625
  try {
@@ -499,6 +766,7 @@ export async function runReconcile(
499
766
  proposed: proposals.length,
500
767
  rejected: rejected.length,
501
768
  capped: cappedEligible.length,
769
+ alreadyMaterialized,
502
770
  written,
503
771
  };
504
772
  // Additive + absence-preserving: the key exists ONLY when the store was
@@ -44,6 +44,17 @@
44
44
  * @property {string} created_at - ISO timestamp.
45
45
  * @property {string|null} processed_at - terminal stamp (mirrors repair store).
46
46
  * @property {string|null} superseded_by
47
+ * @property {string|null} [outcome] - terminal disposition, one of:
48
+ * `'written'` (writer.mjs persisted the rule file), `'already-on-disk'`
49
+ * (engine.mjs found a matching `.claude/rules/` provenance block before
50
+ * proposing — issue #484), or `'rejected'` (the operator declined the
51
+ * proposal in the approval AUQ and writer.mjs archived it — issue #1042).
52
+ * Optional and additive: `isCandidateShape` only requires `learning_key`
53
+ * + `created_at`, so an older record without this field still round-trips
54
+ * through the store unchanged, and nothing BRANCHES on the value —
55
+ * terminality is `processed_at` alone (see {@link isProcessed}), so a
56
+ * pre-#1042 record with no `outcome` keeps behaving exactly as before
57
+ * (absence reads as `'written'`, the only outcome that existed then).
47
58
  */
48
59
 
49
60
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
@@ -265,7 +276,15 @@ export function loadCandidates({ repoRoot, storePath } = {}) {
265
276
  * True iff `existing` already holds a candidate that shares `candidate`'s
266
277
  * `learning_key` AND has a terminal `processed_at` stamp. The reconcile engine
267
278
  * uses this to idempotently SKIP re-proposing a learning whose verdict is
268
- * already terminal. Never throws.
279
+ * already terminal.
280
+ *
281
+ * Deliberately OUTCOME-AGNOSTIC: `processed_at` alone decides. A rule the
282
+ * operator DECLINED (`outcome: 'rejected'`, issue #1042) is therefore just as
283
+ * terminal as one that was written — an explicit "no" is a verdict, and
284
+ * re-asking every run is how it gets forgotten. By the same token a pre-#1042
285
+ * record carrying no `outcome` at all keeps its old meaning unchanged.
286
+ *
287
+ * Never throws.
269
288
  * @param {ReconcileCandidate} candidate - the candidate under consideration.
270
289
  * @param {ReconcileCandidate[]} existing - the currently-persisted candidates.
271
290
  * @returns {boolean}
@@ -337,3 +356,85 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
337
356
  const result = writeStore(absPath, merged);
338
357
  return { merged, written: result.ok === true, skipped };
339
358
  }
359
+
360
+ /**
361
+ * Stamp the persisted candidate for `learningKey` as terminally processed and
362
+ * write it back through {@link mergeCandidates} — the ONLY writer of this
363
+ * store, so this function opens no second write path and no new persisted
364
+ * format: `outcome` is an ordinary field alongside `processed_at`, and the
365
+ * read-side shape guard (`isCandidateShape`) only requires `learning_key` +
366
+ * `created_at`, so it accepts the stamped record unchanged.
367
+ *
368
+ * Looks up the existing record by `learningKey` first, so the write preserves
369
+ * everything the run that proposed it originally recorded (`slug`, `reason`,
370
+ * `confidence`, `created_at`) and only ADDS the terminal stamp. When no record
371
+ * exists yet — e.g. the proposing run used `dryRun` and never merged into the
372
+ * store, or `engine.mjs` discovered the on-disk `.claude/rules/` file directly
373
+ * without ever having proposed it in a prior run — a fresh terminal record is
374
+ * minted from the caller-supplied `fallback*` fields so a LATER run can still
375
+ * dedupe against it.
376
+ *
377
+ * `mergeCandidates`'s own dedupe rule still applies underneath this call: if
378
+ * the existing record is ALREADY terminal (a prior `processed_at`), the merge
379
+ * keeps the existing verdict and this call is a no-op on disk — a terminal
380
+ * verdict is never regressed, even by a second stamp attempt.
381
+ *
382
+ * Never throws (delegates to the never-throwing `loadCandidates`/
383
+ * `mergeCandidates`); on write failure returns `{ written: false, ... }`.
384
+ *
385
+ * @param {Object} [params]
386
+ * @param {string} [params.learningKey] - required; a missing/empty key is a no-op (`{written:false, stamped:null}`).
387
+ * @param {'written'|'already-on-disk'|'rejected'|string} [params.outcome]
388
+ * @param {string} [params.processedAt] - ISO timestamp; defaults to `new Date().toISOString()`.
389
+ * @param {string} [params.fallbackSlug] - used only when no existing record is found.
390
+ * @param {string} [params.fallbackCandidateId] - used only when no existing record is found; defaults to `makeCandidateId(learningKey, fallbackSlug)`.
391
+ * @param {number} [params.fallbackConfidence] - used only when no existing record is found.
392
+ * @param {string} [params.repoRoot]
393
+ * @param {string} [params.storePath]
394
+ * @returns {{ written: boolean, stamped: ReconcileCandidate|null }}
395
+ */
396
+ export function markCandidateProcessed({
397
+ learningKey,
398
+ outcome,
399
+ processedAt,
400
+ fallbackSlug,
401
+ fallbackCandidateId,
402
+ fallbackConfidence,
403
+ repoRoot,
404
+ storePath,
405
+ } = {}) {
406
+ if (typeof learningKey !== 'string' || learningKey.length === 0) {
407
+ return { written: false, stamped: null };
408
+ }
409
+
410
+ const stampAt =
411
+ typeof processedAt === 'string' && processedAt.length > 0 ? processedAt : new Date().toISOString();
412
+
413
+ const { records: existing } = loadCandidates({ repoRoot, storePath });
414
+ const found = existing.find((rec) => rec && rec.learning_key === learningKey);
415
+
416
+ /** @type {ReconcileCandidate} */
417
+ let stamped;
418
+ if (found) {
419
+ stamped = { ...found, processed_at: stampAt, outcome: typeof outcome === 'string' ? outcome : (found.outcome ?? null) };
420
+ } else {
421
+ const slug = typeof fallbackSlug === 'string' ? fallbackSlug : '';
422
+ stamped = buildCandidate({
423
+ id:
424
+ typeof fallbackCandidateId === 'string' && fallbackCandidateId.length > 0
425
+ ? fallbackCandidateId
426
+ : makeCandidateId(learningKey, slug),
427
+ learningKey,
428
+ slug,
429
+ status: 'proposed',
430
+ reason: 'stamped without a prior sidecar record',
431
+ confidence: typeof fallbackConfidence === 'number' ? fallbackConfidence : 0,
432
+ createdAt: stampAt,
433
+ });
434
+ stamped.processed_at = stampAt;
435
+ stamped.outcome = typeof outcome === 'string' ? outcome : null;
436
+ }
437
+
438
+ const result = mergeCandidates({ candidates: [stamped], repoRoot, storePath });
439
+ return { written: result.written === true, stamped };
440
+ }