@@ -34,8 +34,12 @@ import {
3434 formatRecanonicalizeReport ,
3535 recanonicalizeStateKeys ,
3636} from "./recanonicalize.ts" ;
37- import { FOLDER_MAP , hashLocalResource , resolvePullScopeFromFilePaths } from "./resources.ts" ;
38- import { extractBaseSlug , slugify } from "./slug-utils.ts" ;
37+ import {
38+ FOLDER_MAP ,
39+ hashLocalResource ,
40+ resolvePullScopeFromFilePaths ,
41+ } from "./resources.ts" ;
42+ import { extractBaseSlug , isBackupCopyFile , slugify } from "./slug-utils.ts" ;
3943import { hashPayload , loadState , saveState , upsertState } from "./state.ts" ;
4044import type { ResourceState , ResourceType , StateFile } from "./types.ts" ;
4145
@@ -243,6 +247,11 @@ export function listExistingResourceIds(resourceType: ResourceType): string[] {
243247 continue ;
244248 }
245249
250+ // Dashboard-backup siblings are merge reference material, not resources.
251+ if ( isBackupCopyFile ( entry . name ) ) {
252+ continue ;
253+ }
254+
246255 const relativePath = relative ( dir , fullPath ) ;
247256 ids . push ( relativePath . replace ( / \. ( y m l | y a m l | m d ) $ / , "" ) ) ;
248257 }
@@ -408,6 +417,34 @@ async function writeResourceFile(
408417 return filePath ;
409418}
410419
420+ // Write the dashboard version of a resource as a sibling
421+ // `<resourceId>.<TIMESTAMP>.bkp.<ext>` next to the local file, in the same
422+ // serialized form a pull would produce — so a hand-merge diffs cleanly
423+ // against the local file. The timestamp keeps repeated conflicts from
424+ // overwriting earlier copies; the `.bkp.` infix keeps the file invisible to
425+ // all resource discovery (see `isBackupCopyFile`) and gitignored (`*.bkp.*`).
426+ // Used by the push conflict prompt's "create local copy + manual merge"
427+ // choice.
428+ export async function writeDashboardBackup (
429+ resourceType : ResourceType ,
430+ resourceId : string ,
431+ platformPayload : VapiResource ,
432+ state : StateFile ,
433+ ) : Promise < string > {
434+ const credReverse = credentialReverseMap ( state ) ;
435+ const withCredNames = canonicalizeForHash ( platformPayload , state , credReverse ) ;
436+ // Filesystem-safe ISO timestamp: 2026-06-04T19-22-33 (no colons, no ms).
437+ const timestamp = new Date ( )
438+ . toISOString ( )
439+ . replace ( / \. \d { 3 } Z $ / , "" )
440+ . replace ( / : / g, "-" ) ;
441+ return writeResourceFile (
442+ resourceType ,
443+ `${ resourceId } .${ timestamp } .bkp` ,
444+ withCredNames ,
445+ ) ;
446+ }
447+
411448// ─────────────────────────────────────────────────────────────────────────────
412449// Pull Functions
413450// ─────────────────────────────────────────────────────────────────────────────
@@ -418,7 +455,11 @@ export interface PullStats {
418455 skipped : number ;
419456}
420457
421- export type DriftResolveMode = "ours" | "theirs" | "fail" ;
458+ // `defer` preserves the local file AND the drift baseline (no rewrite, no
459+ // error): the conflict evidence survives the pull so push's per-resource
460+ // interactive prompt can ask about exactly the resources that diverged.
461+ // `ours`/`theirs`/`fail` keep their non-interactive (CI) semantics.
462+ export type DriftResolveMode = "ours" | "theirs" | "fail" | "defer" ;
422463
423464export interface BothDivergedResource {
424465 resourceType : ResourceType ;
@@ -446,9 +487,10 @@ function parseResolveMode(explicit?: DriftResolveMode): DriftResolveMode | undef
446487 const arg = process . argv . find ( ( a ) => a . startsWith ( "--resolve=" ) ) ;
447488 if ( ! arg ) return undefined ;
448489 const mode = arg . slice ( "--resolve=" . length ) ;
449- if ( mode === "ours" || mode === "theirs" || mode === "fail" ) return mode ;
490+ if ( mode === "ours" || mode === "theirs" || mode === "fail" || mode === "defer" )
491+ return mode ;
450492 throw new Error (
451- `Invalid --resolve value: ${ mode } . Use --resolve=ours|theirs|fail` ,
493+ `Invalid --resolve value: ${ mode } . Use --resolve=ours|theirs|fail|defer ` ,
452494 ) ;
453495}
454496
@@ -839,6 +881,21 @@ async function resolveBothDivergedResources(options: {
839881 const { state, bothDiverged, resolveMode } = options ;
840882 if ( bothDiverged . length === 0 ) return { exitCode : 0 } ;
841883
884+ if ( resolveMode === "defer" ) {
885+ // Leave everything as-is: local file preserved (the per-type loop already
886+ // skipped the write), baseline untouched. Push's per-resource drift
887+ // prompt will ask about exactly these resources.
888+ console . log (
889+ `\n ⏳ ${ bothDiverged . length } resource(s) have 3-way drift — deferred to push's per-resource prompt:` ,
890+ ) ;
891+ for ( const entry of bothDiverged ) {
892+ console . log (
893+ ` - ${ FOLDER_MAP [ entry . resourceType ] } /${ entry . resourceId } ` ,
894+ ) ;
895+ }
896+ return { exitCode : 0 } ;
897+ }
898+
842899 if ( resolveMode === "fail" ) {
843900 console . error (
844901 `\n❌ ${ bothDiverged . length } resource(s) have 3-way drift (--resolve=fail).` ,
0 commit comments