trekoon 0.3.3 → 0.3.4

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.
@@ -4,90 +4,73 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
4
4
  const QUICKSTART_TEXT = [
5
5
  "Trekoon quickstart",
6
6
  "",
7
- "This quickstart is aligned with .agents/skills/trekoon/SKILL.md.",
8
- "For agents: always use --toon for every command.",
7
+ "Agents: always use --toon on every command.",
8
+ "Aligned with: .agents/skills/trekoon/SKILL.md",
9
9
  "",
10
- "1) Shared storage model",
11
- "- In git repos and worktrees, Trekoon resolves one repo-scoped .trekoon directory.",
12
- "- Every worktree for the same repo points at the same .trekoon/trekoon.db file.",
13
- "- Keep .trekoon gitignored: the SQLite DB is live operational state, not source.",
14
- "- Committing the DB is the wrong fix for setup drift because it snapshots machine-local state.",
10
+ "1) Storage model",
11
+ " In git repos and worktrees, Trekoon keeps one shared .trekoon directory",
12
+ " and one shared .trekoon/trekoon.db database per repository.",
13
+ " Keep .trekoon gitignored. The DB is live state, not source code.",
14
+ " Don't commit the DB; that snapshots machine-local state and won't help.",
15
15
  "",
16
- "2) Bootstrap and agent startup (primary)",
17
- "- Single call: trekoon --toon session",
18
- "- Replaces: init + sync status + task next + dep list + task show in one call.",
19
- "- Returns diagnostics, next ready task, its dependencies, and full task payload.",
20
- "- If diagnostics show recoveryRequired or a tracked/ignored mismatch, stop and repair setup.",
21
- "- Do not continue after storage mismatch, ambiguous recovery, or broken bootstrap warnings.",
22
- "- In worktrees, confirm meta.storageRootDiagnostics.sharedStorageRoot matches the repo root.",
16
+ "2) Agent startup",
17
+ " Single call: trekoon --toon session",
18
+ " Returns diagnostics, sync status, next ready task, its dependencies,",
19
+ " and the full task payload. Replaces init + sync status + task next in one call.",
23
20
  "",
24
- "2a) Bootstrap (manual/legacy use session instead)",
25
- "- 1. Initialize or verify shared storage: trekoon --toon init",
26
- "- 2. Read machine diagnostics: trekoon --toon sync status",
27
- "- 3. Select next task: trekoon --toon task next",
28
- "- 4. Check dependencies: trekoon --toon dep list <task-id>",
29
- "- 5. Show full task: trekoon --toon task show <task-id> --all",
21
+ " If diagnostics show recoveryRequired or a tracked/ignored mismatch,",
22
+ " stop and fix the setup before continuing.",
30
23
  "",
31
- "3) AI execution loop (deterministic, dependency-aware)",
32
- "- 1. Start or resume session: trekoon --toon session",
33
- "- 2. Claim work: trekoon --toon task update <task-id> --status in_progress",
34
- "- 3. Complete with context: trekoon --toon task done <task-id> --append \"Completed implementation\"",
35
- " - Replaces: update status done + task next + dep list + task show in one call.",
36
- " - Returns next ready task, its dependencies, and full task payload.",
37
- "- 4. Or report block: trekoon --toon task update <task-id> --append \"Blocked by <reason>\" --status blocked",
24
+ " Manual bootstrap (step by step):",
25
+ " trekoon --toon init",
26
+ " trekoon --toon sync status",
27
+ " trekoon --toon task next",
38
28
  "",
39
- "4) Worktree diagnostics and recovery",
40
- "- Read machine fields when available: storageMode, repoCommonDir, worktreeRoot, sharedStorageRoot, databaseFile.",
41
- "- sharedStorageRoot differs from worktreeRoot in linked worktrees; that is expected and should be visible.",
42
- "- If recoveryRequired is true, run trekoon --toon init and follow the reported recovery action before more commands.",
43
- "- If tracked and ignored storage disagree, delete the bad fix in Git and re-bootstrap local storage instead.",
29
+ "3) Execution loop",
30
+ " 1. Start session: trekoon --toon session [--epic <epic-id>]",
31
+ " 2. Claim work: trekoon --toon task update <task-id> --status in_progress",
32
+ " 3. Log progress: trekoon --toon task update <task-id> --append \"Done with implementation\"",
33
+ " 4. Finish: trekoon --toon task done <task-id>",
34
+ " Returns the next ready task, its deps, and full payload.",
35
+ " 5. Or report block: trekoon --toon task update <task-id> --append \"Blocked: <reason>\" --status blocked",
44
36
  "",
45
- "5) Power-user command patterns (aligned with skill)",
46
- "- Inspect full epic tree: trekoon --toon epic show <epic-id> --all",
47
- "- Inspect full task payload: trekoon --toon task show <task-id> --all",
48
- "- Check direct dependencies before starting: trekoon --toon dep list <task-id>",
49
- "- Find what this item unblocks: trekoon --toon dep reverse <task-or-subtask-id>",
50
- "- Filter list explicitly: trekoon --toon task list --status in_progress,todo --limit 20",
51
- "- Paginate deterministically: trekoon --toon task list --cursor <n>",
52
- "- Bulk append/status update: trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
37
+ "4) Orientation and suggestions",
38
+ " Next-action suggestions: trekoon --toon suggest [--epic <epic-id>]",
39
+ " Epic progress: trekoon --toon epic progress <epic-id>",
40
+ " Open the board: trekoon board open",
53
41
  "",
54
- "6) Task details and description",
55
- "- Human list and show views default to table format.",
56
- "- Alternate list view: add --view compact.",
57
- "- task/epic/subtask list defaults: open work only (in_progress/in-progress, todo), max 10.",
58
- "- Filter list by status: --status in_progress,todo (CSV).",
59
- "- Change page size: --limit <n>. Show all statuses and all rows with --all.",
60
- "- Continue pagination with --cursor <n> (offset-like list position).",
61
- "- --all cannot be combined with --status, --limit, or --cursor.",
62
- "- Bulk update: use --all or --ids <csv> with --append and/or --status.",
63
- "- Bulk update rejects positional id, and --all/--ids cannot be combined.",
64
- "- Ready queue: trekoon task ready [--limit <n>] [--epic <id>] (deterministic order).",
65
- "- Next execution candidate: trekoon task next [--epic <id>]",
66
- "- Full tree + descriptions (canonical): trekoon --toon epic show <epic-id> --all",
67
- "- Full task payload (including description): trekoon --toon task show <task-id> --all",
68
- "- Optional integration format: trekoon --json task show <task-id> --all",
42
+ "5) Common commands",
43
+ " Full epic tree: trekoon --toon epic show <epic-id> --all",
44
+ " Full task payload: trekoon --toon task show <task-id> --all",
45
+ " Check dependencies: trekoon --toon dep list <task-id>",
46
+ " What does this unblock: trekoon --toon dep reverse <task-or-subtask-id>",
47
+ " Filtered list: trekoon --toon task list --status in_progress,todo --limit 20",
48
+ " Paginate: trekoon --toon task list --cursor <n>",
49
+ " Bulk update: trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
50
+ " Ready queue: trekoon --toon task ready [--limit <n>] [--epic <id>]",
51
+ " Next candidate: trekoon --toon task next [--epic <id>]",
69
52
  "",
70
- "7) Pre-merge sync flow",
71
- "- Run: trekoon --toon sync status",
72
- "- Pull upstream tracker events: trekoon --toon sync pull --from main",
73
- "- Resolve conflicts if needed: trekoon --toon sync resolve <id> --use ours",
74
- "- Run sync status again before opening or merging a PR.",
53
+ "6) List and view defaults",
54
+ " Default scope: open work (in_progress, todo), limit 10.",
55
+ " Filters: --status <csv>, --limit <n>, --cursor <n>, or --all for everything.",
56
+ " Views: --view table (default) or --view compact.",
57
+ " --all cannot be combined with --status, --limit, or --cursor.",
75
58
  "",
76
- "8) Shared-storage wipe warning",
77
- "- trekoon wipe --yes removes the shared repo-scoped .trekoon directory for every worktree in the repo.",
78
- "- Treat wipe as destructive recovery, not routine cleanup.",
79
- "- Never use wipe as a substitute for sync, bootstrap, or gitignore fixes.",
59
+ "7) Pre-merge sync",
60
+ " trekoon --toon sync status",
61
+ " trekoon --toon sync pull --from main",
62
+ " trekoon --toon sync conflicts list",
63
+ " trekoon --toon sync conflicts show <id>",
64
+ " trekoon --toon sync resolve <id> --use theirs --dry-run",
65
+ " trekoon --toon sync resolve <id> --use ours|theirs",
66
+ " trekoon --toon sync status",
67
+ " Always inspect conflicts before resolving. In human mode, --use theirs",
68
+ " prompts for confirmation (30s timeout, defaults to reject).",
80
69
  "",
81
- "9) Machine output examples",
82
- "- trekoon --toon quickstart",
83
- "- trekoon --toon session",
84
- "- trekoon --toon task done <task-id> --append \"Completed implementation\"",
85
- "- trekoon --toon task show <task-id> --all",
86
- "- trekoon --toon epic show <epic-id> --all",
87
- "- trekoon --toon sync status",
88
- "- trekoon --toon task ready --limit 5",
89
- "- trekoon --toon task next",
90
- "- trekoon --toon dep reverse <task-or-subtask-id>",
70
+ "8) Wipe (destructive recovery only)",
71
+ " trekoon wipe --yes",
72
+ " Removes the shared .trekoon directory for every worktree in the repo.",
73
+ " Don't use this for routine cleanup, sync fixes, or gitignore issues.",
91
74
  ].join("\n");
92
75
 
93
76
  export async function runQuickstart(_: CliContext): Promise<CliResult> {
@@ -120,13 +103,17 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
120
103
  preMergeFlow: [
121
104
  "trekoon --toon sync status",
122
105
  "trekoon --toon sync pull --from main",
123
- "trekoon --toon sync resolve <id> --use ours",
106
+ "trekoon --toon sync conflicts list",
107
+ "trekoon --toon sync conflicts show <id>",
108
+ "trekoon --toon sync resolve <id> --use theirs --dry-run",
109
+ "trekoon --toon sync resolve <id> --use ours|theirs",
124
110
  "trekoon --toon sync status",
125
111
  ],
126
112
  executionLoop: [
127
113
  "trekoon --toon session",
128
114
  "trekoon --toon task update <task-id> --status in_progress",
129
- "trekoon --toon task done <task-id> --append \"Completed implementation\"",
115
+ "trekoon --toon task update <task-id> --append \"Completed implementation\"",
116
+ "trekoon --toon task done <task-id>",
130
117
  ],
131
118
  diagnostics: [
132
119
  "storageMode",
@@ -153,7 +140,10 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
153
140
  machineExamples: [
154
141
  "trekoon --toon quickstart",
155
142
  "trekoon --toon session",
156
- "trekoon --toon task done <task-id> --append \"Completed implementation\"",
143
+ "trekoon --toon session --epic <epic-id>",
144
+ "trekoon --toon suggest",
145
+ "trekoon --toon epic progress <epic-id>",
146
+ "trekoon --toon task done <task-id>",
157
147
  "trekoon --toon task show <task-id> --all",
158
148
  "trekoon --toon epic show <epic-id> --all",
159
149
  "trekoon --toon sync status",
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -262,8 +262,8 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
262
262
  const resolvedSourceDir: string = resolve(sourceDir);
263
263
 
264
264
  // Self-reference guard: when cwd IS the package dir (e.g. developing Trekoon
265
- // itself), the source dir and installed dir are the same path. Do not create
266
- // a circular symlink — the directory already contains the bundled files.
265
+ // itself), the source dir and installed dir are the same path. Do not copy
266
+ // over itself — the directory already contains the bundled files.
267
267
  if (resolve(installedDir) === resolvedSourceDir) {
268
268
  return { sourcePath, installedPath, installedDir };
269
269
  }
@@ -286,32 +286,30 @@ function installCanonicalSkill(cwd: string): CliResult | { sourcePath: string; i
286
286
  }
287
287
 
288
288
  if (pathOccupied) {
289
- if (existingIsSymlink) {
290
- // Already a symlink — check whether it points to the correct target.
291
- // Use realpathSync so OS-level symlinks (macOS /var → /private/var)
292
- // do not cause false mismatches.
289
+ if (existingIsDir && !existingIsSymlink) {
290
+ // Real directory — check whether contents are already up to date.
293
291
  try {
294
- const resolvedExisting: string = realpathSync(installedDir);
295
- if (resolvedExisting === realpathSync(resolvedSourceDir)) {
296
- // Symlink is already correct; idempotent success.
292
+ const sourceContents = readFileSync(sourcePath, "utf8");
293
+ const installedContents = readFileSync(installedPath, "utf8");
294
+ if (sourceContents === installedContents) {
295
+ // Already up to date; idempotent success.
297
296
  return { sourcePath, installedPath, installedDir };
298
297
  }
299
298
  } catch {
300
- // Broken symlink fall through to remove and recreate.
299
+ // Can't read installed file refresh below.
301
300
  }
302
- // Stale or broken symlink — remove and recreate.
303
- rmSync(installedDir, { force: true });
304
- } else if (existingIsDir) {
305
- // Legacy directory install (file-copy era) — migrate by removing.
301
+ // Stale copy — remove and recopy.
306
302
  rmSync(installedDir, { recursive: true, force: true });
303
+ } else if (existingIsSymlink) {
304
+ // Legacy symlink (pre-copy era) — remove and replace with copy.
305
+ rmSync(installedDir, { force: true });
307
306
  } else {
308
307
  // Unexpected file — remove.
309
308
  rmSync(installedDir, { force: true });
310
309
  }
311
310
  }
312
311
 
313
- const symlinkTarget: string = toRelativeSymlinkTarget(installedDir, resolvedSourceDir);
314
- symlinkSync(symlinkTarget, installedDir, "dir");
312
+ cpSync(resolvedSourceDir, installedDir, { recursive: true });
315
313
  } catch (error: unknown) {
316
314
  const message = error instanceof Error ? error.message : "Unknown skills install failure";
317
315
  return failResult({
@@ -784,6 +782,93 @@ function probeAndRepairIfInstalled(linkPath: string, expectedTarget: string): Re
784
782
  return repairSymlink(probe);
785
783
  }
786
784
 
785
+ /** Probe and repair local anchor using directory copy instead of symlinks. */
786
+ function probeAndRepairLocalAnchor(installedDir: string, sourceDir: string): RepairResult {
787
+ const resolvedExpected: string = resolve(sourceDir);
788
+ const sourcePath: string = join(sourceDir, "SKILL.md");
789
+ const installedPath: string = join(installedDir, "SKILL.md");
790
+
791
+ // Self-reference guard: source and install are the same path (dev mode).
792
+ if (resolve(installedDir) === resolvedExpected) {
793
+ return {
794
+ probe: { path: installedDir, expectedTarget: resolvedExpected, status: "ok", currentTarget: resolvedExpected },
795
+ action: "ok",
796
+ };
797
+ }
798
+
799
+ let existingIsSymlink = false;
800
+ let existingIsDir = false;
801
+ let pathOccupied = false;
802
+
803
+ try {
804
+ const stat = lstatSync(installedDir);
805
+ pathOccupied = true;
806
+ existingIsSymlink = stat.isSymbolicLink();
807
+ existingIsDir = stat.isDirectory();
808
+ } catch {
809
+ // Not installed.
810
+ }
811
+
812
+ if (!pathOccupied) {
813
+ return {
814
+ probe: { path: installedDir, expectedTarget: resolvedExpected, status: "not_installed", currentTarget: null },
815
+ action: "skipped",
816
+ };
817
+ }
818
+
819
+ if (existingIsSymlink) {
820
+ // Legacy symlink — migrate to directory copy.
821
+ let currentTarget: string | null = null;
822
+ try {
823
+ const rawTarget = readlinkSync(installedDir);
824
+ currentTarget = resolve(dirname(installedDir), rawTarget);
825
+ } catch {
826
+ // Broken symlink.
827
+ }
828
+ rmSync(installedDir, { force: true });
829
+ mkdirSync(dirname(installedDir), { recursive: true });
830
+ cpSync(resolvedExpected, installedDir, { recursive: true });
831
+ return {
832
+ probe: { path: installedDir, expectedTarget: resolvedExpected, status: "legacy", currentTarget },
833
+ action: "migrated",
834
+ };
835
+ }
836
+
837
+ if (existingIsDir) {
838
+ // Real directory — check if contents match.
839
+ try {
840
+ const sourceContents = readFileSync(sourcePath, "utf8");
841
+ const installedContents = readFileSync(installedPath, "utf8");
842
+ if (sourceContents === installedContents) {
843
+ return {
844
+ probe: { path: installedDir, expectedTarget: resolvedExpected, status: "ok", currentTarget: null },
845
+ action: "ok",
846
+ };
847
+ }
848
+ } catch {
849
+ // Can't read — treat as stale.
850
+ }
851
+
852
+ // Stale copy — refresh.
853
+ rmSync(installedDir, { recursive: true, force: true });
854
+ mkdirSync(dirname(installedDir), { recursive: true });
855
+ cpSync(resolvedExpected, installedDir, { recursive: true });
856
+ return {
857
+ probe: { path: installedDir, expectedTarget: resolvedExpected, status: "stale", currentTarget: null },
858
+ action: "repointed",
859
+ };
860
+ }
861
+
862
+ // Unexpected file type — remove and copy.
863
+ rmSync(installedDir, { force: true });
864
+ mkdirSync(dirname(installedDir), { recursive: true });
865
+ cpSync(resolvedExpected, installedDir, { recursive: true });
866
+ return {
867
+ probe: { path: installedDir, expectedTarget: resolvedExpected, status: "legacy", currentTarget: null },
868
+ action: "migrated",
869
+ };
870
+ }
871
+
787
872
  type UpdateScope = "global" | "local";
788
873
 
789
874
  interface UpdateEntry {
@@ -847,9 +932,9 @@ function runSkillsUpdate(context: CliContext): CliResult {
847
932
  entries.push({ scope: "global", label: editor, repair: probeAndRepairIfInstalled(linkPath, globalAnchorPath) });
848
933
  }
849
934
 
850
- // Local anchor: <cwd>/.agents/skills/trekoon bundled package dir.
935
+ // Local anchor: <cwd>/.agents/skills/trekoon directory copy of bundled source.
851
936
  const localAnchorPath: string = join(context.cwd, ".agents", "skills", "trekoon");
852
- entries.push({ scope: "local", label: "anchor", repair: probeAndRepairIfInstalled(localAnchorPath, sourceDir) });
937
+ entries.push({ scope: "local", label: "anchor", repair: probeAndRepairLocalAnchor(localAnchorPath, sourceDir) });
853
938
 
854
939
  // Local editor links: <cwd>/.<editor>/skills/trekoon → local anchor.
855
940
  for (const editor of EDITOR_NAMES) {
@@ -1,4 +1,6 @@
1
- import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
1
+ import { createInterface } from "node:readline";
2
+
3
+ import { findUnknownOption, hasFlag, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
2
4
  import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
3
5
 
4
6
  import { DomainError } from "../domain/types";
@@ -6,12 +8,12 @@ import { failResult, okResult } from "../io/output";
6
8
  import { type CliContext, type CliResult } from "../runtime/command-types";
7
9
  import { resolveStorageResolutionDiagnostics } from "../storage/database";
8
10
  import { assertValidSourceRef } from "../sync/branch-db";
9
- import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncStatus } from "../sync/service";
10
- import { type SyncResolution } from "../sync/types";
11
+ import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncResolvePreview, syncStatus } from "../sync/service";
12
+ import { type ResolvePreviewSummary, type SyncResolution } from "../sync/types";
11
13
 
12
14
  const STATUS_OPTIONS = ["from"] as const;
13
15
  const PULL_OPTIONS = ["from"] as const;
14
- const RESOLVE_OPTIONS = ["use"] as const;
16
+ const RESOLVE_OPTIONS = ["use", "dry-run"] as const;
15
17
  const CONFLICTS_LIST_OPTIONS = ["mode"] as const;
16
18
  const CONFLICTS_SHOW_OPTIONS: readonly string[] = [];
17
19
 
@@ -121,6 +123,36 @@ function isStorageBootstrapError(code: string): boolean {
121
123
  return code === "tracked_ignored_mismatch" || code === "ambiguous_legacy_state" || code === "legacy_import_failed";
122
124
  }
123
125
 
126
+ function formatTheirsConfirmation(preview: ResolvePreviewSummary): string {
127
+ return [
128
+ `Resolve conflict ${preview.conflictId} using theirs?`,
129
+ ` Field: ${preview.fieldName}`,
130
+ ` Current (ours): ${JSON.stringify(preview.oursValue)}`,
131
+ ` Incoming (theirs): ${JSON.stringify(preview.theirsValue)}`,
132
+ "Confirm? [y/N] ",
133
+ ].join("\n");
134
+ }
135
+
136
+ function promptConfirmation(message: string, timeoutMs: number = 30_000): Promise<boolean> {
137
+ return new Promise<boolean>((resolve): void => {
138
+ let settled = false;
139
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
140
+ const timer = setTimeout((): void => {
141
+ if (settled) return;
142
+ settled = true;
143
+ rl.close();
144
+ resolve(false);
145
+ }, timeoutMs);
146
+ rl.question(message, (answer: string): void => {
147
+ if (settled) return;
148
+ settled = true;
149
+ clearTimeout(timer);
150
+ rl.close();
151
+ resolve(answer.trim().toLowerCase() === "y");
152
+ });
153
+ });
154
+ }
155
+
124
156
  export async function runSync(context: CliContext): Promise<CliResult> {
125
157
  const parsed = parseArgs(context.args);
126
158
  const subcommand: string | undefined = parsed.positional[0];
@@ -221,7 +253,63 @@ export async function runSync(context: CliContext): Promise<CliResult> {
221
253
  return usage("sync resolve --use only accepts ours|theirs.", "sync.resolve");
222
254
  }
223
255
 
224
- const summary = syncResolve(context.cwd, conflictId, rawResolution as SyncResolution);
256
+ const resolution: SyncResolution = rawResolution;
257
+ const dryRun: boolean = hasFlag(parsed.flags, "dry-run");
258
+
259
+ if (dryRun) {
260
+ const preview = syncResolvePreview(context.cwd, conflictId, resolution);
261
+
262
+ return okResult({
263
+ command: "sync.resolve",
264
+ human: [
265
+ `[dry-run] Would resolve ${preview.conflictId} using ${preview.resolution}.`,
266
+ `Entity: ${preview.entityKind} ${preview.entityId}`,
267
+ `Field: ${preview.fieldName}`,
268
+ `Ours: ${JSON.stringify(preview.oursValue)}`,
269
+ `Theirs: ${JSON.stringify(preview.theirsValue)}`,
270
+ preview.resolution === "theirs"
271
+ ? `Would write: ${JSON.stringify(preview.wouldWrite)}`
272
+ : `Field stays: ${JSON.stringify(preview.wouldWrite)} (no entity write)`,
273
+ ].join("\n"),
274
+ data: preview,
275
+ });
276
+ }
277
+
278
+ if (resolution === "theirs" && context.mode !== "toon") {
279
+ const preview = syncResolvePreview(context.cwd, conflictId, resolution);
280
+ const confirmed = await promptConfirmation(formatTheirsConfirmation(preview));
281
+
282
+ if (!confirmed) {
283
+ return failResult({
284
+ command: "sync.resolve",
285
+ human: "Resolution cancelled by user.",
286
+ data: {
287
+ conflictId,
288
+ resolution,
289
+ cancelled: true,
290
+ },
291
+ error: {
292
+ code: "cancelled",
293
+ message: "Resolution cancelled by user.",
294
+ },
295
+ });
296
+ }
297
+ }
298
+
299
+ let summary;
300
+ try {
301
+ summary = syncResolve(context.cwd, conflictId, resolution);
302
+ } catch (resolveError: unknown) {
303
+ if (resolveError instanceof Error && resolveError.message.includes("already resolved")) {
304
+ return failResult({
305
+ command: "sync.resolve",
306
+ human: `Conflict '${conflictId}' was resolved by another process while waiting for confirmation.`,
307
+ data: { conflictId, resolution, reason: "already_resolved" },
308
+ error: { code: "already_resolved", message: resolveError.message },
309
+ });
310
+ }
311
+ throw resolveError;
312
+ }
225
313
 
226
314
  return okResult({
227
315
  command: "sync.resolve",