trekoon 0.3.3 → 0.3.5
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/.agents/skills/trekoon/SKILL.md +93 -3
- package/README.md +152 -126
- package/docs/ai-agents.md +105 -104
- package/docs/commands.md +145 -167
- package/docs/machine-contracts.md +240 -68
- package/docs/quickstart.md +78 -148
- package/package.json +1 -1
- package/src/commands/help.ts +249 -253
- package/src/commands/quickstart.ts +73 -77
- package/src/commands/skills.ts +104 -19
- package/src/commands/sync.ts +188 -15
- package/src/domain/tracker-domain.ts +210 -37
- package/src/storage/events-retention.ts +72 -0
- package/src/storage/migrations.ts +28 -0
- package/src/sync/event-writes.ts +8 -6
- package/src/sync/service.ts +299 -64
- package/src/sync/types.ts +36 -0
|
@@ -4,90 +4,78 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
4
4
|
const QUICKSTART_TEXT = [
|
|
5
5
|
"Trekoon quickstart",
|
|
6
6
|
"",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
7
|
+
"Agents: always use --toon on every command.",
|
|
8
|
+
"Aligned with: .agents/skills/trekoon/SKILL.md",
|
|
9
9
|
"",
|
|
10
|
-
"1)
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
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)
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
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
|
-
"
|
|
25
|
-
"
|
|
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
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
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
|
-
"
|
|
46
|
-
"-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
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
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
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
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
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 resolve --all --use ours # batch: all pending at once",
|
|
67
|
+
" trekoon --toon sync status",
|
|
68
|
+
" Always inspect conflicts before resolving. For uniform conflicts, --all",
|
|
69
|
+
" resolves every pending conflict in one command. Optional --entity <id>",
|
|
70
|
+
" and --field <name> narrow the batch. In human mode, --use theirs prompts",
|
|
71
|
+
" for both single-conflict and batch resolve. Single-conflict prompts show",
|
|
72
|
+
" field/value details; batch prompts use a count-only confirmation (30s",
|
|
73
|
+
" timeout, defaults to reject).",
|
|
80
74
|
"",
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
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>",
|
|
75
|
+
"8) Wipe (destructive recovery only)",
|
|
76
|
+
" trekoon wipe --yes",
|
|
77
|
+
" Removes the shared .trekoon directory for every worktree in the repo.",
|
|
78
|
+
" Don't use this for routine cleanup, sync fixes, or gitignore issues.",
|
|
91
79
|
].join("\n");
|
|
92
80
|
|
|
93
81
|
export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
@@ -120,13 +108,18 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
|
120
108
|
preMergeFlow: [
|
|
121
109
|
"trekoon --toon sync status",
|
|
122
110
|
"trekoon --toon sync pull --from main",
|
|
123
|
-
"trekoon --toon sync
|
|
111
|
+
"trekoon --toon sync conflicts list",
|
|
112
|
+
"trekoon --toon sync conflicts show <id>",
|
|
113
|
+
"trekoon --toon sync resolve <id> --use theirs --dry-run",
|
|
114
|
+
"trekoon --toon sync resolve <id> --use ours|theirs",
|
|
115
|
+
"trekoon --toon sync resolve --all --use ours",
|
|
124
116
|
"trekoon --toon sync status",
|
|
125
117
|
],
|
|
126
118
|
executionLoop: [
|
|
127
119
|
"trekoon --toon session",
|
|
128
120
|
"trekoon --toon task update <task-id> --status in_progress",
|
|
129
|
-
"trekoon --toon task
|
|
121
|
+
"trekoon --toon task update <task-id> --append \"Completed implementation\"",
|
|
122
|
+
"trekoon --toon task done <task-id>",
|
|
130
123
|
],
|
|
131
124
|
diagnostics: [
|
|
132
125
|
"storageMode",
|
|
@@ -153,7 +146,10 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
|
153
146
|
machineExamples: [
|
|
154
147
|
"trekoon --toon quickstart",
|
|
155
148
|
"trekoon --toon session",
|
|
156
|
-
"trekoon --toon
|
|
149
|
+
"trekoon --toon session --epic <epic-id>",
|
|
150
|
+
"trekoon --toon suggest",
|
|
151
|
+
"trekoon --toon epic progress <epic-id>",
|
|
152
|
+
"trekoon --toon task done <task-id>",
|
|
157
153
|
"trekoon --toon task show <task-id> --all",
|
|
158
154
|
"trekoon --toon epic show <epic-id> --all",
|
|
159
155
|
"trekoon --toon sync status",
|
package/src/commands/skills.ts
CHANGED
|
@@ -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
|
|
266
|
-
//
|
|
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
|
-
//
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
//
|
|
299
|
+
// Can't read installed file — refresh below.
|
|
301
300
|
}
|
|
302
|
-
// Stale
|
|
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
|
-
|
|
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
|
|
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:
|
|
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) {
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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, syncResolveAll, syncResolveAllPreview, 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", "all", "entity", "field"] 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];
|
|
@@ -206,22 +238,163 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
206
238
|
}
|
|
207
239
|
|
|
208
240
|
const conflictId: string | undefined = parsed.positional[1];
|
|
241
|
+
const batchAll: boolean = hasFlag(parsed.flags, "all");
|
|
242
|
+
const resolveUsage: string = batchAll
|
|
243
|
+
? "sync resolve --all requires --use ours|theirs."
|
|
244
|
+
: "sync resolve requires <conflict-id> --use ours|theirs.";
|
|
245
|
+
|
|
209
246
|
const missingResolutionOption = readMissingOptionValue(parsed.missingOptionValues, "use");
|
|
210
247
|
if (missingResolutionOption !== undefined) {
|
|
211
|
-
return usage(
|
|
248
|
+
return usage(resolveUsage, "sync.resolve");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const missingEntityOption = readMissingOptionValue(parsed.missingOptionValues, "entity");
|
|
252
|
+
if (missingEntityOption !== undefined) {
|
|
253
|
+
return usage("sync resolve --entity requires a value.", "sync.resolve");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const missingFieldOption = readMissingOptionValue(parsed.missingOptionValues, "field");
|
|
257
|
+
if (missingFieldOption !== undefined) {
|
|
258
|
+
return usage("sync resolve --field requires a value.", "sync.resolve");
|
|
212
259
|
}
|
|
213
260
|
|
|
214
261
|
const rawResolution: string | undefined = readOption(parsed.options, "use");
|
|
215
262
|
|
|
216
|
-
if (
|
|
217
|
-
return usage("sync resolve
|
|
263
|
+
if (batchAll && conflictId) {
|
|
264
|
+
return usage("sync resolve --all cannot be combined with a positional conflict ID.", "sync.resolve");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!batchAll && !conflictId) {
|
|
268
|
+
return usage("sync resolve requires <conflict-id> or --all.", "sync.resolve");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!batchAll && readOption(parsed.options, "entity") !== undefined) {
|
|
272
|
+
return usage("sync resolve --entity is only supported with --all.", "sync.resolve");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!batchAll && readOption(parsed.options, "field") !== undefined) {
|
|
276
|
+
return usage("sync resolve --field is only supported with --all.", "sync.resolve");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!rawResolution) {
|
|
280
|
+
return usage(resolveUsage, "sync.resolve");
|
|
218
281
|
}
|
|
219
282
|
|
|
220
283
|
if (rawResolution !== "ours" && rawResolution !== "theirs") {
|
|
221
284
|
return usage("sync resolve --use only accepts ours|theirs.", "sync.resolve");
|
|
222
285
|
}
|
|
223
286
|
|
|
224
|
-
const
|
|
287
|
+
const resolution: SyncResolution = rawResolution;
|
|
288
|
+
const dryRun: boolean = hasFlag(parsed.flags, "dry-run");
|
|
289
|
+
|
|
290
|
+
// --- Batch resolve (--all) ---
|
|
291
|
+
if (batchAll) {
|
|
292
|
+
const entityFilter: string | undefined = readOption(parsed.options, "entity");
|
|
293
|
+
const fieldFilter: string | undefined = readOption(parsed.options, "field");
|
|
294
|
+
const filters: { entityId?: string; fieldName?: string } = {};
|
|
295
|
+
if (entityFilter !== undefined) filters.entityId = entityFilter;
|
|
296
|
+
if (fieldFilter !== undefined) filters.fieldName = fieldFilter;
|
|
297
|
+
|
|
298
|
+
if (dryRun) {
|
|
299
|
+
const preview = syncResolveAllPreview(context.cwd, resolution, filters);
|
|
300
|
+
|
|
301
|
+
return okResult({
|
|
302
|
+
command: "sync.resolve",
|
|
303
|
+
human: `[dry-run] Would resolve ${preview.matchedCount} conflict(s) using ${preview.resolution}.`,
|
|
304
|
+
data: preview,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (resolution === "theirs" && context.mode === "human") {
|
|
309
|
+
const preview = syncResolveAllPreview(context.cwd, resolution, filters);
|
|
310
|
+
const confirmed = await promptConfirmation(
|
|
311
|
+
`Resolve ${preview.matchedCount} conflict(s) using ${resolution}? [y/N] `,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (!confirmed) {
|
|
315
|
+
return failResult({
|
|
316
|
+
command: "sync.resolve",
|
|
317
|
+
human: "Batch resolution cancelled by user.",
|
|
318
|
+
data: { resolution, cancelled: true, filters: preview.filters },
|
|
319
|
+
error: { code: "cancelled", message: "Batch resolution cancelled by user." },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const summary = syncResolveAll(context.cwd, resolution, filters, {
|
|
324
|
+
expectedConflictIds: preview.matchedIds,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return okResult({
|
|
328
|
+
command: "sync.resolve",
|
|
329
|
+
human: `Resolved ${summary.resolvedCount} conflict(s) using ${summary.resolution}.`,
|
|
330
|
+
data: summary,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const summary = syncResolveAll(context.cwd, resolution, filters);
|
|
335
|
+
|
|
336
|
+
return okResult({
|
|
337
|
+
command: "sync.resolve",
|
|
338
|
+
human: `Resolved ${summary.resolvedCount} conflict(s) using ${summary.resolution}.`,
|
|
339
|
+
data: summary,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- Single resolve (positional conflict ID) ---
|
|
344
|
+
if (dryRun) {
|
|
345
|
+
const preview = syncResolvePreview(context.cwd, conflictId!, resolution);
|
|
346
|
+
|
|
347
|
+
return okResult({
|
|
348
|
+
command: "sync.resolve",
|
|
349
|
+
human: [
|
|
350
|
+
`[dry-run] Would resolve ${preview.conflictId} using ${preview.resolution}.`,
|
|
351
|
+
`Entity: ${preview.entityKind} ${preview.entityId}`,
|
|
352
|
+
`Field: ${preview.fieldName}`,
|
|
353
|
+
`Ours: ${JSON.stringify(preview.oursValue)}`,
|
|
354
|
+
`Theirs: ${JSON.stringify(preview.theirsValue)}`,
|
|
355
|
+
preview.resolution === "theirs"
|
|
356
|
+
? `Would write: ${JSON.stringify(preview.wouldWrite)}`
|
|
357
|
+
: `Field stays: ${JSON.stringify(preview.wouldWrite)} (no entity write)`,
|
|
358
|
+
].join("\n"),
|
|
359
|
+
data: preview,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (resolution === "theirs" && context.mode === "human") {
|
|
364
|
+
const preview = syncResolvePreview(context.cwd, conflictId!, resolution);
|
|
365
|
+
const confirmed = await promptConfirmation(formatTheirsConfirmation(preview));
|
|
366
|
+
|
|
367
|
+
if (!confirmed) {
|
|
368
|
+
return failResult({
|
|
369
|
+
command: "sync.resolve",
|
|
370
|
+
human: "Resolution cancelled by user.",
|
|
371
|
+
data: {
|
|
372
|
+
conflictId,
|
|
373
|
+
resolution,
|
|
374
|
+
cancelled: true,
|
|
375
|
+
},
|
|
376
|
+
error: {
|
|
377
|
+
code: "cancelled",
|
|
378
|
+
message: "Resolution cancelled by user.",
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let summary;
|
|
385
|
+
try {
|
|
386
|
+
summary = syncResolve(context.cwd, conflictId!, resolution);
|
|
387
|
+
} catch (resolveError: unknown) {
|
|
388
|
+
if (resolveError instanceof Error && resolveError.message.includes("already resolved")) {
|
|
389
|
+
return failResult({
|
|
390
|
+
command: "sync.resolve",
|
|
391
|
+
human: `Conflict '${conflictId}' was resolved by another process while waiting for confirmation.`,
|
|
392
|
+
data: { conflictId, resolution, reason: "already_resolved" },
|
|
393
|
+
error: { code: "already_resolved", message: resolveError.message },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
throw resolveError;
|
|
397
|
+
}
|
|
225
398
|
|
|
226
399
|
return okResult({
|
|
227
400
|
command: "sync.resolve",
|
|
@@ -233,7 +406,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
233
406
|
if (subcommand === "conflicts") {
|
|
234
407
|
const conflictsCommand: string | undefined = parsed.positional[1];
|
|
235
408
|
if (!conflictsCommand) {
|
|
236
|
-
|
|
409
|
+
return usage("sync conflicts requires list|show.", "sync.conflicts");
|
|
237
410
|
}
|
|
238
411
|
|
|
239
412
|
if (conflictsCommand === "list") {
|
|
@@ -244,12 +417,12 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
244
417
|
|
|
245
418
|
const missingModeOption = readMissingOptionValue(parsed.missingOptionValues, "mode");
|
|
246
419
|
if (missingModeOption !== undefined) {
|
|
247
|
-
|
|
420
|
+
return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
|
|
248
421
|
}
|
|
249
422
|
|
|
250
423
|
const mode = readOption(parsed.options, "mode") ?? "pending";
|
|
251
424
|
if (mode !== "pending" && mode !== "all") {
|
|
252
|
-
|
|
425
|
+
return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
|
|
253
426
|
}
|
|
254
427
|
|
|
255
428
|
const conflicts = listSyncConflicts(context.cwd, mode);
|
|
@@ -271,9 +444,9 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
271
444
|
}
|
|
272
445
|
|
|
273
446
|
const conflictId: string | undefined = parsed.positional[2];
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
447
|
+
if (!conflictId) {
|
|
448
|
+
return usage("sync conflicts show requires <conflict-id>.", "sync.conflicts.show");
|
|
449
|
+
}
|
|
277
450
|
|
|
278
451
|
const conflict = getSyncConflict(context.cwd, conflictId);
|
|
279
452
|
|
|
@@ -293,7 +466,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
|
|
|
293
466
|
});
|
|
294
467
|
}
|
|
295
468
|
|
|
296
|
-
|
|
469
|
+
return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`, "sync.conflicts");
|
|
297
470
|
}
|
|
298
471
|
|
|
299
472
|
return usage(`Unknown sync subcommand '${subcommand}'.`);
|