@afokapu/atdd-flow 0.3.61 → 0.3.63

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.61",
3
+ "version": "0.3.63",
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
@@ -30,7 +30,7 @@ Usage:
30
30
  atdd-flow init <desk-directory> [--git]
31
31
  atdd-flow desk migrate
32
32
  atdd-flow project init <project>
33
- atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]
33
+ atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
34
34
  atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
35
35
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
36
36
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
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>;
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. */
@@ -13,6 +14,7 @@ export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assi
13
14
  export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string; submitted_phase?: TaskPhase };
14
15
  export type DoneWhen = { text: string; proof?: string };
15
16
  export type GovernedBase = { coordinator: string; branch: string; commit: string };
17
+ export type LineageRef = { ref: string; commit: string; reason?: string };
16
18
  /** An append-only accountability decision; prior coordinator evidence remains untouched. */
17
19
  export type CoordinatorTransfer = {
18
20
  from: string;
@@ -21,6 +23,8 @@ export type CoordinatorTransfer = {
21
23
  /** Required when main executes the transfer; references an operator@desk message. */
22
24
  authorization?: string;
23
25
  exact_head: { branch: string; commit: string };
26
+ /** The driver ref used for the lineage check; HEAD means the recorded branch no longer resolved. */
27
+ lineage_ref?: LineageRef;
24
28
  effective_at: string;
25
29
  };
26
30
  export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
@@ -117,6 +121,26 @@ async function writeTask(root: string, projectName: string, id: string, task: Ta
117
121
 
118
122
  /** Records the exact parent head for new bounded topology without rewriting legacy tasks. */
119
123
  async function governedBase(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<GovernedBase | undefined> {
124
+ return (await governedLineage(root, projectName, coordinatorAddress, assignee))?.base;
125
+ }
126
+
127
+ /** The recorded branch when it resolves; otherwise the driver worktree HEAD; otherwise fail closed. */
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
+ catch {
131
+ if (!await exists(driver.worktree)) {
132
+ throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and its worktree ${driver.worktree} is missing; cannot verify lineage.`);
133
+ }
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
+ }
140
+ }
141
+ }
142
+
143
+ async function governedLineage(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<{ base: GovernedBase; lineage: LineageRef } | undefined> {
120
144
  if (!assignee) return undefined;
121
145
  const config = await project(root, projectName);
122
146
  if (!config.repository) return undefined;
@@ -145,10 +169,11 @@ async function governedBase(root: string, projectName: string, coordinatorAddres
145
169
  } else {
146
170
  throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
147
171
  }
148
- const commit = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", branch]);
149
- const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, driver.branch]);
150
- if (common !== commit) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
151
- return { coordinator: coordinator.address, branch, 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}.`);
176
+ return { base: { coordinator: coordinator.address, branch, commit }, lineage };
152
177
  }
153
178
 
154
179
  /**
@@ -227,7 +252,7 @@ async function retireDriver(root: string, projectName: string, assignee: string,
227
252
  if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
228
253
  try {
229
254
  const summary = (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
230
- ?? 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);
231
256
  owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
232
257
  await atomicYaml(paths(root).seatFile(assignee), owner);
233
258
  const checkpoint: Checkpoint = {
@@ -390,8 +415,9 @@ export async function transfer(root: string, projectName: string, id: string, ar
390
415
 
391
416
  // governedBase confirms main/named-coordinator identity, exact branch head,
392
417
  // and the driver's descendant relationship before accountability changes.
393
- const base = await governedBase(root, projectName, target, task.assignee);
394
- if (!base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
418
+ const governed = await governedLineage(root, projectName, target, task.assignee);
419
+ const base = governed?.base;
420
+ if (!governed || !base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
395
421
  const authorization = actor === main ? required(words(args, "--authorization"), "an owner-authorized topology plan") : undefined;
396
422
  if (authorization) await operatorAuthorization(root, authorization, main, {
397
423
  schema: "atdd-workflow/task-transfer-authorization/v1",
@@ -408,6 +434,7 @@ export async function transfer(root: string, projectName: string, id: string, ar
408
434
  reason: required(words(args, "--reason"), "--reason"),
409
435
  ...(authorization ? { authorization } : {}),
410
436
  exact_head: { branch: base.branch, commit: base.commit },
437
+ lineage_ref: governed.lineage,
411
438
  effective_at: now(),
412
439
  };
413
440
  task.coordinator = target;
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
+ }