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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- 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.
|
|
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
|
+
}
|