From fafec2a88522ad6bda43dcba3af4618b878df584 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 02:03:58 +0300 Subject: [PATCH] Add targeted instruction source replacement Agent: livianus --- src/cli/index.tsx | 63 +++- src/cli/session.test.ts | 167 ++++++++++ .../session-render-silent-source-drop.test.ts | 310 +++++++++++++++++- src/lib/session-render.ts | 172 +++++++++- 4 files changed, 695 insertions(+), 17 deletions(-) diff --git a/src/cli/index.tsx b/src/cli/index.tsx index b2e6b58..047d99e 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -15,7 +15,7 @@ import { importConfigs } from "../lib/import.js"; import { extractTemplateVars } from "../lib/template.js"; import { detectMachineContext, resolveProfileVariables } from "../lib/machine.js"; import { applySessionRender, restoreSessionRenderSnapshot } from "../lib/session-apply.js"; -import { planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFilePath, sourcesFromIdentityExport, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_TOOLS, type SessionInstructionLayer, type SessionInstructionSource, type SessionRenderFile, type SessionRenderPlan, type SessionRenderTool } from "../lib/session-render.js"; +import { normalizeSessionInstructionSourceId, planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFilePath, sourcesFromIdentityExport, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_TOOLS, type SessionInstructionLayer, type SessionInstructionSource, type SessionRenderFile, type SessionRenderPlan, type SessionRenderTool } from "../lib/session-render.js"; import { accountedGlobalSourceSlugs, computeGlobalSourceCoverage, formatGlobalSourceCoverageWarnings, type GlobalSourceCoverageResult } from "../lib/global-source-coverage.js"; import { ensurePlatformProfiles } from "../lib/platform-profiles.js"; import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js"; @@ -127,7 +127,7 @@ function parseSessionLayer(value: string): SessionInstructionLayer { throw new Error(`Invalid source layer "${value}"`); } -function parseSessionSource(value: string, order: number, replaceIds: Set): SessionInstructionSource { +function parseSessionSource(value: string, order: number): SessionInstructionSource { const idx = value.indexOf("="); let id = idx > 0 ? value.slice(0, idx).trim() : ""; const path = idx > 0 ? value.slice(idx + 1).trim() : value.trim(); @@ -148,10 +148,49 @@ function parseSessionSource(value: string, order: number, replaceIds: Set= 0 ? trimmed.slice(0, separator) : trimmed).trim(); + if (!replacerId) { + throw new Error(`Invalid --replace-source "${value}" (expected replacer-id or replacer-id=target-source-id)`); + } + if (separator < 0) return { replacerId }; + const targetId = trimmed.slice(separator + 1).trim(); + if (!targetId) { + throw new Error(`Invalid --replace-source "${value}" (target source id is required after "=")`); + } + return { + replacerId, + replacementScope: `source:${normalizeSessionInstructionSourceId(targetId)}`, + }; +} + +function sessionSourceReplacements(values: string[]): Map { + const replacements = new Map(); + for (const value of values) { + const replacement = parseSessionSourceReplacement(value); + const existing = replacements.get(replacement.replacerId); + if (existing && existing.replacementScope !== replacement.replacementScope) { + throw new Error( + `Conflicting --replace-source values for "${replacement.replacerId}": ` + + `${existing.replacementScope ?? "broad"} and ${replacement.replacementScope ?? "broad"}.`, + ); + } + replacements.set(replacement.replacerId, replacement); + } + return replacements; +} + function readSessionInstructionSourceFile(path: string): string { const stat = lstatSync(path); if (stat.isSymbolicLink()) { @@ -191,8 +230,8 @@ async function collectSessionSources( tool: SessionRenderTool, store: ConfigStore, ): Promise { - const replaceIds = new Set(opts.replaceSource ?? []); - const sources = (opts.source ?? []).map((value, index) => parseSessionSource(value, index, replaceIds)); + const replacements = sessionSourceReplacements(opts.replaceSource ?? []); + const sources = (opts.source ?? []).map((value, index) => parseSessionSource(value, index)); for (const value of opts.config ?? []) { const { layer, id } = parseLayeredReference(value); @@ -206,7 +245,15 @@ async function collectSessionSources( sources.push(...sourcesFromIdentityExport(parsed, { path, tool, orderOffset: sources.length })); } - return sources.map((source) => replaceIds.has(source.id) ? { ...source, merge: "replace" } : source); + return sources.map((source) => { + const replacement = replacements.get(source.id); + if (!replacement) return source; + return { + ...source, + merge: "replace", + replacementScope: replacement.replacementScope, + }; + }); } // Reconcile-and-warn for todos 102d6d0a/5dcd60ec: `--config global:` entries @@ -1158,7 +1205,7 @@ sessionCmd.command("plan") .option("--source ", `instruction source file; layers: ${SESSION_SOURCE_LAYER_HELP}`, collectOption, []) .option("--config ", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, []) .option("--identity-export ", "OpenIdentities configs instruction export JSON; repeatable", collectOption, []) - .option("--replace-source ", "source id that replaces earlier layers instead of appending", collectOption, []) + .option("--replace-source [=]", "source id that broadly replaces earlier layers, or targets one earlier source", collectOption, []) .option("--codewith-native-imports", "select the gated Codewith native @ import adapter") .option("--allow-empty-sources", "allow an explicit empty render plan") .option("--check-global-coverage", "warn (non-fatal) when a registered, non-retired global-* source is absent from this render's --config list; expected is read fresh from the registry, independent of this plan (todos 102d6d0a)") @@ -1226,7 +1273,7 @@ sessionCmd.command("apply") .option("--source ", `instruction source file; layers: ${SESSION_SOURCE_LAYER_HELP}`, collectOption, []) .option("--config ", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, []) .option("--identity-export ", "OpenIdentities configs instruction export JSON; repeatable", collectOption, []) - .option("--replace-source ", "source id that replaces earlier layers instead of appending", collectOption, []) + .option("--replace-source [=]", "source id that broadly replaces earlier layers, or targets one earlier source", collectOption, []) .option("--codewith-native-imports", "select the gated Codewith native @ import adapter") .option("--allow-empty-sources", "allow an explicit empty render") .option("--check-global-coverage", "warn (non-fatal) when a registered, non-retired global-* source is absent from this render's --config list; expected is read fresh from the registry, independent of this plan (todos 102d6d0a)") diff --git a/src/cli/session.test.ts b/src/cli/session.test.ts index d6acf5c..e866eeb 100644 --- a/src/cli/session.test.ts +++ b/src/cli/session.test.ts @@ -313,6 +313,173 @@ describe("configs session CLI", () => { } }); + test("--replace-source replacer=target matches identity-exported replacementScope", () => { + const home = makeTempRoot("open-configs-session-cli-"); + try { + const cliScopedExport = join(home, "cli-scoped.json"); + const identityScopedExport = join(home, "identity-scoped.json"); + const sources = [ + { + id: "shared-review", + title: "Shared Review", + kind: "global-rules", + precedence: 0, + mergePolicy: "append", + content: "Shared review requires two reviewers.", + }, + { + id: "r11-recording", + title: "R11 Recording", + kind: "global-rules", + precedence: 1, + mergePolicy: "append", + content: "R11 remains.", + }, + { + id: "codewith-review", + title: "Codewith Review", + kind: "global-rules", + precedence: 2, + mergePolicy: "append", + content: "Codewith review requires one reviewer.", + }, + ]; + const exportPayload = (scoped: boolean) => ({ + contract: "hasna.identities.configs-instructions/v1", + validation: { valid: true }, + sources: sources.map((source) => + source.id === "codewith-review" && scoped + ? { + ...source, + mergePolicy: "replace", + replacementScope: "source:shared-review", + } + : source + ), + }); + writeFileSync(cliScopedExport, JSON.stringify(exportPayload(false))); + writeFileSync(identityScopedExport, JSON.stringify(exportPayload(true))); + const env = { + HOME: home, + HASNA_CONFIGS_HOME: join(home, ".hasna", "configs"), + }; + const common = [ + "session", + "plan", + "--tool", + "codex", + "--profile", + "account999", + "--target-home", + "~/session-home", + "--json", + ]; + + const fromCli = runCli([ + ...common, + "--identity-export", + cliScopedExport, + "--replace-source", + "codewith-review=Shared Review", + ], env); + const fromIdentity = runCli([ + ...common, + "--identity-export", + identityScopedExport, + ], env); + + expect(fromCli.status).toBe(0); + expect(fromIdentity.status).toBe(0); + const cliPlan = JSON.parse(fromCli.stdout) as { + manifest: { + sourceHash: string; + sources: Array<{ id: string; replacementScope: string | null; provenance: unknown }>; + skippedSources: Array<{ id: string; reason: string }>; + }; + }; + const identityPlan = JSON.parse(fromIdentity.stdout) as typeof cliPlan; + expect(cliPlan.manifest.sourceHash).toBe(identityPlan.manifest.sourceHash); + expect(cliPlan.manifest.sources).toEqual(identityPlan.manifest.sources); + expect(cliPlan.manifest.skippedSources).toEqual(identityPlan.manifest.skippedSources); + expect(cliPlan.manifest.sources.map((source) => source.id)).toEqual([ + "r11-recording", + "codewith-review", + ]); + expect(cliPlan.manifest.sources[1]?.replacementScope).toBe("source:shared-review"); + expect(cliPlan.manifest.skippedSources.map((source) => source.id)).toEqual(["shared-review"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("--replace-source replacer keeps broad replacement compatibility", () => { + const home = makeTempRoot("open-configs-session-cli-"); + try { + const exportPath = join(home, "broad-replace.json"); + writeFileSync(exportPath, JSON.stringify({ + contract: "hasna.identities.configs-instructions/v1", + validation: { valid: true }, + sources: [ + { + id: "ordinary-a", + title: "Ordinary A", + kind: "global-rules", + precedence: 0, + mergePolicy: "append", + content: "Ordinary A.", + }, + { + id: "ordinary-b", + title: "Ordinary B", + kind: "global-rules", + precedence: 1, + mergePolicy: "append", + content: "Ordinary B.", + }, + { + id: "broad-replacer", + title: "Broad Replacer", + kind: "global-rules", + precedence: 2, + mergePolicy: "append", + content: "Broad replacement.", + }, + ], + })); + + const result = runCli([ + "session", + "plan", + "--tool", + "codex", + "--profile", + "account999", + "--identity-export", + exportPath, + "--replace-source", + "broad-replacer", + "--json", + ], { + HOME: home, + HASNA_CONFIGS_HOME: join(home, ".hasna", "configs"), + }); + + expect(result.status).toBe(0); + const plan = JSON.parse(result.stdout) as { + manifest: { + sources: Array<{ id: string; replacementScope: string | null }>; + skippedSources: Array<{ id: string }>; + }; + }; + expect(plan.manifest.sources).toEqual([ + expect.objectContaining({ id: "broad-replacer", replacementScope: null }), + ]); + expect(plan.manifest.skippedSources.map((source) => source.id)).toEqual(["ordinary-a", "ordinary-b"]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("applies canonical identity exports with source paths and filters rule provider blocks", () => { const home = makeTempRoot("open-configs-session-cli-"); try { diff --git a/src/lib/session-render-silent-source-drop.test.ts b/src/lib/session-render-silent-source-drop.test.ts index c8d95ef..8d3e232 100644 --- a/src/lib/session-render-silent-source-drop.test.ts +++ b/src/lib/session-render-silent-source-drop.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { planSessionRender, type SessionInstructionSource } from "./session-render"; +import { createHash } from "node:crypto"; +import { + planSessionRender, + sourcesFromIdentityExport, + type SessionInstructionSource, +} from "./session-render"; /** * Regression cover for todos 0c7ffd33 — `session plan` / `session apply` discarded @@ -53,6 +58,10 @@ function plan(sources: SessionInstructionSource[]) { }); } +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + describe("session render reports every source it discards (todos 0c7ffd33)", () => { /** * The PASSING state of the whole probe. Two ordinary sources must survive untouched @@ -170,3 +179,302 @@ describe("session render reports every source it discards (todos 0c7ffd33)", () expect(result.warnings).toEqual([]); }); }); + +describe("targeted source replacement (goal 36dd6ed8)", () => { + const sharedReview = "Shared review requires two reviewers."; + const r11 = "R11 recording eagerness remains byte-for-byte present."; + const r12 = "R12 session self-scheduling remains byte-for-byte present."; + const protectedSafety = "Non-overridable safety remains byte-for-byte present."; + const codewithReview = "Codewith review requires one reviewer."; + + function targetedSources(): SessionInstructionSource[] { + return [ + { id: "shared-review", layer: "global", order: 0, content: sharedReview }, + { id: "r11-recording", layer: "global", order: 1, content: r11 }, + { id: "r12-scheduling", layer: "global", order: 2, content: r12 }, + { + id: "protected-safety", + layer: "global", + order: 3, + content: protectedSafety, + nonOverridable: true, + }, + { + id: "codewith-review", + layer: "global", + order: 4, + merge: "replace", + replacementScope: "source:shared-review", + content: codewithReview, + provenance: { source: "identity-export" }, + }, + ]; + } + + test("removes only the named earlier overridable source and preserves unrelated bytes", () => { + const result = plan(targetedSources()); + const rendered = result.allFiles.map((file) => file.content).join("\n"); + + expect(result.manifest.sources.map((source) => source.id)).toEqual([ + "r11-recording", + "r12-scheduling", + "protected-safety", + "codewith-review", + ]); + expect(rendered).not.toContain(sharedReview); + expect(rendered).toContain(r11); + expect(rendered).toContain(r12); + expect(rendered).toContain(protectedSafety); + expect(rendered).toContain(codewithReview); + + const r11Manifest = result.manifest.sources.find((source) => source.id === "r11-recording"); + const r12Manifest = result.manifest.sources.find((source) => source.id === "r12-scheduling"); + const protectedManifest = result.manifest.sources.find((source) => source.id === "protected-safety"); + expect(r11Manifest?.renderedPayloadSha256).toBe(sha256(r11)); + expect(r12Manifest?.renderedPayloadSha256).toBe(sha256(r12)); + expect(protectedManifest?.renderedPayloadSha256).toBe(sha256(protectedSafety)); + }); + + test("records the targeted relation in skipped sources and replacer provenance", () => { + const result = plan(targetedSources()); + const replacer = result.manifest.sources.find((source) => source.id === "codewith-review"); + + expect(result.manifest.skippedSources).toHaveLength(1); + expect(result.manifest.skippedSources[0]).toMatchObject({ id: "shared-review" }); + expect(result.manifest.skippedSources[0]!.reason).toContain("source:shared-review"); + expect(replacer?.replacementScope).toBe("source:shared-review"); + expect(replacer?.provenance).toMatchObject({ + source: "identity-export", + targetedReplacement: { + scope: "source:shared-review", + targetSourceId: "shared-review", + targetNormalizedSourceId: "shared-review", + }, + }); + }); + + test("targeted replacement changes the source hash", () => { + const appended = targetedSources().map((source) => + source.id === "codewith-review" + ? { ...source, merge: "append" as const, replacementScope: undefined } + : source + ); + + expect(plan(targetedSources()).manifest.sourceHash).not.toBe(plan(appended).manifest.sourceHash); + }); + + test("identity-exported replacementScope follows the same composition contract", () => { + const sources = sourcesFromIdentityExport({ + contract: "hasna.identities.configs-instructions/v1", + validation: { valid: true }, + sources: targetedSources().map((source) => ({ + id: source.id, + title: source.id, + kind: "global-rules", + precedence: source.order, + mergePolicy: source.merge ?? "append", + content: source.content, + nonOverridable: source.nonOverridable ?? false, + replacementScope: source.replacementScope, + provenance: source.provenance, + })), + }, { tool: "claude" }); + + const result = plan(sources); + expect(result.manifest.sources.map((source) => source.id)).toEqual([ + "r11-recording", + "r12-scheduling", + "protected-safety", + "codewith-review", + ]); + expect(result.manifest.skippedSources.map((source) => source.id)).toEqual(["shared-review"]); + }); + + test("two targeted replacers compose left to right without deleting unrelated sources", () => { + const result = plan([ + { id: "target-a", layer: "global", order: 0, content: "Target A." }, + { id: "unrelated", layer: "global", order: 1, content: "Unrelated." }, + { id: "target-b", layer: "global", order: 2, content: "Target B." }, + { + id: "replacer-a", + layer: "global", + order: 3, + merge: "replace", + replacementScope: "source:target-a", + content: "Replacer A.", + }, + { + id: "replacer-b", + layer: "global", + order: 4, + merge: "replace", + replacementScope: "source:target-b", + content: "Replacer B.", + }, + ]); + + expect(result.manifest.sources.map((source) => source.id)).toEqual([ + "unrelated", + "replacer-a", + "replacer-b", + ]); + expect(result.manifest.skippedSources.map((source) => source.id)).toEqual(["target-a", "target-b"]); + }); + + test("fails closed when the targeted source is missing", () => { + expect(() => plan([ + { + id: "replacer", + layer: "global", + order: 0, + merge: "replace", + replacementScope: "source:missing", + content: "Replacer.", + }, + ])).toThrow("missing"); + }); + + test("fails closed when the targeted source appears later", () => { + expect(() => plan([ + { + id: "replacer", + layer: "global", + order: 0, + merge: "replace", + replacementScope: "source:later-target", + content: "Replacer.", + }, + { id: "later-target", layer: "global", order: 1, content: "Later target." }, + ])).toThrow("later"); + }); + + test("fails closed when an earlier replacer already removed the target", () => { + expect(() => plan([ + { id: "target", layer: "global", order: 0, content: "Target." }, + { + id: "first-replacer", + layer: "global", + order: 1, + merge: "replace", + replacementScope: "source:target", + content: "First replacer.", + }, + { + id: "second-replacer", + layer: "global", + order: 2, + merge: "replace", + replacementScope: "source:target", + content: "Second replacer.", + }, + ])).toThrow("already removed"); + }); + + test("fails closed when the target is ambiguous after normalization", () => { + expect(() => plan([ + { id: "shared_review", layer: "global", order: 0, content: "First target." }, + { id: "shared-review", layer: "global", order: 1, content: "Second target." }, + { + id: "replacer", + layer: "global", + order: 2, + merge: "replace", + replacementScope: "source:shared-review", + content: "Replacer.", + }, + ])).toThrow("ambiguous"); + }); + + test("fails closed on unknown replacement scope syntax", () => { + expect(() => plan([ + { id: "target", layer: "global", order: 0, content: "Target." }, + { + id: "replacer", + layer: "global", + order: 1, + merge: "replace", + replacementScope: "rule:target", + content: "Replacer.", + }, + ])).toThrow("replacement scope"); + }); + + test("fails closed when append mode carries a replacement scope", () => { + expect(() => plan([ + { id: "target", layer: "global", order: 0, content: "Target." }, + { + id: "replacer", + layer: "global", + order: 1, + merge: "append", + replacementScope: "source:target", + content: "Replacer.", + }, + ])).toThrow("append"); + }); + + test("fails closed when the targeted source is non-overridable", () => { + expect(() => plan([ + { + id: "protected-target", + layer: "global", + order: 0, + content: "Protected target.", + nonOverridable: true, + }, + { + id: "replacer", + layer: "global", + order: 1, + merge: "replace", + replacementScope: "source:protected-target", + content: "Replacer.", + }, + ])).toThrow("non-overridable"); + }); + + test("unscoped replace remains broad for compatibility", () => { + const result = plan([ + { id: "ordinary-a", layer: "global", order: 0, content: "Ordinary A." }, + { id: "ordinary-b", layer: "global", order: 1, content: "Ordinary B." }, + { + id: "protected", + layer: "global", + order: 2, + content: "Protected.", + nonOverridable: true, + }, + { id: "broad-replacer", layer: "global", order: 3, merge: "replace", content: "Broad." }, + ]); + + expect(result.manifest.sources.map((source) => source.id)).toEqual(["protected", "broad-replacer"]); + expect(result.manifest.skippedSources.map((source) => source.id)).toEqual(["ordinary-a", "ordinary-b"]); + }); + + test("fails when semantic-policy deduplication removed the target before replacement", () => { + expect(() => plan([ + { + id: OLDER_ID, + label: `Rules v${OLDER}`, + layer: "global", + order: 0, + content: rulesPayload(OLDER, "Older policy body."), + }, + { + id: NEWER_ID, + label: `Rules v${NEWER}`, + layer: "global", + order: 1, + content: rulesPayload(NEWER, "Newer policy body."), + }, + { + id: "replacer", + layer: "global", + order: 2, + merge: "replace", + replacementScope: "source:rules-9-9-8", + content: "Replacer.", + }, + ])).toThrow("deduplication"); + }); +}); diff --git a/src/lib/session-render.ts b/src/lib/session-render.ts index 804b886..9238d30 100644 --- a/src/lib/session-render.ts +++ b/src/lib/session-render.ts @@ -568,7 +568,7 @@ function sourceFingerprint(source: OrderedSessionInstructionSource): Record - SESSION_LAYER_RANK[a.resolvedLayer] - SESSION_LAYER_RANK[b.resolvedLayer] || - a.resolvedOrder - b.resolvedOrder || - a.id.localeCompare(b.id) - ); + const ordered = deduplicated.selected.sort(compareSessionInstructionSources); + validateTargetedReplacementSources(originalOrder, ordered, deduplicated.skipped); rejectDuplicateSourceSlugs(ordered); rejectDuplicateRulePaths(ordered); return { sources: ordered, skipped: deduplicated.skipped }; } +function compareSessionInstructionSources( + a: OrderedSessionInstructionSource, + b: OrderedSessionInstructionSource, +): number { + return SESSION_LAYER_RANK[a.resolvedLayer] - SESSION_LAYER_RANK[b.resolvedLayer] + || a.resolvedOrder - b.resolvedOrder + || a.id.localeCompare(b.id); +} + /** * Collapses sources that declare the same semantic policy down to one. * @@ -1074,7 +1084,90 @@ function filterProviderOnlyBlocks(content: string, tool: SessionRenderTool): str return output.join("\n"); } -function composeSources( +function targetedReplacementTarget(source: OrderedSessionInstructionSource): string | null { + if (source.replacementScope == null) return null; + if (source.resolvedMerge !== "replace") { + throw new Error( + `Session instruction source "${source.id}" uses replacement scope "${source.replacementScope}" ` + + `with merge=${source.resolvedMerge}; replacement scopes require merge=replace, not append.`, + ); + } + const scope = source.replacementScope.trim(); + if (!scope.startsWith("source:")) { + throw new Error( + `Invalid replacement scope "${source.replacementScope}" for source "${source.id}"; ` + + 'expected "source:".', + ); + } + const target = scope.slice("source:".length); + if (!target || target !== slug(target)) { + throw new Error( + `Invalid replacement scope "${source.replacementScope}" for source "${source.id}"; ` + + "the target must be a non-empty normalized source id.", + ); + } + return target; +} + +/** + * Validate scoped replacement against the graph before semantic-policy deduplication can + * make the requested target disappear. + * + * Deduplication remains intentionally before composition. A targeted replacement is a + * claim about one exact earlier source, though, so a dedupe loser cannot be reported as + * if the scoped replacer removed it. + */ +function validateTargetedReplacementSources( + originalSources: OrderedSessionInstructionSource[], + selectedSources: OrderedSessionInstructionSource[], + deduplicatedSources: SessionSkippedSource[], +): void { + for (let replacerIndex = 0; replacerIndex < originalSources.length; replacerIndex++) { + const replacer = originalSources[replacerIndex]!; + const targetNormalizedId = targetedReplacementTarget(replacer); + if (targetNormalizedId === null) continue; + + const matches = originalSources.filter((source) => source.normalizedId === targetNormalizedId); + if (matches.length === 0) { + throw new Error( + `Targeted replacement source "${replacer.id}" names missing source "${targetNormalizedId}".`, + ); + } + if (matches.length > 1) { + throw new Error( + `Targeted replacement source "${replacer.id}" is ambiguous after normalization: ` + + `"${targetNormalizedId}" matches ${matches.map((source) => `"${source.id}"`).join(", ")}.`, + ); + } + + const target = matches[0]!; + const targetIndex = originalSources.indexOf(target); + if (targetIndex >= replacerIndex) { + throw new Error( + `Targeted replacement source "${replacer.id}" must name an earlier source; ` + + `"${target.id}" is later than or identical to the replacer.`, + ); + } + if (target.nonOverridable) { + throw new Error( + `Targeted replacement source "${replacer.id}" cannot replace non-overridable source "${target.id}".`, + ); + } + if (!selectedSources.some((source) => source.id === replacer.id)) { + throw new Error( + `Targeted replacement source "${replacer.id}" was removed by semantic-policy deduplication before composition.`, + ); + } + if (deduplicatedSources.some((source) => source.id === target.id)) { + throw new Error( + `Targeted replacement source "${replacer.id}" cannot claim success because target "${target.id}" ` + + "was removed by semantic-policy deduplication before composition.", + ); + } + } +} + +function composeBroadReplaceSources( sources: OrderedSessionInstructionSource[], ): { sources: OrderedSessionInstructionSource[]; skipped: SessionSkippedSource[] } { let start = -1; @@ -1098,6 +1191,69 @@ function composeSources( return { sources: [...protectedSources, ...sources.slice(start)], skipped }; } +function composeSources( + sources: OrderedSessionInstructionSource[], +): { sources: OrderedSessionInstructionSource[]; skipped: SessionSkippedSource[] } { + const hasTargetedReplacement = sources.some((source) => source.replacementScope !== undefined); + if (!hasTargetedReplacement) return composeBroadReplaceSources(sources); + + const selected: OrderedSessionInstructionSource[] = []; + const skipped: SessionSkippedSource[] = []; + for (const source of sources) { + const targetNormalizedId = targetedReplacementTarget(source); + if (targetNormalizedId !== null) { + const targetIndex = selected.findIndex((candidate) => candidate.normalizedId === targetNormalizedId); + if (targetIndex < 0) { + throw new Error( + `Targeted replacement source "${source.id}" cannot replace "${targetNormalizedId}": ` + + "the earlier target was already removed.", + ); + } + const target = selected[targetIndex]!; + if (target.nonOverridable) { + throw new Error( + `Targeted replacement source "${source.id}" cannot replace non-overridable source "${target.id}".`, + ); + } + selected.splice(targetIndex, 1); + skipped.push(skippedSource( + target, + `superseded by "${source.id}": targeted replacement ${source.replacementScope} ` + + `removed exactly source "${target.id}"`, + )); + selected.push({ + ...source, + provenance: { + ...(source.provenance ?? {}), + targetedReplacement: { + scope: source.replacementScope, + targetSourceId: target.id, + targetNormalizedSourceId: target.normalizedId, + }, + }, + }); + continue; + } + + if (source.resolvedMerge === "replace") { + const retained = selected.filter((candidate) => candidate.nonOverridable); + for (const candidate of selected) { + if (candidate.nonOverridable) continue; + skipped.push(skippedSource( + candidate, + `superseded by "${source.id}": a replace-merge source discards earlier overridable instruction layers`, + )); + } + selected.length = 0; + selected.push(...retained, source); + continue; + } + + selected.push(source); + } + return { sources: selected, skipped }; +} + function sectionForSource(source: OrderedSessionInstructionSource): string { const parts = [ ``,