session-orchestrator 3.17.0 → 3.20.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 (221) 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/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -62,7 +62,24 @@
62
62
  * @property {number} eligible
63
63
  * @property {number} proposed
64
64
  * @property {number} rejected
65
+ * @property {number} capped - count of eligible learnings that were NOT proposed
66
+ * this run purely because of the `maxProposalsPerRun` volume brake (issue
67
+ * #900 D — confidence-sorted, lowest-confidence entries cut first). Each
68
+ * capped learning is ALSO counted inside `rejected` (with a `capped — ...`
69
+ * reason) — `totalLearnings === proposed + rejected` still holds unchanged;
70
+ * `capped` is a diagnostic sub-count that lets a report distinguish
71
+ * "genuinely ineligible" rejections from "eligible but cut by the volume
72
+ * brake" ones at a glance.
65
73
  * @property {boolean} written
74
+ * @property {number} [skipped] - how many persisted sidecar lines the store's
75
+ * read-side shape guard rejected and this run therefore DROPPED from disk
76
+ * (`mergeCandidates` rewrites the store in full, never appends — see
77
+ * `idempotency.mjs`). PRESENT-vs-ABSENT is load-bearing and must not be
78
+ * collapsed: `0` means "the store was inspected and nothing was dropped",
79
+ * while ABSENCE means "the store was never inspected this run" — the case
80
+ * under `dryRun` (merge skipped entirely), on the empty short-circuit, on
81
+ * the top-level error path, and when the merge seam reports no count.
82
+ * Defaulting the absent case to `0` would be a false all-clear.
66
83
  *
67
84
  * @typedef {Object} ReconcileResult
68
85
  * @property {ReconcileProposal[]} proposals
@@ -74,15 +91,29 @@
74
91
  import { readFileSync } from 'node:fs';
75
92
  import { isAbsolute, join } from 'node:path';
76
93
 
94
+ import { learningKeyOf } from '../learnings/kebab.mjs';
77
95
  import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
78
96
  import { filterEligible } from './eligibility.mjs';
79
97
  import { toActivationMetadata } from './emitter.mjs';
80
98
  import { renderRule } from './renderer.mjs';
81
- import { makeCandidateId, mergeCandidates as realMergeCandidates } from './idempotency.mjs';
99
+ import {
100
+ DEFAULT_STORE_PATH,
101
+ buildCandidate,
102
+ makeCandidateId,
103
+ mergeCandidates as realMergeCandidates,
104
+ } from './idempotency.mjs';
82
105
 
83
106
  /** Default repo-relative location of the learnings corpus. */
84
107
  const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
85
108
 
109
+ /**
110
+ * Default volume brake (issue #900 D) — mirrors the `reconcile.max-proposals-
111
+ * per-run` Session Config default in `scripts/lib/config/reconcile.mjs`. Applied
112
+ * even when a caller omits `maxProposalsPerRun` entirely, so the engine never
113
+ * silently proposes an unbounded number of rules in one run.
114
+ */
115
+ const DEFAULT_MAX_PROPOSALS_PER_RUN = 10;
116
+
86
117
  /**
87
118
  * Build a fully-zeroed result (the empty / error shape). Touches no disk.
88
119
  * @param {string} [error]
@@ -98,6 +129,7 @@ function zeroedResult(error) {
98
129
  eligible: 0,
99
130
  proposed: 0,
100
131
  rejected: 0,
132
+ capped: 0,
101
133
  written: false,
102
134
  },
103
135
  };
@@ -149,26 +181,19 @@ function defaultLoadLearnings(repoRoot) {
149
181
 
150
182
  /**
151
183
  * Best-effort logical key for a REJECTED learning (rejections never run the
152
- * emitter, so there is no metadata.learningKey). Mirrors the emitter's key
153
- * shape `${type}/${kebab(subject||title)}` when both halves are present; falls
154
- * back to `null` when the type or subject/title is unusable. Never throws.
184
+ * emitter, so there is no `metadata.learningKey`). Delegates to the shared
185
+ * `learningKeyOf` it must NOT merely "mirror" the emitter's shape, it has to
186
+ * BE it: this key is written to `reconcile-candidates.jsonl` and folded into
187
+ * `makeCandidateId`, so a learning that succeeds one run and is rejected the
188
+ * next would otherwise appear in the sidecar under two identities and defeat
189
+ * `isProcessed`/`mergeCandidates` dedupe. Returns `null` for an unkeyable
190
+ * record. Never throws.
155
191
  *
156
192
  * @param {unknown} learning
157
193
  * @returns {string|null}
158
194
  */
159
195
  function rejectedLearningKey(learning) {
160
- if (learning === null || typeof learning !== 'object' || Array.isArray(learning)) return null;
161
- const rec = /** @type {Record<string, unknown>} */ (learning);
162
- const type = typeof rec.type === 'string' && rec.type !== '' ? rec.type : '';
163
- const subjectOrTitle =
164
- (typeof rec.title === 'string' && rec.title !== '' ? rec.title : '') ||
165
- (typeof rec.subject === 'string' && rec.subject !== '' ? rec.subject : '');
166
- if (type === '' || subjectOrTitle === '') return null;
167
- const kebab = subjectOrTitle
168
- .toLowerCase()
169
- .replace(/[^a-z0-9]+/g, '-')
170
- .replace(/^-+|-+$/g, '');
171
- return `${type}/${kebab}`;
196
+ return learningKeyOf(learning);
172
197
  }
173
198
 
174
199
  /**
@@ -186,33 +211,31 @@ function learningType(learning) {
186
211
  }
187
212
 
188
213
  /**
189
- * Build a sidecar ReconcileCandidate line-record (idempotency.mjs schema) for a
190
- * proposed or rejected learning. `created_at` is stamped from the injectable
191
- * clock so output stays deterministic under test.
214
+ * Surface a shape-guard drop on stderr. The sidecar is a mutable work-queue that
215
+ * `mergeCandidates` rewrites in FULL, so a record the read-side shape guard
216
+ * rejects is not merely ignored it is gone from disk after this run. The count
217
+ * alone makes that loss attributable; this WARN is what makes it VISIBLE, since
218
+ * the summary field only helps a caller that thinks to read it.
192
219
  *
193
- * @param {Object} params
194
- * @param {string} params.id
195
- * @param {string|null} params.learningKey
196
- * @param {string} params.slug
197
- * @param {'proposed'|'rejected'} params.status
198
- * @param {string} params.reason
199
- * @param {number} params.confidence
200
- * @param {string} params.createdAt - ISO timestamp.
201
- * @returns {import('./idempotency.mjs').ReconcileCandidate}
220
+ * Never throws: a failing diagnostic must not break the never-throws contract of
221
+ * {@link runReconcile} (a broken stderr pipe would otherwise zero the result).
222
+ *
223
+ * @param {number} skipped - drop count (> 0 by the time this is called).
224
+ * @returns {void}
202
225
  */
203
- function buildCandidate({ id, learningKey, slug, status, reason, confidence, createdAt }) {
204
- return {
205
- id,
206
- schema_version: 1,
207
- learning_key: typeof learningKey === 'string' ? learningKey : '',
208
- slug,
209
- status,
210
- reason,
211
- confidence,
212
- created_at: createdAt,
213
- processed_at: null,
214
- superseded_by: null,
215
- };
226
+ function warnDroppedStoreRecords(skipped) {
227
+ try {
228
+ console.warn(
229
+ `⚠️ reconcile: ${skipped} record(s) in ${DEFAULT_STORE_PATH} failed the ` +
230
+ `candidate shape guard and were DROPPED by this merge — the store is ` +
231
+ `rewritten in full, so they are no longer on disk. Expected shape: a ` +
232
+ `ReconcileCandidate with \`learning_key\` + \`created_at\` (see ` +
233
+ `scripts/lib/reconcile/idempotency.mjs). Only \`mergeCandidates\` may ` +
234
+ `write this store; hand-written or report records do not belong in it.`,
235
+ );
236
+ } catch {
237
+ // A diagnostic must never become the failure it reports on.
238
+ }
216
239
  }
217
240
 
218
241
  /**
@@ -236,6 +259,11 @@ function buildCandidate({ id, learningKey, slug, status, reason, confidence, cre
236
259
  * eligibility placeholder-insight check (forwarded to `filterEligible`). Inert
237
260
  * (no additional rejections) when omitted.
238
261
  * @param {number|Date} [params.now] - injectable clock (emitter fallback + candidate `created_at`).
262
+ * @param {number} [params.maxProposalsPerRun] - volume brake (issue #900 D): after
263
+ * sorting eligible learnings by confidence DESC, only the top N are proposed;
264
+ * the rest are recorded as `capped` rejections. Defaults to
265
+ * {@link DEFAULT_MAX_PROPOSALS_PER_RUN} (10) when omitted, non-finite, or < 1
266
+ * — the brake is ALWAYS active, matching the Session Config default.
239
267
  * @param {boolean} [params.dryRun] - when true, compute proposals but SKIP the merge entirely
240
268
  * (also accepted as `opts.dryRun`; either location sets it).
241
269
  * @param {Object} [opts] - DI seams (all default to real behaviour).
@@ -249,7 +277,15 @@ function buildCandidate({ id, learningKey, slug, status, reason, confidence, cre
249
277
  * @returns {Promise<ReconcileResult>}
250
278
  */
251
279
  export async function runReconcile(
252
- { repoRoot, ruleExpiryDays, minRuleDays, minInsightChars, now, dryRun: dryRunParam } = {},
280
+ {
281
+ repoRoot,
282
+ ruleExpiryDays,
283
+ minRuleDays,
284
+ minInsightChars,
285
+ now,
286
+ maxProposalsPerRun: maxProposalsPerRunParam,
287
+ dryRun: dryRunParam,
288
+ } = {},
253
289
  opts = {},
254
290
  ) {
255
291
  try {
@@ -258,6 +294,12 @@ export async function runReconcile(
258
294
  // (the documented DI seam) — either location flips it on.
259
295
  const dryRun = dryRunParam === true || opts.dryRun === true;
260
296
  const merge = typeof opts.merge === 'function' ? opts.merge : realMergeCandidates;
297
+ // Volume brake (#900 D) — always active; a missing/invalid override falls
298
+ // back to the same default the Session Config parser uses.
299
+ const maxProposalsPerRun =
300
+ Number.isFinite(maxProposalsPerRunParam) && maxProposalsPerRunParam >= 1
301
+ ? Math.floor(maxProposalsPerRunParam)
302
+ : DEFAULT_MAX_PROPOSALS_PER_RUN;
261
303
 
262
304
  // --- Pipeline step 1 — load learnings ----------------------------------
263
305
  /** @type {Array<Record<string, unknown>>} */
@@ -289,6 +331,21 @@ export async function runReconcile(
289
331
  minInsightChars,
290
332
  });
291
333
 
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
+ const confidenceOf = (l) =>
341
+ 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
+
292
349
  /** @type {ReconcileProposal[]} */
293
350
  const proposals = [];
294
351
  /** @type {ReconcileRejection[]} */
@@ -297,7 +354,7 @@ export async function runReconcile(
297
354
  const candidates = [];
298
355
 
299
356
  // --- Pipeline step 4 — per eligible learning (wrapped per-item) ---------
300
- for (const learning of eligible) {
357
+ for (const learning of keptEligible) {
301
358
  try {
302
359
  const metadata = toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDays });
303
360
  const { slug, path, content } = renderRule(learning, metadata);
@@ -352,6 +409,34 @@ export async function runReconcile(
352
409
  }
353
410
  }
354
411
 
412
+ // --- Pipeline step 4b — capped-eligible learnings (#900 D) --------------
413
+ // Learnings that passed eligibility but were cut by the volume brake are
414
+ // NEVER passed to the emitter — recorded directly as rejections (with a
415
+ // `capped — ...` reason) so a report distinguishes this from a genuine
416
+ // ineligibility rejection.
417
+ for (const learning of cappedEligible) {
418
+ const learningKey = rejectedLearningKey(learning);
419
+ const type = learningType(learning);
420
+ const reason = `capped — max-proposals-per-run (${maxProposalsPerRun}) reached; ${cappedEligible.length} lower-confidence eligible learning(s) not proposed this run`;
421
+ rejected.push({
422
+ learningKey,
423
+ type,
424
+ reason,
425
+ status: 'rejected',
426
+ });
427
+ candidates.push(
428
+ buildCandidate({
429
+ id: makeCandidateId(learningKey ?? '', `rejected-${type}`),
430
+ learningKey,
431
+ slug: '',
432
+ status: 'rejected',
433
+ reason,
434
+ confidence: confidenceOf(learning),
435
+ createdAt,
436
+ }),
437
+ );
438
+ }
439
+
355
440
  // --- Pipeline step 5 — per rejected learning (eligibility rejects) ------
356
441
  for (const { learning, reason } of rejectedLearnings) {
357
442
  const learningKey = rejectedLearningKey(learning);
@@ -382,28 +467,45 @@ export async function runReconcile(
382
467
  // The engine's ONLY disk write — and it is skipped entirely under dryRun.
383
468
  // It never writes `.claude/rules/` (FA3 / #696 owns that, post-approval).
384
469
  let written = false;
470
+ // `undefined` (NOT 0) until the merge actually inspects the store — absence
471
+ // means "not checked", 0 means "checked, nothing dropped". See the
472
+ // ReconcileSummary `skipped` typedef.
473
+ /** @type {number|undefined} */
474
+ let skipped;
385
475
  if (!dryRun) {
386
476
  try {
387
477
  const mergeResult = merge({ candidates, repoRoot });
388
478
  written = !!(mergeResult && mergeResult.written === true);
479
+ // Only a finite count from the seam counts as "inspected". A merge seam
480
+ // that reports nothing leaves `skipped` absent rather than fabricating 0.
481
+ if (mergeResult && Number.isFinite(mergeResult.skipped)) {
482
+ skipped = Number(mergeResult.skipped);
483
+ }
389
484
  } catch {
390
- // Merge failure is non-fatal; proposals still returned, written stays false.
485
+ // Merge failure is non-fatal; proposals still returned, written stays
486
+ // false and `skipped` stays absent (the store was never inspected).
391
487
  written = false;
392
488
  }
393
489
  }
394
490
 
491
+ // Make an attributable drop VISIBLE, not merely recorded (WARN, never throw).
492
+ if (typeof skipped === 'number' && skipped > 0) warnDroppedStoreRecords(skipped);
493
+
395
494
  // --- Pipeline step 7 — summary -----------------------------------------
396
- return {
397
- proposals,
398
- rejected,
399
- summary: {
400
- totalLearnings,
401
- eligible: eligible.length,
402
- proposed: proposals.length,
403
- rejected: rejected.length,
404
- written,
405
- },
495
+ /** @type {ReconcileSummary} */
496
+ const summary = {
497
+ totalLearnings,
498
+ eligible: eligible.length,
499
+ proposed: proposals.length,
500
+ rejected: rejected.length,
501
+ capped: cappedEligible.length,
502
+ written,
406
503
  };
504
+ // Additive + absence-preserving: the key exists ONLY when the store was
505
+ // actually inspected, so no consumer can read a false `skipped: 0`.
506
+ if (typeof skipped === 'number') summary.skipped = skipped;
507
+
508
+ return { proposals, rejected, summary };
407
509
  } catch (err) {
408
510
  // never-throws top-level guard.
409
511
  const msg = err && err.message ? err.message : String(err);
@@ -10,7 +10,13 @@
10
10
  * lines — an append would leave stale duplicates.
11
11
  *
12
12
  * Store: `.orchestrator/runtime/reconcile-candidates.jsonl` — a mutable
13
- * work-queue in JSON-Lines format (one ReconcileCandidate per line).
13
+ * work-queue in JSON-Lines format (one ReconcileCandidate per line). The store
14
+ * is OWNED by `mergeCandidates`: it is the only sanctioned writer. Nothing else
15
+ * — no report, no analysis run, no agent — may append to it; a read-side shape
16
+ * guard drops any record that is not a ReconcileCandidate and COUNTS the drop.
17
+ * The count reaches readers through the ONE reader ({@link loadCandidates}
18
+ * returns `{records, skipped}`) and writers through `mergeCandidates`'s
19
+ * `skipped` — there is deliberately no lossy array-only variant beside them.
14
20
  *
15
21
  * Two responsibilities differ from the repair store:
16
22
  * 1. The IDEMPOTENCY KEY is the LOGICAL `learning_key` (issue #695), not the
@@ -66,12 +72,40 @@ function resolveStorePath(repoRoot, storePath) {
66
72
  return join(root, rel);
67
73
  }
68
74
 
75
+ /**
76
+ * Minimal shape guard for a persisted store line. A record is accepted only
77
+ * when it carries the two fields every consumer of this store depends on:
78
+ * - `learning_key` — THE logical dedupe key (`mergeCandidates`, `isProcessed`).
79
+ * - `created_at` — the recency axis (`reconcile-nudge-banner.mjs` `_lastRunAt`).
80
+ *
81
+ * This is deliberately NOT a full schema check: the store is a mutable
82
+ * work-queue whose records may gain fields across schema versions, so
83
+ * over-strict validation would silently drop legitimate future records. It
84
+ * rejects only records that no writer in this repo produces — the concrete
85
+ * incident being a hand-written report artefact using `candidate_id` /
86
+ * `generated_at` / `status:"candidate"` (2026-07-31, see
87
+ * `docs/reconcile/2026-07-31-reconcile-candidates.md`).
88
+ * @param {unknown} rec
89
+ * @returns {boolean}
90
+ */
91
+ function isCandidateShape(rec) {
92
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return false;
93
+ const r = /** @type {Record<string, unknown>} */ (rec);
94
+ if (typeof r.learning_key !== 'string' || r.learning_key.length === 0) return false;
95
+ if (typeof r.created_at !== 'string') return false;
96
+ return true;
97
+ }
98
+
69
99
  /**
70
100
  * Read + defensively parse the store's JSONL lines into ReconcileCandidate
71
- * records. Malformed lines (bad JSON, non-object) are skipped silently. A
72
- * missing file yields `[]`. Never throws.
101
+ * records. Malformed lines (bad JSON, non-object) and shape-foreign records
102
+ * (see {@link isCandidateShape}) are skipped — the latter are COUNTED, because
103
+ * the store is a mutable work-queue that `mergeCandidates` rewrites in full, so
104
+ * a skipped line is dropped from disk on the next merge and a silent drop would
105
+ * be unattributable data loss. A missing file yields `{ records: [], skipped: 0 }`.
106
+ * Never throws.
73
107
  * @param {string} absPath
74
- * @returns {ReconcileCandidate[]}
108
+ * @returns {{ records: ReconcileCandidate[], skipped: number }}
75
109
  */
76
110
  function readStore(absPath) {
77
111
  let raw;
@@ -79,11 +113,12 @@ function readStore(absPath) {
79
113
  raw = readFileSync(absPath, 'utf8');
80
114
  } catch {
81
115
  // ENOENT or any read error → empty store.
82
- return [];
116
+ return { records: [], skipped: 0 };
83
117
  }
84
118
 
85
119
  /** @type {ReconcileCandidate[]} */
86
120
  const records = [];
121
+ let skipped = 0;
87
122
  for (const line of raw.split('\n')) {
88
123
  const trimmed = line.trim();
89
124
  if (trimmed.length === 0) continue;
@@ -91,13 +126,16 @@ function readStore(absPath) {
91
126
  try {
92
127
  parsed = JSON.parse(trimmed);
93
128
  } catch {
129
+ skipped += 1;
94
130
  continue; // skip malformed line
95
131
  }
96
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
132
+ if (isCandidateShape(parsed)) {
97
133
  records.push(/** @type {ReconcileCandidate} */ (parsed));
134
+ } else {
135
+ skipped += 1;
98
136
  }
99
137
  }
100
- return records;
138
+ return { records, skipped };
101
139
  }
102
140
 
103
141
  /**
@@ -153,13 +191,70 @@ export function makeCandidateId(learningKey, slug) {
153
191
  }
154
192
 
155
193
  /**
156
- * Load every ReconcileCandidate currently persisted in the store. Reads JSONL,
157
- * skips malformed lines, returns `[]` for a missing file. Does NOT create the
194
+ * Build a ReconcileCandidate line-record for a proposed or rejected learning.
195
+ *
196
+ * Lives here — beside the {@link ReconcileCandidate} typedef it instantiates and
197
+ * the read-side `isCandidateShape` guard that judges it — so ONE file decides
198
+ * which fields a persisted record carries. It deliberately does NOT mint the
199
+ * `id` (see {@link makeCandidateId}): rejections currently derive their id from
200
+ * `(learningKey, 'rejected-<type>')` while storing `slug: ''`, so folding the
201
+ * mint in here using the record's own slug would change every rejection
202
+ * candidate's id and orphan every rejection row already on disk. Caller-supplied
203
+ * `id` keeps that decision at the call site.
204
+ *
205
+ * `created_at` is caller-supplied (from the engine's injectable clock) so output
206
+ * stays deterministic under test. `processed_at`/`superseded_by` always start
207
+ * null — only the merge/approval path stamps them. Never throws.
208
+ *
209
+ * @param {Object} params
210
+ * @param {string} params.id - deterministic physical id, see {@link makeCandidateId}.
211
+ * @param {string|null} params.learningKey - logical dedupe key; a non-string coerces to `''`.
212
+ * @param {string} params.slug - `.claude/rules/<slug>.md` slug (`''` for rejections).
213
+ * @param {'proposed'|'rejected'} params.status
214
+ * @param {string} params.reason
215
+ * @param {number} params.confidence
216
+ * @param {string} params.createdAt - ISO timestamp.
217
+ * @returns {ReconcileCandidate}
218
+ */
219
+ export function buildCandidate({ id, learningKey, slug, status, reason, confidence, createdAt }) {
220
+ return {
221
+ id,
222
+ schema_version: 1,
223
+ learning_key: typeof learningKey === 'string' ? learningKey : '',
224
+ slug,
225
+ status,
226
+ reason,
227
+ confidence,
228
+ created_at: createdAt,
229
+ processed_at: null,
230
+ superseded_by: null,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Load the persisted store: every ReconcileCandidate that survives the read-side
236
+ * shape guard, PLUS the count of lines it rejected. Reads JSONL, skips malformed
237
+ * lines and shape-foreign records (missing `learning_key` / `created_at`),
238
+ * yields `{ records: [], skipped: 0 }` for a missing file. Does NOT create the
158
239
  * runtime dir (mkdir -p happens only on write). Never throws.
240
+ *
241
+ * `skipped` is part of the return value rather than a second, "diagnostics"
242
+ * reader beside this one, because `records.length === 0` is AMBIGUOUS on its
243
+ * own: a missing store and a store whose every line is shape-foreign both yield
244
+ * `[]`, and a consumer that sees only the array reports "no reconcile run on
245
+ * record" for a store that in fact holds quarantined evidence of one (the
246
+ * concrete defect in `scripts/lib/reconcile-nudge-banner.mjs`, GitLab #955
247
+ * finding 2). Splitting the honest reader off under a longer name left the
248
+ * OBVIOUS name as the lossy one — the next consumer would reach for
249
+ * `loadCandidates`, get `[]`, and re-derive the same wrong conclusion. One
250
+ * reader, one answer.
251
+ *
252
+ * Read-only: unlike {@link mergeCandidates} this does NOT rewrite the store, so
253
+ * the skipped lines are still on disk after this call.
159
254
  * @param {Object} [params]
160
255
  * @param {string} [params.repoRoot] - repo root; relative `storePath` is resolved against it (defaults to `process.cwd()`).
161
256
  * @param {string} [params.storePath] - store path (relative ⇒ joined to repoRoot). Defaults to {@link DEFAULT_STORE_PATH}.
162
- * @returns {ReconcileCandidate[]}
257
+ * @returns {{ records: ReconcileCandidate[], skipped: number }}
163
258
  */
164
259
  export function loadCandidates({ repoRoot, storePath } = {}) {
165
260
  const absPath = resolveStorePath(repoRoot, storePath);
@@ -198,16 +293,21 @@ export function isProcessed(candidate, existing) {
198
293
  * The runtime dir is created with mkdir -p semantics. The store is rewritten in
199
294
  * full (read-all → merge → atomic tmp+rename), never appended. Output lines are
200
295
  * sorted by `learning_key` for deterministic output. Never throws; on write
201
- * failure returns `{ merged, written: false }`.
296
+ * failure returns `written: false`.
297
+ *
298
+ * `skipped` reports how many persisted lines the read-side shape guard rejected
299
+ * (malformed JSON, or a record missing `learning_key`/`created_at`). Because the
300
+ * store is rewritten in full, those lines are DROPPED from disk by this call —
301
+ * the count is what makes that loss attributable instead of silent.
202
302
  * @param {Object} [params]
203
303
  * @param {ReconcileCandidate[]} [params.candidates] - newly minted candidates to merge.
204
304
  * @param {string} [params.repoRoot]
205
305
  * @param {string} [params.storePath]
206
- * @returns {{ merged: ReconcileCandidate[], written: boolean }}
306
+ * @returns {{ merged: ReconcileCandidate[], written: boolean, skipped: number }}
207
307
  */
208
308
  export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
209
309
  const absPath = resolveStorePath(repoRoot, storePath);
210
- const store = readStore(absPath);
310
+ const { records: store, skipped } = readStore(absPath);
211
311
 
212
312
  // Index existing records by learning_key for O(1) lookup. Last write wins for
213
313
  // any pre-existing duplicates in the file (defensive — store should be unique).
@@ -235,5 +335,5 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
235
335
  );
236
336
 
237
337
  const result = writeStore(absPath, merged);
238
- return { merged, written: result.ok === true };
338
+ return { merged, written: result.ok === true, skipped };
239
339
  }