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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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 {
|
|
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).
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
*
|
|
194
|
-
* @
|
|
195
|
-
*
|
|
196
|
-
* @param {
|
|
197
|
-
* @
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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)
|
|
72
|
-
*
|
|
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 (
|
|
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
|
-
*
|
|
157
|
-
*
|
|
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 `
|
|
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
|
}
|