@afokapu/atdd-flow 0.3.34 → 0.3.35
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/README.md +2 -0
- package/package.json +1 -1
- package/src/seats.ts +93 -4
package/README.md
CHANGED
|
@@ -109,6 +109,8 @@ atdd-flow spawn example-app driver runtime
|
|
|
109
109
|
|
|
110
110
|
`spawn` creates missing driver worktrees through Git: this example creates `/Users/you/Github/worktrees/example-app/runtime` on `delivery/runtime`. ATDD Bun owns safe retirement, not creation. A seat can own several tasks.
|
|
111
111
|
|
|
112
|
+
For the canonical named-coordinator topology, create an unassigned `todo` task first, then use `atdd-flow spawn <project> driver <name> --task <task-id>`. Flow derives the fresh `delivery/<name>` worktree from the task coordinator's exact `integration/<stream>` head (or `main` for `main@project`), records that commit as `governed_base`, assigns the task, and reports the integration return branch. It refuses existing seats, worktrees, or branches and invalid, dirty, stale, generic, nested, or cross-project coordinator lineage rather than reusing history.
|
|
113
|
+
|
|
112
114
|
## Deliver work
|
|
113
115
|
|
|
114
116
|
```text
|
package/package.json
CHANGED
package/src/seats.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
6
6
|
import {
|
|
7
7
|
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
|
|
8
|
-
atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
9
|
-
required, run, runOutput, runtimeAddress, seat, words, yaml,
|
|
8
|
+
atomicYaml, canonicalAddress, desk, exists, fill, has, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
9
|
+
required, run, runOutput, runtimeAddress, seat, taskId, words, yaml,
|
|
10
10
|
} from "./core";
|
|
11
11
|
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
12
|
-
import { seatTasks } from "./tasks";
|
|
12
|
+
import { type Task, seatTasks } from "./tasks";
|
|
13
13
|
import { misplacedSeats, projectHerdrSeat } from "./multiplexer";
|
|
14
14
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
15
15
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
@@ -112,9 +112,98 @@ async function ensureWorktree(config: Project, role: Role, worktree: string, bra
|
|
|
112
112
|
await run(command, true);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
async function noBranch(config: Project, branch: string) {
|
|
116
|
+
const probe = Bun.spawn(["git", "-C", required(config.repository, "project repository"), "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
117
|
+
if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function taskCoordinatorBase(root: string, config: Project, task: Task) {
|
|
121
|
+
const coordinatorAddress = await canonicalAddress(root, task.coordinator);
|
|
122
|
+
const suffix = `@${config.project}`;
|
|
123
|
+
if (!coordinatorAddress.endsWith(suffix)) throw new Error(`Task coordinator ${coordinatorAddress} is outside project ${config.project}.`);
|
|
124
|
+
const coordinator = await seat(root, coordinatorAddress);
|
|
125
|
+
const primary = resolve(required(config.repository, "project repository"));
|
|
126
|
+
let branch: string;
|
|
127
|
+
if (coordinator.role === "main") {
|
|
128
|
+
if (coordinator.address !== `main@${config.project}` || coordinator.branch !== "main" || resolve(coordinator.worktree) !== primary) {
|
|
129
|
+
throw new Error(`Main coordinator ${coordinator.address} has mismatched primary lineage.`);
|
|
130
|
+
}
|
|
131
|
+
branch = "main";
|
|
132
|
+
} else if (coordinator.role === "coordinator") {
|
|
133
|
+
const match = coordinator.address.match(new RegExp(`^coordinator\\.([a-z0-9_-]+)@${config.project}$`));
|
|
134
|
+
if (!match || coordinator.branch !== `integration/${match[1]}` || resolve(coordinator.worktree) === primary) {
|
|
135
|
+
throw new Error(`Coordinator ${coordinator.address} has mismatched, generic, or nested integration lineage.`);
|
|
136
|
+
}
|
|
137
|
+
branch = coordinator.branch;
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
|
|
140
|
+
}
|
|
141
|
+
if (coordinator.project !== config.project) throw new Error(`Task coordinator ${coordinator.address} is outside project ${config.project}.`);
|
|
142
|
+
if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
|
|
143
|
+
throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
|
|
144
|
+
}
|
|
145
|
+
const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
|
|
146
|
+
if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
|
|
147
|
+
const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
|
|
148
|
+
const branchHead = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", branch]);
|
|
149
|
+
if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
|
|
150
|
+
return { coordinator, branch, commit: head };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Creates a fresh driver and assigns its existing todo task from the task coordinator's exact head. */
|
|
154
|
+
async function spawnTaskDriver(root: string, config: Project, role: Role, name: string, args: string[]) {
|
|
155
|
+
if (role.address !== "driver.{name}@{project}" || role.branch !== "delivery/{name}" || !role.worktree) {
|
|
156
|
+
throw new Error("Task-aware driver spawn requires the canonical driver role topology.");
|
|
157
|
+
}
|
|
158
|
+
if (has(args, "--worktree") || has(args, "--branch")) throw new Error("Task-aware driver spawn derives its worktree and branch; do not provide --worktree or --branch.");
|
|
159
|
+
const id = taskId(required(words(args, "--task"), "--task"));
|
|
160
|
+
const task = await readYaml<Task>(paths(root).taskFile(config.project, id));
|
|
161
|
+
if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
|
|
162
|
+
if (task.status !== "todo" || task.assignee) throw new Error(`Task ${id} must be an unassigned todo task.`);
|
|
163
|
+
const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
|
|
164
|
+
const address = fill(role.address, entries);
|
|
165
|
+
const branch = fill(role.branch, entries);
|
|
166
|
+
const worktree = resolve(fill(role.worktree, { ...entries, repository: config.repository ?? "" }));
|
|
167
|
+
if (await exists(paths(root).seatFile(address))) throw new Error(`Driver seat ${address} already exists; refusing to reuse it.`);
|
|
168
|
+
if (await exists(worktree)) throw new Error(`Driver worktree ${worktree} already exists; refusing to reuse it.`);
|
|
169
|
+
await noBranch(config, branch);
|
|
170
|
+
const base = await taskCoordinatorBase(root, config, task);
|
|
171
|
+
const portfolio = await modelPortfolio(root);
|
|
172
|
+
const requestedAgent = words(args, "--agent");
|
|
173
|
+
if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
|
|
174
|
+
const legacyAgent = portfolio ? undefined : requestedAgent ?? role.agent;
|
|
175
|
+
const purpose = words(args, "--purpose") ?? (role.purpose ? fill(role.purpose, entries) : undefined);
|
|
176
|
+
const record: Seat = {
|
|
177
|
+
schema: "atdd-workflow/seat/v2", address, role: "driver", project: config.project, worktree, branch,
|
|
178
|
+
...(legacyAgent ? { agent: legacyAgent } : {}), ...(purpose ? { purpose } : {}),
|
|
179
|
+
};
|
|
180
|
+
task.assignee = address;
|
|
181
|
+
task.governed_base = { coordinator: base.coordinator.address, branch: base.branch, commit: base.commit };
|
|
182
|
+
let created = false;
|
|
183
|
+
try {
|
|
184
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
185
|
+
await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
|
|
186
|
+
created = true;
|
|
187
|
+
const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
|
|
188
|
+
const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", base.branch]);
|
|
189
|
+
if (current !== base.commit || currentBranch !== base.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
190
|
+
await atomicYaml(paths(root).seatFile(address), record);
|
|
191
|
+
await atomicYaml(paths(root).taskFile(config.project, id), task);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (created) await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
|
|
194
|
+
await rm(paths(root).seat(address), { recursive: true, force: true });
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
console.log(`${address} assigned ${id} return ${base.branch}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
115
200
|
export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {
|
|
116
201
|
const config = await project(root, projectName);
|
|
117
202
|
const role = required(config.roles[roleName], `role ${roleName}`);
|
|
203
|
+
if (words(args, "--task")) {
|
|
204
|
+
if (roleName !== "driver") throw new Error("--task is supported only for driver spawn.");
|
|
205
|
+
return spawnTaskDriver(root, config, role, name, args);
|
|
206
|
+
}
|
|
118
207
|
const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
|
|
119
208
|
// Retain the historical primary coordinator command for existing Desks while
|
|
120
209
|
// new defaults make main@project the only new primary identity.
|