@afokapu/atdd-flow 0.3.63 → 0.3.65

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.
@@ -6,7 +6,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
7
  import { paths, readYaml, type Seat } from "../../src/core";
8
8
  import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
9
- import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
9
+ import { type NoticeState, createNoticeState, refreshDelivered, sendSettledDirective, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
10
10
  import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
11
11
  import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
12
12
  import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
@@ -382,7 +382,11 @@ export default function (pi: ExtensionAPI) {
382
382
  return message ? { message: message as typeof event.message } : undefined;
383
383
  });
384
384
  // Nothing queued survives a settled run, so a pending flag left by a cleared queue cannot suppress future notices.
385
- pi.on("agent_settled", () => { notices.pending = false; });
385
+ pi.on("agent_settled", async () => {
386
+ notices.pending = false;
387
+ // Run completion is not task completion; one bounded continuation per unchanged directive.
388
+ await sendSettledDirective(host, root, seat, notices);
389
+ });
386
390
  pi.on("turn_end", async (_event, ctx) => {
387
391
  if (handoverPrompted) return;
388
392
  const percent = ctx.getContextUsage()?.percent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.63",
3
+ "version": "0.3.65",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -19,7 +19,9 @@ import { multiplexer } from "./multiplexer";
19
19
  import * as cleanup from "./ephemeral-resources";
20
20
  import * as ownerAlerts from "./owner-escalations";
21
21
  import { recoveryFor } from "./recovery-bundle";
22
+ import { reconcileOnce } from "./reconcile";
22
23
  import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
24
+ import { migrateProjectVcs } from "./vcs-migration";
23
25
 
24
26
  const usage = `atdd-flow — filesystem-first agent seats and tasks
25
27
 
@@ -31,6 +33,7 @@ Usage:
31
33
  atdd-flow desk migrate
32
34
  atdd-flow project init <project>
33
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]
34
37
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
35
38
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
36
39
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
@@ -65,6 +68,7 @@ Usage:
65
68
  atdd-flow task open <project> <task-id>
66
69
  atdd-flow cleanup status <project>
67
70
  atdd-flow cleanup checklist <project> <task-id>
71
+ atdd-flow reconcile --once [--dry-run]
68
72
  atdd-flow owner-alert scan --by <coordinator-or-main> [--re-notify-after-ms <milliseconds>]
69
73
  atdd-flow owner-alert deliver|acknowledge|execute|resolve <alert-id> --by <address> [--note <text>|--option <id>|--reason <text>]
70
74
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
@@ -105,8 +109,9 @@ async function main() {
105
109
  project: async () => {
106
110
  if (rest[0] === "init") return initProject(root, required(rest[1], "project"));
107
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));
108
113
  if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
109
- 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>`.");
110
115
  },
111
116
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
112
117
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
@@ -166,6 +171,12 @@ async function main() {
166
171
  if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
167
172
  throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
168
173
  },
174
+ reconcile: async () => {
175
+ if (!rest.includes("--once") || rest.some((value) => value !== "--once" && value !== "--dry-run"))
176
+ throw new Error("Use `atdd-flow reconcile --once [--dry-run]`.");
177
+ process.stdout.write(Bun.YAML.stringify(await reconcileOnce(root, { dryRun: rest.includes("--dry-run") })));
178
+ return;
179
+ },
169
180
  "owner-alert": async () => {
170
181
  const [subcommand, alertId, ...tail] = rest;
171
182
  if (subcommand === "scan") {
@@ -14,6 +14,8 @@ export type NoticeState = {
14
14
  /** A next-action follow-up has been queued and not yet delivered. */
15
15
  pending: boolean;
16
16
  lastDelivered?: string;
17
+ /** One automatic post-settlement continuation per unchanged directive. */
18
+ settledNudged?: string;
17
19
  /** Blocker notices already delivered this session, keyed by task and blocker text. */
18
20
  blockers: Set<string>;
19
21
  /** Mail ids already notified this session. */
@@ -50,19 +52,46 @@ export async function sendTaskDirective(host: NoticeHost, root: string, seat: st
50
52
  }
51
53
  }
52
54
 
55
+ /** A settled model turn is not task completion. Give one additional nudge for a
56
+ * still-actionable directive, but never loop on unchanged task state. */
57
+ export async function sendSettledDirective(host: NoticeHost, root: string, seat: string, state: NoticeState) {
58
+ if (state.pending) return;
59
+ try {
60
+ const directive = await nextDirective(root, seat);
61
+ if (!directive?.command) return; // Waiting for an authority or dependency is not work for this seat.
62
+ const key = directiveKey(directive);
63
+ if (key !== state.lastDelivered) return sendTaskDirective(host, root, seat, state);
64
+ if (state.settledNudged === key) return;
65
+ state.settledNudged = key;
66
+ state.pending = true;
67
+ host.sendMessage({
68
+ customType: "atdd-flow-next-action",
69
+ content: directiveNotice(seat, directive),
70
+ display: true,
71
+ details: { ...directiveDetails(directive), continuation: true },
72
+ }, { triggerTurn: true, deliverAs: "followUp" });
73
+ } catch { state.pending = false; }
74
+ }
75
+
53
76
  /** Re-derives a held next-action follow-up when Pi delivers it; a queued message cannot be dropped, so it is replaced by the shortest truthful text. */
54
77
  async function currentDirective(root: string, seat: string, state: NoticeState, message: NoticeMessage): Promise<NoticeMessage> {
55
78
  state.pending = false;
56
79
  const directive = await nextDirective(root, seat).catch(() => undefined);
57
80
  if (!directive) {
58
81
  state.lastDelivered = undefined;
82
+ state.settledNudged = undefined;
59
83
  return { ...message, content: `SYSTEM: next-action notice for ${seat} withdrawn: task state has no actionable step. Task state outranks mail.`, details: { authority: "task", lifecycle: "NONE" } };
60
84
  }
61
85
  const key = directiveKey(directive);
62
86
  if (key === state.lastDelivered) {
87
+ if (message.details?.continuation === true && directive.command) {
88
+ return { ...message, content: `${directiveNotice(seat, directive)} The task remains open. Continue or escalate a real blocker; do not stop merely because this turn ended.`,
89
+ details: { ...directiveDetails(directive), continuation: true } };
90
+ }
63
91
  return { ...message, content: `SYSTEM: next action for ${seat} unchanged on ${directive.project}/${directive.task} [${directive.lifecycle}]; see the previous notice.`, details: directiveDetails(directive) };
64
92
  }
65
93
  state.lastDelivered = key;
94
+ state.settledNudged = undefined;
66
95
  if (directive.lifecycle === "BLOCKED") state.blockers.add(key);
67
96
  return { ...message, content: directiveNotice(seat, directive), details: directiveDetails(directive) };
68
97
  }
@@ -0,0 +1,151 @@
1
+ // URN: component:flow:e012:reconcile:backend:application
2
+ // Tested-By:
3
+ // - test:flow:continuation:E012-UNIT-001
4
+ // Runtime: bun
5
+ // Purpose: Wake idle owners of existing actionable Flow obligations.
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, readdir } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { notify } from "./adapters";
10
+ import { type HerdrLocator, type Seat, atomicYaml, paths, readYaml, runOutput } from "./core";
11
+ import { type TaskDirective, directiveNotice, nextDirective } from "./tasks";
12
+ import { isRuntimeStateStale, readRuntimeState, withOwnedRuntimeState } from "./runtime-state";
13
+
14
+ type Outcome = "woke" | "dry_run" | "cooldown" | "exhausted" | "inactive" | "unverified" | "busy" | "replaced" | "wake_failed";
15
+ export type Reconciliation = { seat: string; task: string; outcome: Outcome };
16
+ export type ReconcileOptions = {
17
+ now?: Date;
18
+ dryRun?: boolean;
19
+ cooldownMs?: number;
20
+ maxNudges?: number;
21
+ inspect?: (locator: HerdrLocator, seat: string) => Promise<string>;
22
+ wake?: (locator: HerdrLocator, notice: string) => Promise<void>;
23
+ };
24
+
25
+ type Attempt = { schema: "atdd-flow/continuation-attempt/v1"; key: string; at: string; count: number };
26
+ type Target = { owner: Seat; directive: TaskDirective; locator: HerdrLocator; token: string };
27
+ type Settings = Required<Pick<ReconcileOptions, "now" | "cooldownMs" | "maxNudges" | "inspect" | "wake">> & { dryRun: boolean };
28
+
29
+ const agentName = (address: string) => `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32);
30
+ const fingerprint = (target: Target) =>
31
+ createHash("sha256").update(JSON.stringify({ directive: target.directive, session: target.owner.runtime?.pi_session, owner: target.token })).digest("hex");
32
+ const attemptFile = (root: string, address: string) =>
33
+ join(root, ".atdd-flow", "continuation", `${encodeURIComponent(address)}.yaml`);
34
+
35
+ /** Read Herdr status without treating missing or mismatched identities as healthy.
36
+ * The live host is an observation surface, never a seat registry.
37
+ */
38
+ async function inspectHerdr(locator: HerdrLocator, address: string) {
39
+ const raw = JSON.parse(await runOutput(["herdr", "--session", locator.session, "agent", "get", agentName(address)]));
40
+ const envelope = raw?.result ?? raw;
41
+ const agent = envelope?.agent ?? envelope;
42
+ if (agent?.name !== agentName(address) || agent?.pane_id !== locator.pane) return "unverified";
43
+ return String(agent?.agent_status ?? agent?.status ?? agent?.state ?? "unverified").toLowerCase();
44
+ }
45
+
46
+ /** Discover durable seat identity from the existing Desk directories.
47
+ * No second fleet registry is created or synchronized.
48
+ */
49
+ async function seats(root: string): Promise<Seat[]> {
50
+ const found: Seat[] = [];
51
+ for (const project of await readdir(paths(root).work, { withFileTypes: true })) {
52
+ if (!project.isDirectory()) continue;
53
+ for (const local of await readdir(paths(root).seats(project.name), { withFileTypes: true }).catch(() => [])) {
54
+ if (!local.isDirectory()) continue;
55
+ found.push(await readYaml<Seat>(join(paths(root).seats(project.name), local.name, "seat.yaml")));
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+
61
+ function validateLimits(cooldownMs: number, maxNudges: number) {
62
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0) throw new Error("Invalid continuation cooldown.");
63
+ if (!Number.isInteger(maxNudges) || maxNudges < 1) throw new Error("Invalid maximum nudge count.");
64
+ }
65
+
66
+ function settings(options: ReconcileOptions): Settings {
67
+ const cooldownMs = options.cooldownMs ?? 5 * 60_000;
68
+ const maxNudges = options.maxNudges ?? 2;
69
+ validateLimits(cooldownMs, maxNudges);
70
+ return {
71
+ now: options.now ?? new Date(),
72
+ dryRun: options.dryRun ?? false,
73
+ cooldownMs, maxNudges,
74
+ inspect: options.inspect ?? inspectHerdr,
75
+ wake: options.wake ?? ((locator, notice) => notify("herdr", locator.pane, notice, locator.session)),
76
+ };
77
+ }
78
+
79
+ const outcome = (owner: Seat, directive: TaskDirective, status: Outcome): Reconciliation =>
80
+ ({ seat: owner.address, task: `${directive.project}/${directive.task}`, outcome: status });
81
+
82
+ /** An actionable target also needs a live, matching runtime observation.
83
+ * This deliberately refuses an unknown or stale session rather than guessing.
84
+ */
85
+ async function target(root: string, owner: Seat, directive: TaskDirective, at: Date): Promise<Target | undefined> {
86
+ const runtime = owner.runtime;
87
+ const locator = runtime?.addresses.herdr;
88
+ if (runtime?.application !== "herdr" || !locator || typeof locator === "string") return;
89
+ const active = await readRuntimeState(root, owner.address);
90
+ if (!active) return;
91
+ if (isRuntimeStateStale(active, 60_000, at)) return;
92
+ if (runtime.pi_session && active.pi_session !== runtime.pi_session) return;
93
+ return { owner, directive, locator, token: active.owner_token };
94
+ }
95
+
96
+ /** The per-seat retry memo is advisory: changing work or replacing an agent
97
+ * starts a new key, while unchanged instructions have bounded retries.
98
+ */
99
+ function attemptDisposition(previous: Attempt | undefined, key: string, config: Settings): Outcome | undefined {
100
+ if (previous?.schema !== "atdd-flow/continuation-attempt/v1") return;
101
+ if (previous.key !== key) return;
102
+ if (previous.count >= config.maxNudges) return "exhausted";
103
+ if (config.now.getTime() - Date.parse(previous.at) < config.cooldownMs) return "cooldown";
104
+ }
105
+
106
+ /** Write the attempt before contacting Herdr, so a transient host failure
107
+ * cannot turn repeated reconciliation into a prompt storm.
108
+ */
109
+ async function wakeOnce(root: string, candidate: Target, config: Settings): Promise<Outcome> {
110
+ const file = attemptFile(root, candidate.owner.address);
111
+ const key = fingerprint(candidate);
112
+ const previous = await readYaml<Attempt>(file).catch(() => undefined);
113
+ const suppressed = attemptDisposition(previous, key, config);
114
+ if (suppressed) return suppressed;
115
+ const count = previous?.key === key ? previous.count + 1 : 1;
116
+ await mkdir(join(root, ".atdd-flow", "continuation"), { recursive: true });
117
+ await atomicYaml(file, { schema: "atdd-flow/continuation-attempt/v1", key, at: config.now.toISOString(), count } satisfies Attempt);
118
+ try {
119
+ await config.wake(candidate.locator, `${directiveNotice(candidate.owner.address, candidate.directive)} The task remains open. Continue within your authority; escalate a real blocker rather than ending the obligation.`);
120
+ return "woke";
121
+ } catch { return "wake_failed"; }
122
+ }
123
+
124
+ async function reconcileSeat(root: string, owner: Seat, config: Settings): Promise<Reconciliation | undefined> {
125
+ if (owner.retired) return;
126
+ const directive = await nextDirective(root, owner.address);
127
+ // A directive without a command is a legitimate wait on another actor.
128
+ if (!directive?.command) return;
129
+ const candidate = await target(root, owner, directive, config.now);
130
+ if (!candidate) return outcome(owner, directive, "inactive");
131
+ let status: string;
132
+ try { status = await config.inspect(candidate.locator, owner.address); }
133
+ catch { return outcome(owner, directive, "unverified"); }
134
+ if (status === "unverified") return outcome(owner, directive, "unverified");
135
+ if (status !== "idle" && status !== "done") return outcome(owner, directive, "busy");
136
+ if (config.dryRun) return outcome(owner, directive, "dry_run");
137
+ // Serialize with a live runtime's token-fenced wake; a replacement cannot be prompted.
138
+ const result = await withOwnedRuntimeState(root, owner.address, candidate.token, () => wakeOnce(root, candidate, config));
139
+ return outcome(owner, directive, result.owned ? result.value : "replaced");
140
+ }
141
+
142
+ /** One-shot, advisory fleet reconciliation. Task state remains the sole work authority. */
143
+ export async function reconcileOnce(root: string, options: ReconcileOptions = {}): Promise<Reconciliation[]> {
144
+ const config = settings(options);
145
+ const results: Reconciliation[] = [];
146
+ for (const owner of await seats(root)) {
147
+ const result = await reconcileSeat(root, owner, config);
148
+ if (result) results.push(result);
149
+ }
150
+ return results;
151
+ }
@@ -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
+ }