@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.
- package/extensions/pi/index.ts +6 -2
- package/package.json +1 -1
- package/src/cli.ts +9 -1
- package/src/core.ts +2 -0
- package/src/next-action-notices.ts +29 -0
- package/src/reconcile.ts +151 -0
- package/src/reviews.ts +22 -36
- package/src/seats.ts +44 -38
- package/src/tasks.ts +14 -10
- package/src/vcs.ts +186 -0
package/extensions/pi/index.ts
CHANGED
|
@@ -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", () => {
|
|
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
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
|
}
|
package/src/reconcile.ts
ADDED
|
@@ -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
|
|
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. */
|
|
@@ -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
|
-
|
|
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 {
|
|
135
|
-
|
|
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
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
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
|
+
}
|