@afokapu/atdd-flow 0.3.64 → 0.3.66

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/README.md CHANGED
@@ -130,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
130
130
  atdd-flow task done example-app runtime-rollout --by coordinator@example-app
131
131
  ```
132
132
 
133
- Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main, the operator, or a seat on the primary checkout) once every task it coordinates or is assigned is `done`, through the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
133
+ Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main or the operator) once every task it coordinates or is assigned is `done`. A non-main coordinator sharing the primary checkout is retired as a Desk record without worktree finish; other coordinators use the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
134
134
 
135
135
  At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
136
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.64",
3
+ "version": "0.3.66",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "bun": ">=1.3.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@afokapu/atdd-bun": "0.10.89"
44
+ "@afokapu/atdd-bun": "0.10.101"
45
45
  },
46
46
  "dependencies": {
47
47
  "@typesafe-ai/sdk": "^0.6.0",
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import * as ownerAlerts from "./owner-escalations";
21
21
  import { recoveryFor } from "./recovery-bundle";
22
22
  import { reconcileOnce } from "./reconcile";
23
23
  import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
24
+ import { migrateProjectVcs } from "./vcs-migration";
24
25
 
25
26
  const usage = `atdd-flow — filesystem-first agent seats and tasks
26
27
 
@@ -32,6 +33,7 @@ Usage:
32
33
  atdd-flow desk migrate
33
34
  atdd-flow project init <project>
34
35
  atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
36
+ atdd-flow project migrate-vcs <project> --to jj [--dry-run]
35
37
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
36
38
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
37
39
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
@@ -107,8 +109,9 @@ async function main() {
107
109
  project: async () => {
108
110
  if (rest[0] === "init") return initProject(root, required(rest[1], "project"));
109
111
  if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
112
+ if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
110
113
  if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
111
- throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
114
+ throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
112
115
  },
113
116
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
114
117
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
package/src/tasks.ts CHANGED
@@ -242,7 +242,7 @@ async function retirementBun(root: string, projectName: string) {
242
242
  return [required(Bun.which("atdd-bun") ?? undefined, "atdd-bun in the primary checkout node_modules/.bin or on PATH")];
243
243
  }
244
244
 
245
- async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false) {
245
+ async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false, allowPrimaryCheckout = false) {
246
246
  const unfinished = (await allTasks(root, projectName))
247
247
  .filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && !terminal(entry.task))
248
248
  .map((entry) => entry.id);
@@ -251,7 +251,10 @@ async function retireDriver(root: string, projectName: string, assignee: string,
251
251
  const owner = await seat(root, assignee);
252
252
  if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
253
253
  try {
254
- const summary = (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
254
+ const primary = (await project(root, projectName)).repository;
255
+ const primaryCheckout = allowPrimaryCheckout && primary && await canonicalPath(owner.worktree) === await canonicalPath(primary);
256
+ const summary = (primaryCheckout ? `Primary checkout ${owner.worktree} retained; retired without worktree finish.` : undefined)
257
+ ?? (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
255
258
  ?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(await project(root, projectName))], owner.worktree);
256
259
  owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
257
260
  await atomicYaml(paths(root).seatFile(assignee), owner);
@@ -276,18 +279,11 @@ async function retireDriver(root: string, projectName: string, assignee: string,
276
279
  await closeRetiredDriverWorkspace(root, owner);
277
280
  }
278
281
 
279
- /**
280
- * Only main retires a coordinator, and never one seated on the primary checkout; a driver may also be
281
- * retired by the coordinator of every task assigned to it.
282
- */
282
+ /** Only main retires a coordinator; a driver may also be retired by the coordinator of every task assigned to it. */
283
283
  async function assertRetireAuthority(root: string, owner: Seat, actor: string, owned: ListedTask[]) {
284
284
  const main = `main@${owner.project}`;
285
285
  if (owner.role === "coordinator") {
286
286
  if (actor !== main) throw new Error(`Only ${main} may retire coordinator ${owner.address}.`);
287
- const primary = (await project(root, owner.project)).repository;
288
- if (primary && await canonicalPath(owner.worktree) === await canonicalPath(primary)) {
289
- throw new Error(`Cannot retire ${owner.address}; its worktree is the primary checkout.`);
290
- }
291
287
  } else if (actor !== main && owned.some((entry) => entry.task.coordinator !== actor)) {
292
288
  throw new Error(`Only ${main} or the coordinator of every task assigned to ${owner.address} may retire it.`);
293
289
  }
@@ -295,7 +291,8 @@ async function assertRetireAuthority(root: string, owner: Seat, actor: string, o
295
291
 
296
292
  /**
297
293
  * Retires a driver whose assigned tasks are all terminal (done, superseded, or rejected), or, for main only, a coordinator
298
- * (never main or the primary checkout) whose coordinated and assigned tasks are all terminal.
294
+ * (never main) whose coordinated and assigned tasks are all terminal. A non-main coordinator on the
295
+ * primary checkout retires its record without invoking worktree finish.
299
296
  */
300
297
  export async function retireSeat(root: string, address: string, args: string[]) {
301
298
  const resolved = await canonicalAddress(root, address);
@@ -310,7 +307,7 @@ export async function retireSeat(root: string, address: string, args: string[])
310
307
  const unfinished = owned.filter((entry) => !terminal(entry.task)).map((entry) => entry.id);
311
308
  if (unfinished.length) throw new Error(`Cannot retire ${resolved}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
312
309
  await assertRetireAuthority(root, owner, actor, owned);
313
- await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true);
310
+ await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true, coordinator && resolved !== `main@${owner.project}`);
314
311
  console.log(`${resolved} retired`);
315
312
  }
316
313
 
@@ -0,0 +1,312 @@
1
+ // URN: component:flow:jj-vcs-migration:vcs-migration:backend:application
2
+ // Tested-By:
3
+ // - test:flow:jj-vcs-migration:E013-UNIT-001
4
+ // Runtime: bun
5
+ // Purpose: Migrate one legacy Flow Git project to colocated JJ without losing lineage.
6
+ //
7
+ // Migration is deliberately narrower than project configuration. Configuration
8
+ // selects a VCS before seats exist; migration converts an already-live topology.
9
+ // The project remains Git-authoritative until every recreated workspace verifies.
10
+ //
11
+ // The conversion never rewrites historical task or review records. Secondary
12
+ // branches gain the same clean JJ working-copy anchor used by greenfield seats,
13
+ // leaving their legacy Git tip as the unchanged parent and preserving ancestry.
14
+ //
15
+ // Preflight is intentionally strict because conversion briefly replaces clean
16
+ // linked Git worktrees at their existing paths. Any dirty, detached, unmanaged,
17
+ // review-state, or ambiguous topology is rejected before JJ metadata is created.
18
+ //
19
+ // Rollback uses the captured branch, commit, and path tuple rather than inferring
20
+ // state from a partial JJ conversion. The durable project VCS is written last.
21
+
22
+ import { readdir, realpath, rm } from "node:fs/promises";
23
+ import { join, resolve } from "node:path";
24
+ import {
25
+ type Project, type Seat, atomicYaml, exists, paths, project, readYaml, required, yaml,
26
+ } from "./core";
27
+ import { assertBranchReady, assertRepository, branchCommit, vcsMode, workspaceName } from "./vcs";
28
+
29
+ type CommandResult = { code: number; out: string; err: string };
30
+ type SeatState = { address: string; branch: string; worktree: string; commit: string; primary: boolean };
31
+ type GitWorktree = { path: string; branch?: string };
32
+ type TaskLite = { assignee?: string; status?: string; governed_base?: { commit?: string } };
33
+ type ReviewsLite = { attempts?: Array<{ delivery_head?: string }> };
34
+
35
+ export type VcsMigrationPlan = {
36
+ schema: "atdd-flow/vcs-migration-plan/v1";
37
+ project: string;
38
+ from: "git";
39
+ to: "jj";
40
+ primary: SeatState;
41
+ workspaces: SeatState[];
42
+ lineage: { tasks_checked: number; reviews_checked: number };
43
+ };
44
+
45
+ type PreparedMigration = {
46
+ config: Project;
47
+ repository: string;
48
+ states: SeatState[];
49
+ plan: VcsMigrationPlan;
50
+ };
51
+
52
+ /** Run one external VCS command without throwing so rollback can inspect failures. */
53
+ async function command(cwd: string, executable: string, args: string[]): Promise<CommandResult> {
54
+ const child = Bun.spawn([executable, ...args], { cwd, stdout: "pipe", stderr: "pipe" });
55
+ const [out, err, code] = await Promise.all([
56
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
57
+ ]);
58
+ return { code, out: out.trim(), err: err.trim() };
59
+ }
60
+
61
+ /** Require successful command output for all forward migration operations. */
62
+ async function output(cwd: string, executable: string, args: string[]) {
63
+ const result = await command(cwd, executable, args);
64
+ if (result.code !== 0) throw new Error(`${executable} ${args.join(" ")} failed: ${result.err || result.out}`);
65
+ return result.out;
66
+ }
67
+
68
+ /** Parse only the conventional semantic version tuple advertised by Git and JJ. */
69
+ function versionTuple(text: string) {
70
+ const match = text.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
71
+ if (!match) throw new Error(`Unable to parse version from: ${text}`);
72
+ return match.slice(1).map(Number) as [number, number, number];
73
+ }
74
+
75
+ /** Compare tool versions without adding a semver dependency to Flow. */
76
+ function atLeast(actual: [number, number, number], expected: [number, number, number]) {
77
+ for (let index = 0; index < actual.length; index += 1) {
78
+ if (actual[index]! !== expected[index]!) return actual[index]! > expected[index]!;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ /** JJ colocated child workspaces require JJ 0.46 and its Git 2.42 floor. */
84
+ async function assertToolVersions(repository: string) {
85
+ const jj = versionTuple(await output(repository, "jj", ["version"]));
86
+ if (!atLeast(jj, [0, 46, 0])) throw new Error(`Jujutsu 0.46.0 or newer is required; found ${jj.join(".")}.`);
87
+ const git = versionTuple(await output(repository, "git", ["--version"]));
88
+ if (!atLeast(git, [2, 42, 0])) throw new Error(`Git 2.42.0 or newer is required for colocated JJ workspaces; found ${git.join(".")}.`);
89
+ }
90
+
91
+ /** Retired seats own no live workspace and therefore are not migration inputs. */
92
+ async function activeSeats(root: string, name: string) {
93
+ const directory = paths(root).seats(name);
94
+ const entries = await readdir(directory, { withFileTypes: true });
95
+ const records = await Promise.all(entries.filter(entry => entry.isDirectory()).map(entry =>
96
+ readYaml<Seat>(join(directory, entry.name, "seat.yaml"))));
97
+ return records.filter(record => !record.retired);
98
+ }
99
+
100
+ /** Capture the exact clean Git tuple that migration must preserve or parent. */
101
+ async function inspectSeat(repository: string, record: Seat): Promise<SeatState> {
102
+ const worktree = await realpath(resolve(record.worktree)).catch(() => null);
103
+ if (!worktree) throw new Error(`Seat ${record.address} worktree does not exist: ${record.worktree}`);
104
+ const top = await realpath(await output(worktree, "git", ["rev-parse", "--show-toplevel"]));
105
+ if (top !== worktree) throw new Error(`Seat ${record.address} is not at a Git worktree root: ${worktree}`);
106
+ if (await output(worktree, "git", ["status", "--porcelain"])) throw new Error(`Seat ${record.address} worktree is dirty: ${worktree}`);
107
+ const branch = await output(worktree, "git", ["branch", "--show-current"]);
108
+ if (branch !== record.branch) throw new Error(`Seat ${record.address} is checked out at ${branch || "detached HEAD"}, not ${record.branch}.`);
109
+ const commit = await output(worktree, "git", ["rev-parse", "HEAD"]);
110
+ const ref = await output(repository, "git", ["rev-parse", "--verify", `refs/heads/${record.branch}^{commit}`]);
111
+ if (commit !== ref) throw new Error(`Seat ${record.address} HEAD does not match branch ${record.branch}.`);
112
+ return { address: record.address, branch: record.branch, worktree, commit, primary: worktree === repository };
113
+ }
114
+
115
+ /** Parse Git's porcelain registry rather than trusting directory existence alone. */
116
+ function parseWorktrees(text: string): GitWorktree[] {
117
+ return text.split(/\n\s*\n/).filter(Boolean).map(block => {
118
+ const lines = block.split("\n");
119
+ const path = lines.find(line => line.startsWith("worktree "))?.slice("worktree ".length);
120
+ const branch = lines.find(line => line.startsWith("branch refs/heads/"))?.slice("branch refs/heads/".length);
121
+ if (!path) throw new Error("Git worktree registry contains an entry without a path.");
122
+ return { path: resolve(path), ...(branch ? { branch } : {}) };
123
+ });
124
+ }
125
+
126
+ /** Refuse unmanaged Git worktrees so conversion cannot strand operator state. */
127
+ async function assertWorktreeRegistry(repository: string, states: SeatState[]) {
128
+ const listed = parseWorktrees(await output(repository, "git", ["worktree", "list", "--porcelain"]));
129
+ const expected = new Map(states.map(state => [state.worktree, state]));
130
+ if (listed.length !== expected.size) throw new Error("Git worktree registry contains worktrees not owned by active Flow seats.");
131
+ for (const item of listed) {
132
+ const state = expected.get(await realpath(item.path).catch(() => item.path));
133
+ if (!state) throw new Error(`Git worktree ${item.path} is not owned by an active Flow seat.`);
134
+ if (item.branch !== state.branch) throw new Error(`Git worktree ${item.path} is registered at ${item.branch ?? "detached HEAD"}, not ${state.branch}.`);
135
+ }
136
+ }
137
+
138
+ /** Sanitized JJ workspace names must stay one-to-one with Flow branches. */
139
+ function assertWorkspaceNames(states: SeatState[]) {
140
+ const names = new Set<string>();
141
+ for (const state of states.filter(entry => !entry.primary)) {
142
+ const name = workspaceName(state.branch);
143
+ if (!name) throw new Error(`Branch ${state.branch} cannot form a JJ workspace name.`);
144
+ if (names.has(name)) throw new Error(`Multiple branches map to JJ workspace name ${name}.`);
145
+ names.add(name);
146
+ }
147
+ }
148
+
149
+ /** Historical lineage is valid only while every referenced Git commit resolves. */
150
+ async function commitExists(repository: string, commit: string) {
151
+ return (await command(repository, "git", ["cat-file", "-e", `${commit}^{commit}`])).code === 0;
152
+ }
153
+
154
+ /** Validate task bases before any branch acquires a JJ working-copy anchor. */
155
+ async function assertTaskLineage(directory: string, repository: string, states: SeatState[], files: string[]) {
156
+ const byAddress = new Map(states.map(state => [state.address, state]));
157
+ let checked = 0;
158
+ for (const file of files.filter(file => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml"))) {
159
+ const task = await readYaml<TaskLite>(join(directory, file));
160
+ if (task.status === "review") throw new Error(`Task ${file} is in review; complete or return it before VCS migration.`);
161
+ const base = task.governed_base?.commit;
162
+ if (!base) continue;
163
+ checked += 1;
164
+ if (!await commitExists(repository, base)) throw new Error(`Task ${file} governed base ${base} is unavailable.`);
165
+ const owner = task.assignee ? byAddress.get(task.assignee) : undefined;
166
+ if (owner && (await command(repository, "git", ["merge-base", "--is-ancestor", base, owner.commit])).code !== 0) {
167
+ throw new Error(`Task ${file} governed base is not an ancestor of ${owner.address}.`);
168
+ }
169
+ }
170
+ return checked;
171
+ }
172
+
173
+ /** Review heads remain historical evidence; only their referenced commits must survive. */
174
+ async function assertReviewLineage(directory: string, repository: string, files: string[]) {
175
+ let checked = 0;
176
+ for (const file of files.filter(file => file.endsWith(".reviews.yaml"))) {
177
+ const history = await readYaml<ReviewsLite>(join(directory, file));
178
+ for (const attempt of history.attempts ?? []) {
179
+ if (!attempt.delivery_head) continue;
180
+ checked += 1;
181
+ if (!await commitExists(repository, attempt.delivery_head)) throw new Error(`Review ${file} delivery head ${attempt.delivery_head} is unavailable.`);
182
+ }
183
+ }
184
+ return checked;
185
+ }
186
+
187
+ /** Read lineage once so dry-run and execution share the identical preflight proof. */
188
+ async function assertLineage(root: string, name: string, repository: string, states: SeatState[]) {
189
+ const directory = paths(root).tasks(name);
190
+ if (!await exists(directory)) return { tasks_checked: 0, reviews_checked: 0 };
191
+ const files = await readdir(directory);
192
+ const tasks_checked = await assertTaskLineage(directory, repository, states, files);
193
+ const reviews_checked = await assertReviewLineage(directory, repository, files);
194
+ return { tasks_checked, reviews_checked };
195
+ }
196
+
197
+ /** Build the complete deterministic migration plan before creating .jj metadata. */
198
+ async function prepare(root: string, name: string): Promise<PreparedMigration> {
199
+ const config = await project(root, name);
200
+ if (vcsMode(config) !== "git") throw new Error(`Project ${name} is not a Git project.`);
201
+ const repository = await realpath(resolve(required(config.repository, `project ${name} repository`))).catch(() => null);
202
+ if (!repository) throw new Error(`Project ${name} repository does not exist.`);
203
+ await assertRepository("git", repository);
204
+ if (await exists(join(repository, ".jj"))) throw new Error(`Project ${name} already has Jujutsu metadata; refusing implicit adoption.`);
205
+ await assertToolVersions(repository);
206
+ const records = await activeSeats(root, name);
207
+ if (!records.length) throw new Error("Project has no active seats; configure vcs: jj directly before creating seats.");
208
+ const states = await Promise.all(records.map(record => inspectSeat(repository, record)));
209
+ const primary = states.filter(state => state.primary);
210
+ if (primary.length !== 1) throw new Error(`Migration requires exactly one active seat in the primary checkout; found ${primary.length}.`);
211
+ if (new Set(states.map(state => state.worktree)).size !== states.length) throw new Error("Active Flow seats must have distinct worktree paths before migration.");
212
+ assertWorkspaceNames(states);
213
+ await assertWorktreeRegistry(repository, states);
214
+ const lineage = await assertLineage(root, name, repository, states);
215
+ const workspaces = states.filter(state => !state.primary);
216
+ return { config, repository, states, plan: { schema: "atdd-flow/vcs-migration-plan/v1", project: name, from: "git", to: "jj", primary: primary[0]!, workspaces, lineage } };
217
+ }
218
+
219
+ /** The primary bookmark is intentionally not advanced during migration. */
220
+ async function verifyPrimary(state: SeatState) {
221
+ const ready = await assertBranchReady("jj", state.worktree, state.branch);
222
+ if (ready !== state.commit) throw new Error(`Primary bookmark ${state.branch} changed during JJ migration.`);
223
+ }
224
+
225
+ /** Secondary seats may gain one clean child; their legacy tip must be its parent. */
226
+ async function migratedHead(state: SeatState) {
227
+ const current = await output(state.worktree, "jj", ["log", "-r", "@", "--no-graph", "-T", "commit_id"]);
228
+ const parent = await output(state.worktree, "jj", ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
229
+ const changed = await output(state.worktree, "jj", ["diff", "-r", "@", "--name-only"]);
230
+ if (current !== state.commit && (parent !== state.commit || changed)) {
231
+ throw new Error(`JJ workspace ${state.worktree} does not preserve legacy tip ${state.commit} as its clean parent.`);
232
+ }
233
+ return current;
234
+ }
235
+
236
+ /** Prove JJ bookmark, working copy, and exported Git ref are synchronized. */
237
+ async function verifyMigratedWorkspace(state: SeatState) {
238
+ const current = await migratedHead(state);
239
+ const bookmarked = await branchCommit("jj", state.worktree, state.branch);
240
+ if (bookmarked !== current) throw new Error(`Bookmark ${state.branch} is not attached to its JJ working-copy revision.`);
241
+ const exported = await output(state.worktree, "git", ["rev-parse", "--verify", `refs/heads/${state.branch}^{commit}`]);
242
+ if (exported !== current) throw new Error(`Git ref ${state.branch} is not synchronized with its JJ bookmark.`);
243
+ }
244
+
245
+ /** Replace one clean linked Git worktree at the same path with a JJ workspace. */
246
+ async function convertWorkspace(repository: string, state: SeatState) {
247
+ await output(repository, "git", ["worktree", "remove", state.worktree]);
248
+ await output(repository, "jj", ["workspace", "add", "--colocate", "--name", workspaceName(state.branch), "-r", state.branch, state.worktree]);
249
+ await output(state.worktree, "jj", ["bookmark", "set", state.branch, "-r", "@"]);
250
+ await verifyMigratedWorkspace(state);
251
+ }
252
+
253
+ /** Remove only workspace state created by this migration attempt. */
254
+ async function removePartialWorkspace(repository: string, state: SeatState) {
255
+ await command(repository, "jj", ["workspace", "remove", workspaceName(state.branch)]);
256
+ await command(repository, "git", ["worktree", "remove", "--force", state.worktree]);
257
+ }
258
+
259
+ /** Restore one captured Git branch/worktree tuple after removing JJ metadata. */
260
+ async function restoreGitWorkspace(repository: string, state: SeatState) {
261
+ const errors: string[] = [];
262
+ await rm(state.worktree, { recursive: true, force: true }).catch(error => errors.push(String(error)));
263
+ const reset = await command(repository, "git", ["branch", "-f", state.branch, state.commit]);
264
+ if (reset.code) errors.push(reset.err || reset.out);
265
+ const restored = await command(repository, "git", ["worktree", "add", state.worktree, state.branch]);
266
+ if (restored.code) errors.push(restored.err || restored.out);
267
+ return errors;
268
+ }
269
+
270
+ /** Best-effort rollback never changes project.vcs and reports every incomplete restore. */
271
+ async function rollback(repository: string, primary: SeatState, displaced: SeatState[]) {
272
+ const errors: string[] = [];
273
+ for (const state of [...displaced].reverse()) await removePartialWorkspace(repository, state);
274
+ await rm(join(repository, ".jj"), { recursive: true, force: true }).catch(error => errors.push(String(error)));
275
+ const switched = await command(repository, "git", ["switch", primary.branch]);
276
+ if (switched.code) errors.push(switched.err || switched.out);
277
+ for (const state of displaced) errors.push(...await restoreGitWorkspace(repository, state));
278
+ return errors;
279
+ }
280
+
281
+ /** Execute the only supported legacy conversion: Git project to colocated JJ. */
282
+ export async function migrateProjectVcs(root: string, name: string, args: string[]) {
283
+ const toIndex = args.indexOf("--to"), to = toIndex < 0 ? undefined : args[toIndex + 1];
284
+ const dryRun = args.includes("--dry-run");
285
+ const consumed = new Set([toIndex, toIndex + 1, ...(dryRun ? [args.indexOf("--dry-run")] : [])]);
286
+ const valid = toIndex >= 0 && to === "jj"
287
+ && args.filter(arg => arg === "--to").length === 1
288
+ && args.filter(arg => arg === "--dry-run").length <= 1
289
+ && args.every((_arg, index) => consumed.has(index));
290
+ if (!valid) throw new Error("Use `atdd-flow project migrate-vcs <project> --to jj [--dry-run]`.");
291
+ const prepared = await prepare(root, name);
292
+ if (dryRun) { process.stdout.write(yaml.print(prepared.plan)); return; }
293
+
294
+ const displaced: SeatState[] = [];
295
+ try {
296
+ await output(prepared.repository, "jj", ["git", "init", "--colocate"]);
297
+ await assertRepository("jj", prepared.repository);
298
+ await verifyPrimary(prepared.plan.primary);
299
+ for (const state of prepared.plan.workspaces) {
300
+ displaced.push(state);
301
+ await convertWorkspace(prepared.repository, state);
302
+ }
303
+ await verifyPrimary(prepared.plan.primary);
304
+ for (const state of prepared.plan.workspaces) await verifyMigratedWorkspace(state);
305
+ await atomicYaml(paths(root).projectFile(name), { ...prepared.config, vcs: "jj" });
306
+ process.stdout.write(`${name} migrated git -> jj ${prepared.states.length} seats lineage preserved\n`);
307
+ } catch (error) {
308
+ const rollbackErrors = await rollback(prepared.repository, prepared.plan.primary, displaced);
309
+ const suffix = rollbackErrors.length ? ` Automatic rollback was incomplete: ${rollbackErrors.join("; ")}` : " Git worktrees were restored.";
310
+ throw new Error(`JJ migration failed before project.vcs changed: ${(error as Error).message}.${suffix}`);
311
+ }
312
+ }