diff --git a/AGENTS.md b/AGENTS.md index a762602..e8e4a76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,32 @@ Runs the engine's local validators against every YAML/MD file in the org without - End-of-pull summary with counts per direction - A hard gate (`--resolve` flag) on 3-way conflicts before they silently lose data +When multiple resources are `both-diverged`, you can mix decisions in one pull: + +```bash +# Global mode for every both-diverged resource. +npm run pull -- --resolve=ours +npm run pull -- --resolve=theirs +npm run pull -- --resolve=fail + +# Per-resource modes in one command. +npm run pull -- \ + --resolve=assistants/intake=ours \ + --resolve=squads/main=theirs + +# Path-level override: choose a resource base, then override selected paths. +# This keeps the git copy of the assistant except for dashboard voice settings. +npm run pull -- \ + --resolve=assistants/intake=ours \ + --resolve-path=assistants/intake:voice=theirs +``` + +Path rules use dot paths, with numeric array indexes supported either as +`members.0.assistantId` or `members[0].assistantId`. Assistant markdown bodies +map to `model.messages` because pull parses `.md` resources into the same +object shape used for hashing. A path-level rule requires a per-resource or +global `ours` / `theirs` base so unspecified paths have an explicit owner. + `--force` skips all of this and just overwrites local with dashboard. Use it ONLY when you literally need to nuke local and re-materialize dashboard truth (rare). Plain pull is the DEFAULT for both humans and agents; `--force` is the escape hatch. **Pull-output icon legend.** Distinct semantics in a single pulled-resource line: @@ -110,6 +136,7 @@ Runs the engine's local validators against every YAML/MD file in the org without | `✏️` | Locally modified file detected by git, preserved as-is | | `⬆️` | `local-ahead` — local has unpushed edits, needs to flow UP to dashboard (preserved) | | `⬇️` | `--resolve=theirs` — overwrote local with dashboard (flowed DOWN) | +| `🔀` | Mixed path resolution — one resource merged dashboard and git-selected paths | | `🔒` | Platform-default resource (read-only, immutable) | | `🚫` | Matched `.vapi-ignore` (not tracked locally) | | `🗑️` | Locally deleted (deletion intent recorded in state) | @@ -992,4 +1019,3 @@ When transferring to human: 3. Create simulations (pair personality + scenario) 4. Create suites (batch simulations together) 5. Run via Vapi dashboard or API - diff --git a/README.md b/README.md index 459c828..f840f19 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,29 @@ npm run pull -- --bootstrap npm run pull -- --type assistants --id ``` +When `pull` reports `both-diverged`, choose a whole-run default, mix decisions +per resource, or merge selected paths: + +```bash +# Same decision for every both-diverged resource. +npm run pull -- --resolve=ours +npm run pull -- --resolve=theirs + +# Different decisions in one run. +npm run pull -- \ + --resolve=assistants/intake=ours \ + --resolve=squads/main=theirs + +# Keep git as the base, but take dashboard voice settings. +npm run pull -- \ + --resolve=assistants/intake=ours \ + --resolve-path=assistants/intake:voice=theirs +``` + +Path rules use the parsed resource object shape. For assistants, the Markdown +body is represented as the system message under `model.messages`; squad arrays +can be addressed with indexes such as `members[0].assistantId`. + ### Live testing what you just deployed ```bash diff --git a/improvements.md b/improvements.md index 405492e..4aeb2c5 100644 --- a/improvements.md +++ b/improvements.md @@ -73,6 +73,7 @@ you which stack PR closes the row.** | 19 | No `maxTokens` floor warning for tool-using assistants | `maxTokens: 1` bricks the assistant silently | None | RESOLVED 2026-04-30 (Stack D) | | 20 | Prompt vocabulary leaks into TTS | `Reason.` becomes verbal contaminant | None | Partial — Stack D heuristic | | 21 | `.vapi-ignore` was pull-only (push could silently delete) | `--force` push DELETEd dashboard-only opt-outs | None | RESOLVED 2026-05-11 (#TBD) | +| 22 | Granular `both-diverged` resolution | Mix dashboard/git choices without manual hand-merge | #4 | RESOLVED 2026-05-29 (#TBD) | --- @@ -1030,6 +1031,58 @@ RESOLVED 2026-05-11 (#TBD — PR number updates when opened). --- +## 22. Granular `both-diverged` resolution was whole-run only + +**[RESOLVED 2026-05-29] (#TBD)** + +**Discovered:** PRISM-852, from the mudflap-prod iForm drift incident on +2026-05-26. Squad and assistant resources diverged after dashboard pushes +and local bucket edits, and the operator had to use coarse overwrite/manual +merge choices to ship. + +### Problem + +`pull` could classify resources as `both-diverged`, but conflict resolution +was whole-run and whole-resource only. Operators could not take dashboard +`voice` while keeping git `model.messages`, or resolve one squad from the +dashboard and another assistant from git in a single command. + +### Current behavior (Verified) + +- Whole-run defaults still work: `--resolve=ours|theirs|fail`. +- Per-resource resolution is supported with repeatable flags: + `--resolve=assistants/intake=ours --resolve=squads/main=theirs`. +- Path-level resolution is supported by choosing a resource base and then + overriding parsed object paths: + `--resolve=assistants/intake=ours --resolve-path=assistants/intake:voice=theirs`. +- Path rules support dot paths and numeric array indexes, including + `members[0].assistantId`. Assistant Markdown bodies are parsed into + `model.messages`, so prompt/body resolution uses the same object shape as + the hash pipeline. +- Path-level mixed writes set `lastPulledHash` to the platform hash observed + during resolution. If the merged local file still differs from the full + dashboard payload, the next pull classifies it as `local-ahead`, not a + phantom `both-diverged`. + +### Risk + +Without granular resolution, a safe sync can force broad choices: lose +dashboard changes, lose git changes, or hand-merge resource files manually +while preserving the engine's hash invariants by memory. + +### Resolution + +Added a pure drift-resolution parser/merger and wired it into `pull` after +all `both-diverged` resources are collected, before any conflict writes occur. +If any conflict lacks an explicit mode, the pull exits before applying scoped +resolutions. Test coverage lives in `tests/drift-resolve.test.ts`. + +### Status + +RESOLVED 2026-05-29 (#TBD — PR number updates when opened). + +--- + ## Out of scope (intentionally not improvements) - **State file is identity-only and not git-ignored.** It's intentionally diff --git a/src/drift-resolve.ts b/src/drift-resolve.ts new file mode 100644 index 0000000..5bfc3f1 --- /dev/null +++ b/src/drift-resolve.ts @@ -0,0 +1,292 @@ +import { type ResourceType, VALID_RESOURCE_TYPES } from "./types.ts"; + +export type DriftResolveMode = "ours" | "theirs" | "fail"; +export type PathResolveMode = Exclude; + +export interface DriftPathRule { + path: string; + mode: PathResolveMode; +} + +export interface DriftResolveSelection { + defaultMode?: DriftResolveMode; + perResource: Map; + perPath: Map; +} + +export function resourceResolveKey( + resourceType: ResourceType, + resourceId: string, +): string { + return `${resourceType}/${resourceId}`; +} + +export function formatResolveUsage(): string { + return ( + "Use --resolve=ours|theirs|fail, " + + "--resolve=/=ours|theirs|fail, or " + + "--resolve-path=/:=ours|theirs" + ); +} + +function parseMode(value: string): DriftResolveMode { + if (value === "ours" || value === "theirs" || value === "fail") { + return value; + } + throw new Error(`Invalid resolve mode: ${value}. ${formatResolveUsage()}`); +} + +function parsePathMode(value: string): PathResolveMode { + const mode = parseMode(value); + if (mode === "fail") { + throw new Error(`Invalid path resolve mode: fail. ${formatResolveUsage()}`); + } + return mode; +} + +function parseResourceRef(ref: string): { + resourceType: ResourceType; + resourceId: string; +} { + const slash = ref.indexOf("/"); + if (slash <= 0 || slash === ref.length - 1) { + throw new Error(`Invalid resolve target: ${ref}. ${formatResolveUsage()}`); + } + + const resourceType = ref.slice(0, slash); + const resourceId = ref.slice(slash + 1); + if (!VALID_RESOURCE_TYPES.includes(resourceType as ResourceType)) { + throw new Error( + `Invalid resolve resource type: ${resourceType}. ` + + `Expected one of: ${VALID_RESOURCE_TYPES.join(", ")}`, + ); + } + + return { resourceType: resourceType as ResourceType, resourceId }; +} + +function setUnique( + map: Map, + key: K, + value: V, + label: string, +): void { + if (map.has(key)) { + throw new Error(`Duplicate ${label}: ${String(key)}`); + } + map.set(key, value); +} + +export function parseDriftResolveSelection( + args: string[], + explicitDefaultMode?: DriftResolveMode, +): DriftResolveSelection { + const selection: DriftResolveSelection = { + defaultMode: explicitDefaultMode, + perResource: new Map(), + perPath: new Map(), + }; + + for (const arg of args) { + if (arg.startsWith("--resolve-path=")) { + const spec = arg.slice("--resolve-path=".length); + const modeSeparator = spec.lastIndexOf("="); + const pathSeparator = spec.indexOf(":"); + if ( + modeSeparator <= 0 || + pathSeparator <= 0 || + pathSeparator > modeSeparator + ) { + throw new Error( + `Invalid --resolve-path value: ${spec}. ${formatResolveUsage()}`, + ); + } + + const target = spec.slice(0, pathSeparator); + const path = spec.slice(pathSeparator + 1, modeSeparator); + const mode = parsePathMode(spec.slice(modeSeparator + 1)); + if (!path) { + throw new Error( + `Invalid --resolve-path value: ${spec}. Path is required.`, + ); + } + + const { resourceType, resourceId } = parseResourceRef(target); + const key = resourceResolveKey(resourceType, resourceId); + const rules = selection.perPath.get(key) ?? []; + if (rules.some((rule) => rule.path === path)) { + throw new Error(`Duplicate path resolve rule: ${key}:${path}`); + } + rules.push({ path, mode }); + selection.perPath.set(key, rules); + continue; + } + + if (!arg.startsWith("--resolve=")) continue; + + const spec = arg.slice("--resolve=".length); + const separator = spec.lastIndexOf("="); + if (separator === -1) { + const mode = parseMode(spec); + if (selection.defaultMode && selection.defaultMode !== mode) { + throw new Error( + `Duplicate global --resolve mode: ${selection.defaultMode} and ${mode}`, + ); + } + selection.defaultMode = mode; + continue; + } + + const target = spec.slice(0, separator); + const mode = parseMode(spec.slice(separator + 1)); + const { resourceType, resourceId } = parseResourceRef(target); + const key = resourceResolveKey(resourceType, resourceId); + setUnique(selection.perResource, key, mode, "resource resolve rule"); + } + + return selection; +} + +function cloneJson(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +function parsePath(path: string): Array { + return path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter(Boolean) + .map((part) => (/^\d+$/.test(part) ? Number(part) : part)); +} + +function getPath( + value: Record, + path: Array, +): { exists: boolean; value?: unknown } { + let current: unknown = value; + for (const segment of path) { + if (Array.isArray(current) && typeof segment === "number") { + if (segment < 0 || segment >= current.length) return { exists: false }; + current = current[segment]; + continue; + } + if ( + current && + typeof current === "object" && + !Array.isArray(current) && + typeof segment === "string" && + Object.prototype.hasOwnProperty.call(current, segment) + ) { + current = (current as Record)[segment]; + continue; + } + return { exists: false }; + } + return { exists: true, value: current }; +} + +function deletePath( + target: Record, + path: Array, +): void { + if (path.length === 0) return; + let current: unknown = target; + for (const segment of path.slice(0, -1)) { + if (Array.isArray(current) && typeof segment === "number") { + current = current[segment]; + } else if ( + current && + typeof current === "object" && + !Array.isArray(current) && + typeof segment === "string" + ) { + current = (current as Record)[segment]; + } else { + return; + } + if (current === undefined || current === null) return; + } + + const leaf = path[path.length - 1]; + if (Array.isArray(current) && typeof leaf === "number") { + current.splice(leaf, 1); + } else if ( + current && + typeof current === "object" && + !Array.isArray(current) && + typeof leaf === "string" + ) { + delete (current as Record)[leaf]; + } +} + +function setPath( + target: Record, + path: Array, + value: unknown, +): void { + if (path.length === 0) return; + let current: unknown = target; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]!; + const next = path[i + 1]!; + if (Array.isArray(current) && typeof segment === "number") { + current[segment] ??= typeof next === "number" ? [] : {}; + current = current[segment]; + continue; + } + if ( + current && + typeof current === "object" && + !Array.isArray(current) && + typeof segment === "string" + ) { + const object = current as Record; + object[segment] ??= typeof next === "number" ? [] : {}; + current = object[segment]; + continue; + } + throw new Error( + `Cannot set path through non-object segment: ${String(segment)}`, + ); + } + + const leaf = path[path.length - 1]; + if (Array.isArray(current) && typeof leaf === "number") { + current[leaf] = cloneJson(value); + return; + } + if ( + current && + typeof current === "object" && + !Array.isArray(current) && + typeof leaf === "string" + ) { + (current as Record)[leaf] = cloneJson(value); + return; + } + throw new Error(`Cannot set path on non-object leaf: ${String(leaf)}`); +} + +export function mergeResourceByPathRules(options: { + localData: Record; + platformData: Record; + baseMode: PathResolveMode; + rules: DriftPathRule[]; +}): Record { + const { localData, platformData, baseMode, rules } = options; + const merged = cloneJson(baseMode === "ours" ? localData : platformData); + + for (const rule of rules) { + const source = rule.mode === "ours" ? localData : platformData; + const parsedPath = parsePath(rule.path); + const sourceValue = getPath(source, parsedPath); + if (sourceValue.exists) { + setPath(merged, parsedPath, sourceValue.value); + } else { + deletePath(merged, parsedPath); + } + } + + return merged; +} diff --git a/src/drift.ts b/src/drift.ts index adf519b..719089f 100644 --- a/src/drift.ts +++ b/src/drift.ts @@ -53,7 +53,7 @@ export function formatDriftLabel(direction: DriftDirection): string { case "local-ahead": return "[local-ahead — run npm run push to propagate local edits up]"; case "both-diverged": - return "[both-diverged — 3-way conflict, pass --resolve=ours|theirs|fail]"; + return "[both-diverged — 3-way conflict, pass --resolve=ours|theirs|fail or scoped --resolve=/=ours|theirs]"; case "no-baseline": return "[direction unknown — no lastPulledHash baseline; pull --bootstrap first]"; case "clean": diff --git a/src/pull-cmd.ts b/src/pull-cmd.ts index 01df2a8..5487551 100644 --- a/src/pull-cmd.ts +++ b/src/pull-cmd.ts @@ -2,8 +2,8 @@ // - With slug: forwards to pull.ts (existing non-interactive behavior) // - Without slug: enters interactive mode (org selection + resource picker) // -// Pull flags (--force, --bootstrap, --resolve=ours|theirs|fail) are parsed -// inside runPull from process.argv, same as --force. +// Pull flags (--force, --bootstrap, --resolve=..., --resolve-path=...) are +// parsed inside runPull from process.argv, same as --force. const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const arg = process.argv[2]; diff --git a/src/pull.ts b/src/pull.ts index 3eeda25..c1b3632 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -18,14 +18,26 @@ import { import { credentialReverseMap, replaceCredentialRefs } from "./credentials.ts"; import { classifyDrift, - formatDriftLabel, type DriftDirection, + formatDriftLabel, } from "./drift.ts"; +import { + type DriftResolveMode, + type DriftResolveSelection, + formatResolveUsage, + mergeResourceByPathRules, + parseDriftResolveSelection, + resourceResolveKey, +} from "./drift-resolve.ts"; import { formatRecanonicalizeReport, recanonicalizeStateKeys, } from "./recanonicalize.ts"; -import { FOLDER_MAP, hashLocalResource } from "./resources.ts"; +import { + FOLDER_MAP, + hashLocalResource, + loadLocalResourceData, +} from "./resources.ts"; import { extractBaseSlug, slugify } from "./slug-utils.ts"; import { hashPayload, loadState, saveState, upsertState } from "./state.ts"; import type { ResourceState, ResourceType, StateFile } from "./types.ts"; @@ -649,8 +661,6 @@ export interface PullStats { skipped: number; } -export type DriftResolveMode = "ours" | "theirs" | "fail"; - export interface BothDivergedResource { resourceType: ResourceType; resourceId: string; @@ -672,15 +682,10 @@ function emptyDriftCounts(): DriftDirectionCounts { }; } -function parseResolveMode(explicit?: DriftResolveMode): DriftResolveMode | undefined { - if (explicit) return explicit; - const arg = process.argv.find((a) => a.startsWith("--resolve=")); - if (!arg) return undefined; - const mode = arg.slice("--resolve=".length); - if (mode === "ours" || mode === "theirs" || mode === "fail") return mode; - throw new Error( - `Invalid --resolve value: ${mode}. Use --resolve=ours|theirs|fail`, - ); +function parseResolveSelection( + explicit?: DriftResolveMode, +): DriftResolveSelection { + return parseDriftResolveSelection(process.argv.slice(3), explicit); } function sleepMs(ms: number): Promise { @@ -1071,16 +1076,30 @@ export async function pullResourceType( async function resolveBothDivergedResources(options: { state: StateFile; bothDiverged: BothDivergedResource[]; - resolveMode?: DriftResolveMode; + resolveSelection: DriftResolveSelection; }): Promise<{ exitCode: number }> { - const { state, bothDiverged, resolveMode } = options; + const { state, bothDiverged, resolveSelection } = options; if (bothDiverged.length === 0) return { exitCode: 0 }; - if (resolveMode === "fail") { + const entriesWithModes = bothDiverged.map((entry) => { + const key = resourceResolveKey(entry.resourceType, entry.resourceId); + return { + entry, + key, + mode: + resolveSelection.perResource.get(key) ?? resolveSelection.defaultMode, + pathRules: resolveSelection.perPath.get(key) ?? [], + }; + }); + + const explicitFailures = entriesWithModes.filter( + ({ mode }) => mode === "fail", + ); + if (explicitFailures.length > 0) { console.error( - `\n❌ ${bothDiverged.length} resource(s) have 3-way drift (--resolve=fail).`, + `\n❌ ${explicitFailures.length} resource(s) have 3-way drift with fail resolution.`, ); - for (const entry of bothDiverged) { + for (const { entry } of explicitFailures) { console.error( ` - ${entry.resourceType}/${entry.resourceId}\n` + ` local-hash: ${entry.localHash.slice(0, 8)}… platform-hash: ${entry.platformHash.slice(0, 8)}… last-pulled: ${entry.lastPulledHash.slice(0, 8)}…`, @@ -1089,14 +1108,13 @@ async function resolveBothDivergedResources(options: { return { exitCode: 1 }; } - if (!resolveMode) { + const unresolved = entriesWithModes.filter(({ mode }) => !mode); + if (unresolved.length > 0) { console.error( - `\n❌ ${bothDiverged.length} resource(s) have 3-way drift (both local and dashboard changed since last pull).`, + `\n❌ ${unresolved.length} resource(s) have 3-way drift (both local and dashboard changed since last pull).`, ); - console.error( - " Pass --resolve=ours|theirs|fail to proceed:", - ); - for (const entry of bothDiverged) { + console.error(` ${formatResolveUsage()}:`); + for (const { entry } of unresolved) { console.error( ` - ${FOLDER_MAP[entry.resourceType]}/${entry.resourceId}\n` + ` local-hash: ${entry.localHash.slice(0, 8)}… platform-hash: ${entry.platformHash.slice(0, 8)}… last-pulled: ${entry.lastPulledHash.slice(0, 8)}…`, @@ -1111,16 +1129,71 @@ async function resolveBothDivergedResources(options: { console.error( " --resolve=fail exit non-zero without writing anything (CI mode — fail the build so a human investigates)", ); + console.error( + "\n --resolve=assistants/intake=ours keep git for one resource", + ); + console.error( + " --resolve-path=assistants/intake:voice=theirs take one dashboard path after choosing a resource base", + ); return { exitCode: 1 }; } const credReverse = credentialReverseMap(state); - for (const entry of bothDiverged) { + for (const { entry, key, mode, pathRules } of entriesWithModes) { const section = state[entry.resourceType]; - if (resolveMode === "ours") { + if (pathRules.length > 0) { + if (mode !== "ours" && mode !== "theirs") { + throw new Error( + `Path-level resolution for ${key} requires --resolve=${key}=ours|theirs as a base.`, + ); + } + + const localData = loadLocalResourceData( + entry.resourceType, + entry.resourceId, + ); + if (!localData) { + throw new Error( + `Path-level resolution for ${key} requires an existing local resource file.`, + ); + } + const platformData = canonicalizeForHash( + entry.resource, + state, + credReverse, + ); + const merged = mergeResourceByPathRules({ + localData, + platformData, + baseMode: mode, + rules: pathRules, + }); + + await writeResourceFile(entry.resourceType, entry.resourceId, merged); console.log( - ` ⬆️ ${entry.resourceId} (both diverged — resolving with --resolve=ours, preserving local) ${formatDriftLabel("both-diverged")}`, + ` 🔀 ${entry.resourceId} (both diverged — resolving ${pathRules.length} path(s) on --resolve=${key}=${mode}) ${formatDriftLabel("both-diverged")}`, + ); + const mergedHash = hashPayload(merged); + const diskHash = hashLocalResource(entry.resourceType, entry.resourceId); + // If the selected paths reconstruct the full dashboard payload, keep the + // usual post-write disk baseline. Otherwise, preserve the observed + // platform baseline so the next pull reports the merged file as + // local-ahead instead of repeating both-diverged. + upsertState(section, entry.resourceId, { + uuid: entry.resource.id, + lastPulledHash: + mergedHash === entry.platformHash && diskHash + ? diskHash + : entry.platformHash, + lastPulledAt: new Date().toISOString(), + }); + continue; + } + + if (mode === "ours") { + console.log( + ` ⬆️ ${entry.resourceId} (both diverged — resolving with --resolve=${key}=ours, preserving local) ${formatDriftLabel("both-diverged")}`, ); // No write — local file is preserved. lastPulledHash = entry.platformHash // marks "the last-pulled baseline was the platform state at resolve time, @@ -1134,7 +1207,11 @@ async function resolveBothDivergedResources(options: { continue; } - const withCredNames = canonicalizeForHash(entry.resource, state, credReverse); + const withCredNames = canonicalizeForHash( + entry.resource, + state, + credReverse, + ); await writeResourceFile( entry.resourceType, @@ -1142,7 +1219,7 @@ async function resolveBothDivergedResources(options: { withCredNames, ); console.log( - ` ⬇️ ${entry.resourceId} (both diverged — resolving with --resolve=theirs, overwriting local with platform) ${formatDriftLabel("both-diverged")}`, + ` ⬇️ ${entry.resourceId} (both diverged — resolving with --resolve=${key}=theirs, overwriting local with platform) ${formatDriftLabel("both-diverged")}`, ); // Hash the post-write disk form (same invariant as the normal pull-write path). const diskHash = hashLocalResource(entry.resourceType, entry.resourceId); @@ -1195,7 +1272,7 @@ export async function runPull(options: PullOptions = {}): Promise { const bootstrap = options.bootstrap ?? BOOTSTRAP_SYNC; const typeFilter = options.typeFilter ?? APPLY_FILTER.resourceTypes; const resourceIds = options.resourceIds ?? APPLY_FILTER.resourceIds; - const resolveMode = parseResolveMode(options.resolveMode); + const resolveSelection = parseResolveSelection(options.resolveMode); const driftCounts = emptyDriftCounts(); const bothDivergedResources: BothDivergedResource[] = []; @@ -1324,7 +1401,11 @@ export async function runPull(options: PullOptions = {}): Promise { if (shouldPull("squads")) stats.squads = await pullResourceType("squads", state, pullOpts); if (shouldPull("personalities")) - stats.personalities = await pullResourceType("personalities", state, pullOpts); + stats.personalities = await pullResourceType( + "personalities", + state, + pullOpts, + ); if (shouldPull("scenarios")) stats.scenarios = await pullResourceType("scenarios", state, pullOpts); if (shouldPull("simulations")) @@ -1361,7 +1442,7 @@ export async function runPull(options: PullOptions = {}): Promise { const resolveResult = await resolveBothDivergedResources({ state, bothDiverged: bothDivergedResources, - resolveMode, + resolveSelection, }); await saveState(state); @@ -1393,7 +1474,10 @@ export async function runPull(options: PullOptions = {}): Promise { console.log(" 🚫 = matched .vapi-ignore (not tracked)"); console.log(" ✏️ = locally modified (preserved)"); console.log(" ⬆️ = local ahead of dashboard (preserved)"); - console.log(" ⬇️ = both diverged, --resolve=theirs (overwrote local)"); + console.log( + " ⬇️ = both diverged, --resolve=theirs (overwrote local)", + ); + console.log(" 🔀 = both diverged, path-level mixed resolution"); console.log(" 📝 = engine wrote/updated file on disk"); console.log(" 🗑️ = locally deleted (intent in state)"); console.log( @@ -1411,7 +1495,7 @@ export async function runPull(options: PullOptions = {}): Promise { "\n💡 Tip: run plain pull first (this) to see what changed before resorting to --force.", ); console.log( - " --force is for \"I know exactly what I want from the dashboard and I'm overwriting locals\" — rare.", + ' --force is for "I know exactly what I want from the dashboard and I\'m overwriting locals" — rare.', ); } } diff --git a/src/resources.ts b/src/resources.ts index 384d741..40811ca 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -132,6 +132,15 @@ function findLocalResourceFile( return undefined; } +export function loadLocalResourceData( + type: ResourceType, + resourceId: string, +): Record | null { + const filePath = findLocalResourceFile(type, resourceId); + if (!filePath) return null; + return parseResourceDataFromFile(filePath); +} + /** Stable content hash of a local resource file (same basis as lastPulledHash). */ export function hashLocalResource( type: ResourceType, diff --git a/tests/drift-resolve.test.ts b/tests/drift-resolve.test.ts new file mode 100644 index 0000000..ed21063 --- /dev/null +++ b/tests/drift-resolve.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + mergeResourceByPathRules, + parseDriftResolveSelection, + resourceResolveKey, +} from "../src/drift-resolve.ts"; + +test("parseDriftResolveSelection: supports mixed global and per-resource modes", () => { + const selection = parseDriftResolveSelection([ + "--resolve=fail", + "--resolve=assistants/intake=ours", + "--resolve=squads/main=theirs", + ]); + + assert.equal(selection.defaultMode, "fail"); + assert.equal(selection.perResource.get("assistants/intake"), "ours"); + assert.equal(selection.perResource.get("squads/main"), "theirs"); +}); + +test("parseDriftResolveSelection: supports per-path overrides", () => { + const selection = parseDriftResolveSelection([ + "--resolve=assistants/intake=ours", + "--resolve-path=assistants/intake:voice=theirs", + "--resolve-path=assistants/intake:model.messages=ours", + ]); + + const rules = selection.perPath.get( + resourceResolveKey("assistants", "intake"), + ); + assert.deepEqual(rules, [ + { path: "voice", mode: "theirs" }, + { path: "model.messages", mode: "ours" }, + ]); +}); + +test("parseDriftResolveSelection: rejects invalid path fail mode", () => { + assert.throws( + () => + parseDriftResolveSelection([ + "--resolve-path=assistants/intake:voice=fail", + ]), + /Invalid path resolve mode/, + ); +}); + +test("mergeResourceByPathRules: keeps local base and takes selected dashboard path", () => { + const merged = mergeResourceByPathRules({ + baseMode: "ours", + localData: { + name: "Intake", + voice: { provider: "11labs", voiceId: "old" }, + model: { messages: [{ role: "system", content: "local prompt" }] }, + }, + platformData: { + name: "Intake", + voice: { provider: "cartesia", voiceId: "new" }, + model: { messages: [{ role: "system", content: "dashboard prompt" }] }, + }, + rules: [{ path: "voice", mode: "theirs" }], + }); + + assert.deepEqual(merged, { + name: "Intake", + voice: { provider: "cartesia", voiceId: "new" }, + model: { messages: [{ role: "system", content: "local prompt" }] }, + }); +}); + +test("mergeResourceByPathRules: handles array path segments for squad members", () => { + const merged = mergeResourceByPathRules({ + baseMode: "theirs", + localData: { + members: [ + { + assistantId: "local-assistant", + assistantOverrides: { variableValues: { bucket: "git" } }, + }, + ], + }, + platformData: { + members: [ + { + assistantId: "dashboard-assistant", + assistantOverrides: { variableValues: { bucket: "dashboard" } }, + }, + ], + }, + rules: [{ path: "members[0].assistantId", mode: "ours" }], + }); + + assert.deepEqual(merged, { + members: [ + { + assistantId: "local-assistant", + assistantOverrides: { variableValues: { bucket: "dashboard" } }, + }, + ], + }); +});