@afokapu/atdd-flow 0.3.62 → 0.3.64

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.62",
3
+ "version": "0.3.64",
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,6 +19,7 @@ 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";
23
24
 
24
25
  const usage = `atdd-flow — filesystem-first agent seats and tasks
@@ -30,7 +31,7 @@ Usage:
30
31
  atdd-flow init <desk-directory> [--git]
31
32
  atdd-flow desk migrate
32
33
  atdd-flow project init <project>
33
- atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]
34
+ atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
34
35
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
35
36
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
36
37
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
@@ -65,6 +66,7 @@ Usage:
65
66
  atdd-flow task open <project> <task-id>
66
67
  atdd-flow cleanup status <project>
67
68
  atdd-flow cleanup checklist <project> <task-id>
69
+ atdd-flow reconcile --once [--dry-run]
68
70
  atdd-flow owner-alert scan --by <coordinator-or-main> [--re-notify-after-ms <milliseconds>]
69
71
  atdd-flow owner-alert deliver|acknowledge|execute|resolve <alert-id> --by <address> [--note <text>|--option <id>|--reason <text>]
70
72
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
@@ -166,6 +168,12 @@ async function main() {
166
168
  if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
167
169
  throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
168
170
  },
171
+ reconcile: async () => {
172
+ if (!rest.includes("--once") || rest.some((value) => value !== "--once" && value !== "--dry-run"))
173
+ throw new Error("Use `atdd-flow reconcile --once [--dry-run]`.");
174
+ process.stdout.write(Bun.YAML.stringify(await reconcileOnce(root, { dryRun: rest.includes("--dry-run") })));
175
+ return;
176
+ },
169
177
  "owner-alert": async () => {
170
178
  const [subcommand, alertId, ...tail] = rest;
171
179
  if (subcommand === "scan") {
package/src/core.ts CHANGED
@@ -44,6 +44,8 @@ type LegacyCoordination = { schema: "atdd-workflow/coordination/v2"; site: strin
44
44
  export type Project = {
45
45
  schema: string;
46
46
  project: string;
47
+ /** Repository VCS. Omitted preserves the historical Git behavior. */
48
+ vcs?: "git" | "jj";
47
49
  repository?: string;
48
50
  worktree_root?: string;
49
51
  roles: Record<string, Role>;
@@ -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
+ }
package/src/reviews.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "./judgment";
11
11
  import { availableModelCandidates, bind, resolveExecutable, resolveModelCommand } from "./seats";
12
12
  import { type Task } from "./tasks";
13
+ import { assertBranchReady, changedFiles, currentCommit, diffCommand, planArtifacts, vcsMode, type VcsMode } from "./vcs";
13
14
 
14
15
  const conventionRule = "atdd-bun.review.behavioral-reconciliation";
15
16
  const conventionRelativePath = "conventions/atdd-bun.review/atdd-bun.review.behavioral-reconciliation.convention.yaml";
@@ -105,34 +106,15 @@ export async function behavioralReviewRequired(root: string, task: Task) {
105
106
  return explicitWorkflowProfile(policyRoot);
106
107
  }
107
108
 
108
- async function currentHead(worktree: string) {
109
- return runOutput(["git", "rev-parse", "HEAD"], worktree);
110
- }
111
-
112
- async function assertCleanDelivery(worktree: string) {
113
- const dirty = await runOutput(["git", "status", "--porcelain"], worktree);
114
- if (dirty) throw new Error("Final behavioral review requires a clean committed delivery worktree.");
115
- }
116
-
117
- async function changedFiles(worktree: string, base: string) {
118
- const outputs = await Promise.allSettled([
119
- runOutput(["git", "diff", "--name-only", `${base}...HEAD`], worktree),
120
- runOutput(["git", "diff", "--name-only"], worktree),
121
- runOutput(["git", "diff", "--name-only", "--cached"], worktree),
122
- runOutput(["git", "ls-files", "--others", "--exclude-standard"], worktree),
123
- ]);
124
- return [...new Set(outputs.flatMap((output) => output.status === "fulfilled"
125
- ? output.value.split("\n").map((entry) => entry.trim()).filter(Boolean)
126
- : []
127
- ))].slice(0, 200);
128
- }
129
-
130
- async function planArtifacts(worktree: string) {
131
- try {
132
- const output = await runOutput(["git", "ls-files", "plan"], worktree);
133
- return output.split("\n").map((entry) => entry.trim()).filter((entry) => entry.endsWith(".yaml")).slice(0, 400);
134
- } catch {
135
- return [];
109
+ async function cleanDeliveryRevision(mode: VcsMode, worktree: string, branch: string) {
110
+ if (mode === "git") {
111
+ const dirty = await runOutput(["git", "status", "--porcelain"], worktree);
112
+ if (dirty) throw new Error("Final behavioral review requires a clean committed delivery worktree.");
113
+ return currentCommit(mode, worktree);
114
+ }
115
+ try { return await assertBranchReady(mode, worktree, branch); }
116
+ catch (error) {
117
+ throw new Error(`Final behavioral review requires a clean committed delivery worktree: ${(error as Error).message}`);
136
118
  }
137
119
  }
138
120
 
@@ -172,9 +154,10 @@ async function route(
172
154
  options: JudgmentOptions = {},
173
155
  ) {
174
156
  const config = await project(root, projectName);
157
+ const mode = vcsMode(config);
175
158
  const role = config.roles[owner.role];
176
159
  const base = role?.base ?? config.roles.coordinator?.branch ?? "main";
177
- const [files, plans] = await Promise.all([changedFiles(owner.worktree, base), planArtifacts(owner.worktree)]);
160
+ const [files, plans] = await Promise.all([changedFiles(mode, owner.worktree, base), planArtifacts(mode, owner.worktree)]);
178
161
  const judgment = await routeBehavioralReview({
179
162
  title: task.title,
180
163
  ...(task.body ? { body: task.body } : {}),
@@ -188,6 +171,7 @@ async function route(
188
171
  files,
189
172
  plans,
190
173
  base,
174
+ mode,
191
175
  };
192
176
  }
193
177
 
@@ -250,7 +234,8 @@ export async function launchBehavioralReview(
250
234
  if (!task.done_when.every((entry) => Boolean(entry.proof))) throw new Error(`Task ${taskId} is missing delivery proof.`);
251
235
 
252
236
  const owner = await ownerForTask(root, task);
253
- await assertCleanDelivery(owner.worktree);
237
+ const mode = vcsMode(await project(root, projectName));
238
+ const head = await cleanDeliveryRevision(mode, owner.worktree, owner.branch);
254
239
 
255
240
  let gateOutput: string;
256
241
  try {
@@ -294,7 +279,6 @@ export async function launchBehavioralReview(
294
279
  }
295
280
 
296
281
  const reviewer = await writeReviewerSeat(root, projectName, taskId, owner);
297
- const head = await currentHead(owner.worktree);
298
282
  const history = await readBehavioralReviews(root, projectName, taskId) ?? {
299
283
  schema: "atdd-workflow/behavioral-reviews/v1" as const,
300
284
  task: `${projectName}/${taskId}`,
@@ -327,7 +311,7 @@ export async function launchBehavioralReview(
327
311
  runtime: { worktree: owner.worktree, branch: owner.branch, head },
328
312
  implementation: {
329
313
  changed_files: routed.files,
330
- diff_command: `git diff ${routed.base}...HEAD`,
314
+ diff_command: diffCommand(routed.mode, routed.base),
331
315
  },
332
316
  },
333
317
  };
@@ -420,8 +404,9 @@ export async function recordBehavioralReview(root: string, projectName: string,
420
404
  const actor = required(words(args, "--by"), "--by");
421
405
  if (actor !== attempt.reviewer.address) throw new Error(`Only ${attempt.reviewer.address} may record review ${attempt.id}.`);
422
406
  const owner = await ownerForTask(root, task);
423
- await assertCleanDelivery(owner.worktree);
424
- if (await currentHead(owner.worktree) !== attempt.delivery_head) throw new Error("Delivery HEAD changed during behavioral review; launch a new review against the current delivery.");
407
+ const mode = vcsMode(await project(root, projectName));
408
+ const head = await cleanDeliveryRevision(mode, owner.worktree, owner.branch);
409
+ if (head !== attempt.delivery_head) throw new Error(mode === "git" ? "Delivery HEAD changed during behavioral review; launch a new review against the current delivery." : "Delivery revision changed during behavioral review; launch a new review against the current delivery.");
425
410
  const file = required(words(args, "--file"), "--file");
426
411
  const result = validateResult(yaml.parse(await readFile(file, "utf8")));
427
412
  attempt.status = "complete";
@@ -443,6 +428,7 @@ export async function assertAcceptedBehavioralReview(root: string, projectName:
443
428
  if (latest.status !== "complete" || !latest.result) throw new Error(`Task ${taskId} has no completed final behavioral review.`);
444
429
  if (latest.result.decision !== "APPROVE") throw new Error(`Task ${taskId} final behavioral review is ${latest.result.decision}, not APPROVE.`);
445
430
  const owner = await ownerForTask(root, task);
446
- await assertCleanDelivery(owner.worktree);
447
- if (await currentHead(owner.worktree) !== latest.delivery_head) throw new Error(`Task ${taskId} changed after final behavioral review; review the current delivery before completion.`);
431
+ const mode = vcsMode(await project(root, projectName));
432
+ const head = await cleanDeliveryRevision(mode, owner.worktree, owner.branch);
433
+ if (head !== latest.delivery_head) throw new Error(`Task ${taskId} changed after final behavioral review; review the current delivery before completion.`);
448
434
  }
package/src/seats.ts CHANGED
@@ -15,6 +15,7 @@ import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
15
15
  import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
16
16
  import { projectCollaborationPolicy } from "./collaboration-policy";
17
17
  import { assertReplacementTarget, replacementForMainLaunch } from "./handover";
18
+ import { addWorkspace, assertBranchReady, assertRepository, branchCommit, branchExists, commitExists, isAncestor, rollbackWorkspace, vcsMode } from "./vcs";
18
19
 
19
20
  const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
20
21
 
@@ -72,11 +73,11 @@ export async function initProject(root: string, name: string) {
72
73
  console.log(`Initialized project ${name}`);
73
74
  }
74
75
 
75
- const configureUsage = "Use `atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]`.";
76
+ const configureUsage = "Use `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`.";
76
77
 
77
78
  /**
78
- * The supported way to declare an existing project's primary checkout and linked-worktree root.
79
- * Everything is validated before one atomic write; only those two project fields change.
79
+ * The supported way to declare an existing project's VCS, primary checkout, and linked-worktree root.
80
+ * Everything is validated before one atomic write; no seat, task, runtime, branch/bookmark, or workspace is mutated.
80
81
  */
81
82
  type MigrationSeat = { folder: string; record: Seat };
82
83
 
@@ -179,18 +180,27 @@ export async function configureProject(root: string, name: string, args: string[
179
180
  const values = new Map<string, string>();
180
181
  for (let index = 0; index < args.length; index += 2) {
181
182
  const flag = args[index], value = args[index + 1];
182
- if (!["--repository", "--worktree-root"].includes(flag) || values.has(flag) || !value || value.startsWith("--")) throw new Error(configureUsage);
183
+ if (!["--vcs", "--repository", "--worktree-root"].includes(flag) || values.has(flag) || !value || value.startsWith("--")) throw new Error(configureUsage);
183
184
  values.set(flag, value);
184
185
  }
185
186
  if (!values.size) throw new Error(configureUsage);
187
+ const selectedVcs = values.get("--vcs");
188
+ if (selectedVcs && selectedVcs !== "git" && selectedVcs !== "jj") throw new Error(configureUsage);
189
+ const currentMode = vcsMode(config);
190
+ const mode = (selectedVcs ?? currentMode) as "git" | "jj";
191
+ if (selectedVcs && selectedVcs !== currentMode && (await migrationSeats(root, config)).length) {
192
+ throw new Error(`Project VCS cannot change from ${currentMode} to ${selectedVcs} after seats exist.`);
193
+ }
186
194
  const repository = values.get("--repository"), worktreeRoot = values.get("--worktree-root");
187
- if (repository) {
188
- const path = resolve(repository);
189
- const real = (value: string) => { try { return realpathSync(value); } catch { return value; } };
190
- const top = existsSync(path) ? Bun.spawnSync(["git", "-C", path, "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" }) : undefined;
191
- if (!top || top.exitCode !== 0 || real(top.stdout.toString().trim()) !== real(path)) throw new Error(`${path} is not the top level of a Git repository.`);
192
- config.repository = path;
195
+ const configuredRepository = repository ?? config.repository;
196
+ if (selectedVcs && !configuredRepository) throw new Error("Project repository is required before selecting a VCS.");
197
+ const repositoryPath = configuredRepository ? resolve(configuredRepository) : undefined;
198
+ if (repositoryPath && (repository || selectedVcs)) {
199
+ try { await assertRepository(mode, repositoryPath); }
200
+ catch (error) { throw new Error((error as Error).message); }
201
+ config.repository = repositoryPath;
193
202
  }
203
+ if (selectedVcs) config.vcs = mode;
194
204
  if (worktreeRoot) config.worktree_root = resolve(worktreeRoot);
195
205
  await atomicYaml(paths(root).projectFile(name), config);
196
206
  for (const entry of await misplacedSeats(root, name)) console.log(`unprojected ${entry.address}: ${entry.reason}`);
@@ -200,18 +210,13 @@ export async function configureProject(root: string, name: string, args: string[
200
210
  async function ensureWorktree(config: Project, role: Role, worktree: string, branch: string) {
201
211
  if (!config.repository || worktree === resolve(config.repository) || await exists(worktree)) return;
202
212
  await mkdir(dirname(worktree), { recursive: true });
203
- const ref = `refs/heads/${branch}`;
204
- const probe = Bun.spawn(["git", "-C", config.repository, "show-ref", "--verify", "--quiet", ref]);
205
- const branchExists = await probe.exited === 0;
206
- const command = branchExists
207
- ? ["git", "-C", config.repository, "worktree", "add", worktree, branch]
208
- : ["git", "-C", config.repository, "worktree", "add", "-b", branch, worktree, role.base ?? "HEAD"];
209
- await run(command, true);
213
+ await addWorkspace(vcsMode(config), config.repository, worktree, branch, role.base ?? "HEAD");
210
214
  }
211
215
 
212
216
  async function noBranch(config: Project, branch: string) {
213
- const probe = Bun.spawn(["git", "-C", required(config.repository, "project repository"), "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
214
- if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
217
+ if (await branchExists(vcsMode(config), required(config.repository, "project repository"), branch)) {
218
+ throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
219
+ }
215
220
  }
216
221
 
217
222
  async function taskCoordinatorBase(root: string, config: Project, task: Task, requireCleanCheckout = true) {
@@ -236,16 +241,19 @@ async function taskCoordinatorBase(root: string, config: Project, task: Task, re
236
241
  throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
237
242
  }
238
243
  if (coordinator.project !== config.project) throw new Error(`Task coordinator ${coordinator.address} is outside project ${config.project}.`);
239
- const repository = required(config.repository, "project repository");
240
- const branchHead = await runOutput(["git", "-C", repository, "rev-parse", branch]);
244
+ const mode = vcsMode(config);
245
+ const branchHead = await branchCommit(mode, coordinator.worktree, branch);
241
246
  if (!requireCleanCheckout) return { coordinator, branch, commit: branchHead };
242
- if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
243
- throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
247
+ if (mode === "git") {
248
+ if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
249
+ const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
250
+ if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
251
+ const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
252
+ if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
253
+ } else {
254
+ try { await assertBranchReady(mode, coordinator.worktree, branch); }
255
+ catch (error) { throw new Error(`Coordinator ${coordinator.address} is not ready: ${(error as Error).message}`); }
244
256
  }
245
- const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
246
- if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
247
- const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
248
- if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
249
257
  return { coordinator, branch, commit: branchHead };
250
258
  }
251
259
 
@@ -288,10 +296,9 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
288
296
  const coordinatorBase = await taskCoordinatorBase(root, config, task, !baseArgument);
289
297
  let base = coordinatorBase;
290
298
  if (baseArgument) {
291
- try { await runOutput(["git", "-C", required(config.repository, "project repository"), "cat-file", "-e", `${baseArgument}^{commit}`]); }
292
- catch { throw new Error(`Explicit base ${baseArgument} does not exist locally in the configured repository.`); }
293
- try { await run(["git", "-C", required(config.repository, "project repository"), "merge-base", "--is-ancestor", coordinatorBase.commit, baseArgument], true); }
294
- catch { throw new Error(`Explicit base ${baseArgument} does not descend ${coordinatorBase.branch}.`); }
299
+ const repository = required(config.repository, "project repository");
300
+ if (!await commitExists(repository, baseArgument)) throw new Error(`Explicit base ${baseArgument} does not exist locally in the configured repository.`);
301
+ if (!await isAncestor(repository, coordinatorBase.commit, baseArgument)) throw new Error(`Explicit base ${baseArgument} does not descend ${coordinatorBase.branch}.`);
295
302
  base = { ...coordinatorBase, commit: baseArgument };
296
303
  }
297
304
  if (dryRun) {
@@ -311,23 +318,22 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
311
318
  task.assignee = address;
312
319
  task.governed_base = { coordinator: base.coordinator.address, branch: base.branch, commit: base.commit };
313
320
  let created = false;
321
+ const mode = vcsMode(config);
322
+ const repository = required(config.repository, "project repository");
314
323
  try {
315
324
  await mkdir(dirname(worktree), { recursive: true });
316
- await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
325
+ await addWorkspace(mode, repository, worktree, branch, base.commit);
317
326
  created = true;
318
- const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", coordinatorBase.branch]);
327
+ const currentBranch = await branchCommit(mode, base.coordinator.worktree, coordinatorBase.branch);
319
328
  if (currentBranch !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
320
329
  if (!baseArgument) {
321
- const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
330
+ const current = await assertBranchReady(mode, base.coordinator.worktree, coordinatorBase.branch);
322
331
  if (current !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
323
332
  }
324
333
  await atomicYaml(paths(root).seatFile(address), record);
325
334
  await atomicYaml(paths(root).taskFile(config.project, id), task);
326
335
  } catch (error) {
327
- if (created) {
328
- await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
329
- await run(["git", "-C", required(config.repository, "project repository"), "branch", "-D", branch], true).catch(() => undefined);
330
- }
336
+ if (created) await rollbackWorkspace(mode, repository, worktree, branch);
331
337
  await rm(paths(root).seat(address), { recursive: true, force: true });
332
338
  throw error;
333
339
  }
package/src/tasks.ts CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, join, resolve } from "node:path";
3
3
  import {
4
4
  type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
5
5
  } from "./core";
6
+ import { branchCommit, currentCommit, isAncestor, vcsMode, type VcsMode } from "./vcs";
6
7
 
7
8
  export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "superseded" | "rejected";
8
9
  /** An optional, descriptive implementation phase; task status remains the lifecycle authority. */
@@ -124,15 +125,18 @@ async function governedBase(root: string, projectName: string, coordinatorAddres
124
125
  }
125
126
 
126
127
  /** The recorded branch when it resolves; otherwise the driver worktree HEAD; otherwise fail closed. */
127
- async function driverLineageRef(driver: Seat): Promise<LineageRef> {
128
- const resolveRef = (ref: string) => runOutput(["git", "-C", driver.worktree, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
129
- try { return { ref: driver.branch, commit: await resolveRef(driver.branch) }; }
128
+ async function driverLineageRef(mode: VcsMode, driver: Seat): Promise<LineageRef> {
129
+ try { return { ref: driver.branch, commit: await branchCommit(mode, driver.worktree, driver.branch) }; }
130
130
  catch {
131
131
  if (!await exists(driver.worktree)) {
132
132
  throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and its worktree ${driver.worktree} is missing; cannot verify lineage.`);
133
133
  }
134
- try { return { ref: "HEAD", commit: await resolveRef("HEAD"), reason: `recorded branch ${driver.branch} no longer resolves; used worktree HEAD` }; }
135
- catch { throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and worktree ${driver.worktree} has no valid HEAD.`); }
134
+ try {
135
+ const commit = await currentCommit(mode, driver.worktree);
136
+ return { ref: mode === "git" ? "HEAD" : "@", commit, reason: `recorded branch ${driver.branch} no longer resolves; used worktree ${mode === "git" ? "HEAD" : "@"}` };
137
+ } catch {
138
+ throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and worktree ${driver.worktree} has no valid current revision.`);
139
+ }
136
140
  }
137
141
  }
138
142
 
@@ -165,10 +169,10 @@ async function governedLineage(root: string, projectName: string, coordinatorAdd
165
169
  } else {
166
170
  throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
167
171
  }
168
- const commit = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", branch]);
169
- const lineage = await driverLineageRef(driver);
170
- const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, lineage.commit]);
171
- if (common !== commit) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
172
+ const mode = vcsMode(config);
173
+ const commit = await branchCommit(mode, coordinator.worktree, branch);
174
+ const lineage = await driverLineageRef(mode, driver);
175
+ if (!await isAncestor(driver.worktree, commit, lineage.commit)) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
172
176
  return { base: { coordinator: coordinator.address, branch, commit }, lineage };
173
177
  }
174
178
 
@@ -248,7 +252,7 @@ async function retireDriver(root: string, projectName: string, assignee: string,
248
252
  if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
249
253
  try {
250
254
  const summary = (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
251
- ?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch"], owner.worktree);
255
+ ?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch", "--vcs", vcsMode(await project(root, projectName))], owner.worktree);
252
256
  owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
253
257
  await atomicYaml(paths(root).seatFile(assignee), owner);
254
258
  const checkpoint: Checkpoint = {
package/src/vcs.ts ADDED
@@ -0,0 +1,186 @@
1
+ // URN: component:flow:jj-vcs:vcs:backend:application
2
+ // Tested-By:
3
+ // - test:flow:jj-vcs:E011-UNIT-001
4
+ // Runtime: bun
5
+ // Purpose: Provide the narrow Git/Jujutsu repository capability used by Flow.
6
+ //
7
+ // VCS selection is deliberately project-scoped: one Desk may coordinate Git and
8
+ // Jujutsu repositories at the same time. The durable Flow model keeps calling
9
+ // the delivery reference a branch; in JJ mode that reference is a bookmark.
10
+ //
11
+ // JJ support is intentionally colocated-only. Git remains the interchange layer
12
+ // for exact commit ancestry and remote-compatible refs, while JJ owns working
13
+ // copies, workspace creation, and bookmark movement. This keeps the capability
14
+ // additive and preserves existing Git review and publication assumptions.
15
+ //
16
+ // A JJ working copy is itself a commit. An untouched workspace may therefore sit
17
+ // on an empty @ child whose parent is the governed bookmark. That is equivalent
18
+ // to a clean Git checkout for readiness checks; a non-empty @ is real work and
19
+ // must move the delivery bookmark before it can be considered ready.
20
+ //
21
+ // These helpers contain the VCS-specific mechanics so seats, tasks, and reviews
22
+ // keep their existing responsibilities instead of growing parallel Git/JJ paths.
23
+ import { realpath } from "node:fs/promises";
24
+ import { resolve } from "node:path";
25
+ import type { Project } from "./core";
26
+ import { run, runOutput } from "./core";
27
+
28
+ export type VcsMode = "git" | "jj";
29
+
30
+ export function vcsMode(config: Pick<Project, "vcs">): VcsMode {
31
+ const mode = config.vcs ?? "git";
32
+ if (mode !== "git" && mode !== "jj") throw new Error(`Unsupported project VCS: ${String(mode)}`);
33
+ return mode;
34
+ }
35
+
36
+ export function workspaceName(branch: string) {
37
+ return branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
38
+ }
39
+
40
+ async function jjVersion(cwd: string) {
41
+ const output = await runOutput(["jj", "version"], cwd);
42
+ const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
43
+ if (!match) throw new Error(`Unable to parse Jujutsu version from: ${output}`);
44
+ const [, major, minor] = match.map(Number);
45
+ if (major === 0 && minor < 46) throw new Error(`Jujutsu 0.46.0 or newer is required; found ${match[0]}.`);
46
+ }
47
+
48
+ export async function assertRepository(mode: VcsMode, path: string) {
49
+ const requested = resolve(path);
50
+ if (mode === "git") {
51
+ try {
52
+ const expected = await realpath(requested);
53
+ const top = await realpath(await runOutput(["git", "-C", expected, "rev-parse", "--show-toplevel"]));
54
+ if (top === expected) return;
55
+ } catch { /* Preserve the established configuration diagnostic below. */ }
56
+ throw new Error(`${requested} is not the top level of a Git repository.`);
57
+ }
58
+ let expected: string;
59
+ try { expected = await realpath(requested); }
60
+ catch { throw new Error(`${requested} is not the top level of a Jujutsu repository.`); }
61
+ await jjVersion(expected);
62
+ const top = await realpath(await runOutput(["jj", "root"], expected));
63
+ if (top !== expected) throw new Error(`${expected} is not the top level of a Jujutsu repository.`);
64
+ // Colocation must be concrete, not inferred from a successful JJ command:
65
+ // the same directory must also be the Git top level used for shared refs.
66
+ const gitTop = await realpath(await runOutput(["git", "-C", expected, "rev-parse", "--show-toplevel"]));
67
+ if (gitTop !== expected) throw new Error(`${expected} is not a colocated Git-backed Jujutsu repository.`);
68
+ await runOutput(["jj", "git", "colocation", "status"], expected);
69
+ }
70
+
71
+ export async function snapshot(mode: VcsMode, cwd: string) {
72
+ if (mode === "jj") await runOutput(["jj", "status"], cwd);
73
+ }
74
+
75
+ export async function branchCommit(mode: VcsMode, cwd: string, branch: string) {
76
+ if (mode === "git") return runOutput(["git", "-C", cwd, "rev-parse", branch]);
77
+ await snapshot(mode, cwd);
78
+ return runOutput(["jj", "log", "-r", branch, "--no-graph", "-T", "commit_id"], cwd);
79
+ }
80
+
81
+ export async function currentCommit(mode: VcsMode, cwd: string) {
82
+ if (mode === "git") return runOutput(["git", "-C", cwd, "rev-parse", "HEAD"]);
83
+ await snapshot(mode, cwd);
84
+ return runOutput(["jj", "log", "-r", "@", "--no-graph", "-T", "commit_id"], cwd);
85
+ }
86
+
87
+ export async function branchExists(mode: VcsMode, cwd: string, branch: string) {
88
+ if (mode === "git") {
89
+ try { await runOutput(["git", "-C", cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]); return true; }
90
+ catch { return false; }
91
+ }
92
+ try { await branchCommit(mode, cwd, branch); return true; }
93
+ catch { return false; }
94
+ }
95
+
96
+ export async function commitExists(cwd: string, commit: string) {
97
+ try { await runOutput(["git", "-C", cwd, "cat-file", "-e", `${commit}^{commit}`]); return true; }
98
+ catch { return false; }
99
+ }
100
+
101
+ export async function isAncestor(cwd: string, ancestor: string, descendant: string) {
102
+ try { await run(["git", "-C", cwd, "merge-base", "--is-ancestor", ancestor, descendant], true); return true; }
103
+ catch { return false; }
104
+ }
105
+
106
+ export async function assertBranchReady(mode: VcsMode, cwd: string, branch: string) {
107
+ if (mode === "git") {
108
+ if (await runOutput(["git", "-C", cwd, "status", "--porcelain"])) throw new Error(`Worktree ${cwd} is dirty.`);
109
+ const checkedOut = await runOutput(["git", "-C", cwd, "branch", "--show-current"]);
110
+ if (checkedOut !== branch) throw new Error(`Worktree ${cwd} is not checked out at ${branch}.`);
111
+ } else {
112
+ await snapshot(mode, cwd);
113
+ const conflicts = await runOutput(["jj", "log", "-r", "@ & conflicts()", "--no-graph", "-T", "commit_id"], cwd);
114
+ if (conflicts) throw new Error(`Jujutsu workspace ${cwd} has unresolved conflicts.`);
115
+ }
116
+ const [current, expected] = await Promise.all([currentCommit(mode, cwd), branchCommit(mode, cwd, branch)]);
117
+ if (current === expected) return expected;
118
+ if (mode === "jj") {
119
+ const [parent, changed] = await Promise.all([
120
+ runOutput(["jj", "log", "-r", "@-", "--no-graph", "-T", "commit_id"], cwd),
121
+ runOutput(["jj", "diff", "-r", "@", "--name-only"], cwd),
122
+ ]);
123
+ if (parent === expected && !changed) return expected;
124
+ }
125
+ throw new Error(`Worktree ${cwd} is stale relative to ${branch}.`);
126
+ }
127
+
128
+ export async function addWorkspace(mode: VcsMode, repository: string, worktree: string, branch: string, base: string) {
129
+ if (mode === "git") {
130
+ const exists = await branchExists(mode, repository, branch);
131
+ await run(exists
132
+ ? ["git", "-C", repository, "worktree", "add", worktree, branch]
133
+ : ["git", "-C", repository, "worktree", "add", "-b", branch, worktree, base], true);
134
+ return;
135
+ }
136
+ if (await branchExists(mode, repository, branch)) {
137
+ await run(["jj", "workspace", "add", "--colocate", "--name", workspaceName(branch), "-r", branch, worktree], true, repository);
138
+ } else {
139
+ await run(["jj", "workspace", "add", "--colocate", "--name", workspaceName(branch), "-r", base, worktree], true, repository);
140
+ await run(["jj", "bookmark", "set", branch, "-r", "@"], true, worktree);
141
+ }
142
+ await assertBranchReady(mode, worktree, branch);
143
+ }
144
+
145
+ export async function rollbackWorkspace(mode: VcsMode, repository: string, worktree: string, branch: string) {
146
+ if (mode === "git") {
147
+ await run(["git", "-C", repository, "worktree", "remove", "--force", worktree], true).catch(() => undefined);
148
+ await run(["git", "-C", repository, "branch", "-D", branch], true).catch(() => undefined);
149
+ return;
150
+ }
151
+ await run(["jj", "workspace", "remove", workspaceName(branch)], true, repository).catch(() => undefined);
152
+ await run(["jj", "bookmark", "delete", branch], true, repository).catch(() => undefined);
153
+ }
154
+
155
+ export async function changedFiles(mode: VcsMode, worktree: string, base: string) {
156
+ if (mode === "jj") {
157
+ await snapshot(mode, worktree);
158
+ const output = await runOutput(["jj", "diff", "--name-only", "--from", base, "--to", "@"], worktree);
159
+ return output.split("\n").map(entry => entry.trim()).filter(Boolean).slice(0, 200);
160
+ }
161
+ const outputs = await Promise.allSettled([
162
+ runOutput(["git", "diff", "--name-only", `${base}...HEAD`], worktree),
163
+ runOutput(["git", "diff", "--name-only"], worktree),
164
+ runOutput(["git", "diff", "--name-only", "--cached"], worktree),
165
+ runOutput(["git", "ls-files", "--others", "--exclude-standard"], worktree),
166
+ ]);
167
+ return [...new Set(outputs.flatMap(output => output.status === "fulfilled"
168
+ ? output.value.split("\n").map(entry => entry.trim()).filter(Boolean)
169
+ : []
170
+ ))].slice(0, 200);
171
+ }
172
+
173
+ export async function planArtifacts(mode: VcsMode, worktree: string) {
174
+ try {
175
+ const output = mode === "git"
176
+ ? await runOutput(["git", "ls-files", "plan"], worktree)
177
+ : await runOutput(["jj", "file", "list", "plan"], worktree);
178
+ return output.split("\n").map(entry => entry.trim()).filter(entry => entry.endsWith(".yaml")).slice(0, 400);
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ export function diffCommand(mode: VcsMode, base: string) {
185
+ return mode === "git" ? `git diff ${base}...HEAD` : `jj diff --from ${base} --to @`;
186
+ }