@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 +1 -1
- package/src/cli.ts +1 -1
- package/src/core.ts +2 -0
- package/src/reviews.ts +22 -36
- package/src/seats.ts +44 -38
- package/src/tasks.ts +34 -7
- package/src/vcs.ts +186 -0
package/package.json
CHANGED
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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:
|
|
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
|
|
424
|
-
|
|
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
|
|
447
|
-
|
|
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;
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
240
|
-
const branchHead = await
|
|
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 (
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
325
|
+
await addWorkspace(mode, repository, worktree, branch, base.commit);
|
|
317
326
|
created = true;
|
|
318
|
-
const currentBranch = await
|
|
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
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
394
|
-
|
|
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
|
+
}
|