@afokapu/atdd-flow 0.3.45 → 0.3.47

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +4 -2
  3. package/src/seats.ts +146 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { basename, resolve } from "node:path";
4
- import { init, initProject, configureProject, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
4
+ import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
5
5
  import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
6
6
  import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
7
7
  import * as tasks from "./tasks";
@@ -25,6 +25,7 @@ Usage:
25
25
  atdd-flow desk migrate
26
26
  atdd-flow project init <project>
27
27
  atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]
28
+ atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
28
29
  atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
29
30
  atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
30
31
  atdd-flow attach <address> [--application <application>] [--wake host|native]
@@ -95,7 +96,8 @@ async function main() {
95
96
  project: async () => {
96
97
  if (rest[0] === "init") return initProject(root, required(rest[1], "project"));
97
98
  if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
98
- throw new Error("Use `atdd-flow project init <project>` or `atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]`.");
99
+ if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
100
+ throw new Error("Use `atdd-flow project init <project>`, `atdd-flow project configure <project> [--repository <primary-checkout>] [--worktree-root <directory>]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
99
101
  },
100
102
  spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
101
103
  bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
package/src/seats.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
- import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rm, stat, 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";
@@ -77,6 +77,102 @@ const configureUsage = "Use `atdd-flow project configure <project> [--repository
77
77
  * The supported way to declare an existing project's primary checkout and linked-worktree root.
78
78
  * Everything is validated before one atomic write; only those two project fields change.
79
79
  */
80
+ type MigrationSeat = { folder: string; record: Seat };
81
+
82
+ const namedTopologyMigrationUsage = "Use `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.";
83
+
84
+ async function migrationSeats(root: string, config: Project): Promise<MigrationSeat[]> {
85
+ const directory = paths(root).seats(config.project);
86
+ if (!await exists(directory)) return [];
87
+ return Promise.all((await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map(async (entry) => ({
88
+ folder: entry.name,
89
+ record: await readYaml<Seat>(join(directory, entry.name, "seat.yaml")),
90
+ })));
91
+ }
92
+
93
+ /**
94
+ * Validates existing coordinator records without provisioning anything. A policy
95
+ * migration must preserve generic history while refusing ambiguous named resources.
96
+ */
97
+ function assertMigrationTopology(config: Project, seats: MigrationSeat[]) {
98
+ const primary = resolve(config.repository!);
99
+ const root = resolve(config.worktree_root!);
100
+ const coordinators = seats.filter(({ record }) => record.role === "coordinator");
101
+ const genericAddress = `coordinator@${config.project}`;
102
+ const generic = coordinators.filter(({ record }) => record.address === genericAddress);
103
+ if (generic.length > 1 || generic.some(({ folder }) => folder !== "coordinator")) throw new Error("Legacy generic coordinator collides with another coordinator record.");
104
+
105
+ const named = coordinators.filter(({ record }) => record.address !== genericAddress);
106
+ for (const { record } of generic) for (const namedRecord of named) {
107
+ if (record.branch === namedRecord.branch || record.worktree === namedRecord.worktree) {
108
+ throw new Error("Legacy generic coordinator collides with a named coordinator resource.");
109
+ }
110
+ }
111
+ const addresses = new Set<string>(), branches = new Set<string>(), worktrees = new Set<string>();
112
+ for (const { record } of named) {
113
+ if (!record.address.match(new RegExp(`^coordinator\\.([a-z0-9_-]+)@${config.project}$`))) throw new Error("Named coordinator seat collision.");
114
+ if (addresses.has(record.address)) throw new Error("Named coordinator seat collision.");
115
+ addresses.add(record.address);
116
+ if (branches.has(record.branch)) throw new Error("Named coordinator branch collision.");
117
+ branches.add(record.branch);
118
+ const worktree = resolve(record.worktree);
119
+ if (worktrees.has(worktree)) throw new Error("Named coordinator worktree collision.");
120
+ worktrees.add(worktree);
121
+ }
122
+ for (const { record } of named) {
123
+ const stream = record.address.slice("coordinator.".length, -(`@${config.project}`).length);
124
+ const worktree = resolve(record.worktree);
125
+ if (record.branch !== `integration/${stream}` || worktree !== resolve(join(root, stream)) || worktree === primary) {
126
+ throw new Error("Named coordinator seat collision.");
127
+ }
128
+ }
129
+ for (const { record } of seats.filter(({ record }) => record.role !== "coordinator")) for (const namedRecord of named) {
130
+ if (record.branch === namedRecord.branch) throw new Error("Named coordinator branch collision.");
131
+ if (resolve(record.worktree) === resolve(namedRecord.worktree)) throw new Error("Named coordinator worktree collision.");
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Migrates only an existing project's coordinator role template. Validation is
137
+ * read-only and precedes the one atomic policy write, so no resource can leak.
138
+ */
139
+ export async function migrateNamedCoordinatorTopology(root: string, name: string, args: string[]) {
140
+ if (args.length !== 2 || args[0] !== "--baseline" || !args[1]) throw new Error(args.includes("--baseline") ? namedTopologyMigrationUsage : "Missing --baseline.");
141
+ const baseline = args[1]!;
142
+ if (!/^[0-9a-f]{40}$/.test(baseline)) throw new Error("--baseline must be a full lowercase SHA-1.");
143
+ if (!await exists(paths(root).projectFile(name))) throw new Error(`Project ${name} does not exist.`);
144
+ const config = await project(root, name);
145
+ if (!config.repository) throw new Error(`Project ${name} has no repository.`);
146
+ if (!config.worktree_root) throw new Error(`Project ${name} has no worktree_root.`);
147
+ const repository = resolve(config.repository), worktreeRoot = resolve(config.worktree_root);
148
+ const top = existsSync(repository) ? Bun.spawnSync(["git", "-C", repository, "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" }) : undefined;
149
+ if (!top || top.exitCode !== 0 || realpathSync(top.stdout.toString().trim()) !== realpathSync(repository)) throw new Error(`Project ${name} repository is not a Git top level.`);
150
+ try { if (!(await stat(worktreeRoot)).isDirectory()) throw new Error(); }
151
+ catch { throw new Error(`Project ${name} has no worktree_root.`); }
152
+
153
+ const remote = (await runOutput(["git", "-C", repository, "ls-remote", "--exit-code", "origin", "refs/heads/main"])).trim().split(/\s+/)[0];
154
+ if (remote !== baseline) throw new Error(`Baseline ${baseline} does not equal origin/main.`);
155
+ try { await runOutput(["git", "-C", repository, "cat-file", "-e", `${baseline}^{commit}`]); }
156
+ catch { throw new Error(`Baseline ${baseline} is not available locally.`); }
157
+ assertMigrationTopology(config, await migrationSeats(root, config));
158
+
159
+ const coordinator = config.roles.coordinator;
160
+ if (!coordinator) throw new Error(`Project ${name} has no coordinator role policy.`);
161
+ const next: Project = {
162
+ ...config,
163
+ roles: {
164
+ ...config.roles,
165
+ coordinator: {
166
+ ...coordinator,
167
+ address: "coordinator.{name}@{project}", branch: "integration/{name}", base: "main", worktree: "{worktree_root}/{name}",
168
+ },
169
+ },
170
+ };
171
+ try { await atomicYaml(paths(root).projectFile(name), next); }
172
+ catch { throw new Error("Unable to atomically write migration policy."); }
173
+ console.log(`${name} named coordinator topology migrated`);
174
+ }
175
+
80
176
  export async function configureProject(root: string, name: string, args: string[]) {
81
177
  const config = await project(root, name);
82
178
  const values = new Map<string, string>();
@@ -117,7 +213,7 @@ async function noBranch(config: Project, branch: string) {
117
213
  if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
118
214
  }
119
215
 
120
- async function taskCoordinatorBase(root: string, config: Project, task: Task) {
216
+ async function taskCoordinatorBase(root: string, config: Project, task: Task, requireCleanCheckout = true) {
121
217
  const coordinatorAddress = await canonicalAddress(root, task.coordinator);
122
218
  const suffix = `@${config.project}`;
123
219
  if (!coordinatorAddress.endsWith(suffix)) throw new Error(`Task coordinator ${coordinatorAddress} is outside project ${config.project}.`);
@@ -139,15 +235,17 @@ async function taskCoordinatorBase(root: string, config: Project, task: Task) {
139
235
  throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
140
236
  }
141
237
  if (coordinator.project !== config.project) throw new Error(`Task coordinator ${coordinator.address} is outside project ${config.project}.`);
238
+ const repository = required(config.repository, "project repository");
239
+ const branchHead = await runOutput(["git", "-C", repository, "rev-parse", branch]);
240
+ if (!requireCleanCheckout) return { coordinator, branch, commit: branchHead };
142
241
  if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
143
242
  throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
144
243
  }
145
244
  const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
146
245
  if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
147
246
  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
247
  if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
150
- return { coordinator, branch, commit: head };
248
+ return { coordinator, branch, commit: branchHead };
151
249
  }
152
250
 
153
251
  /** Creates a fresh driver and assigns its existing todo task from the task coordinator's exact head. */
@@ -156,6 +254,22 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
156
254
  throw new Error("Task-aware driver spawn requires the canonical driver role topology.");
157
255
  }
158
256
  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.");
257
+ const baseArgument = words(args, "--base");
258
+ const dryRun = has(args, "--dry-run");
259
+ const explicit = args.includes("--base") || dryRun;
260
+ if (explicit) {
261
+ const allowed = new Set(["--task", "--base", "--dry-run"]);
262
+ const taskArgument = words(args, "--task");
263
+ const onlyExpectedValues = args.every((arg, index) => allowed.has(arg)
264
+ || (index > 0 && ["--task", "--base"].includes(args[index - 1]!)));
265
+ if (!onlyExpectedValues || args.filter((arg) => arg === "--task").length !== 1
266
+ || args.filter((arg) => arg === "--base").length !== 1
267
+ || args.filter((arg) => arg === "--dry-run").length > 1
268
+ || !taskArgument || taskArgument.startsWith("--")
269
+ || !baseArgument || !/^[0-9a-f]{40}$/.test(baseArgument)) {
270
+ throw new Error("Task-aware explicit base requires one --task <task-id> and one --base <full-40-lowercase-commit-sha>.");
271
+ }
272
+ }
159
273
  const id = taskId(required(words(args, "--task"), "--task"));
160
274
  const task = await readYaml<Task>(paths(root).taskFile(config.project, id));
161
275
  if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
@@ -167,7 +281,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
167
281
  if (await exists(paths(root).seatFile(address))) throw new Error(`Driver seat ${address} already exists; refusing to reuse it.`);
168
282
  if (await exists(worktree)) throw new Error(`Driver worktree ${worktree} already exists; refusing to reuse it.`);
169
283
  await noBranch(config, branch);
170
- const base = await taskCoordinatorBase(root, config, task);
284
+ // An explicit baseline is governed by the repository ref, never by a
285
+ // potentially dirty or stale coordinator checkout. Legacy spawn retains its
286
+ // existing clean-checkout guard and derives exactly as before.
287
+ const coordinatorBase = await taskCoordinatorBase(root, config, task, !baseArgument);
288
+ let base = coordinatorBase;
289
+ if (baseArgument) {
290
+ try { await runOutput(["git", "-C", required(config.repository, "project repository"), "cat-file", "-e", `${baseArgument}^{commit}`]); }
291
+ catch { throw new Error(`Explicit base ${baseArgument} does not exist locally in the configured repository.`); }
292
+ try { await run(["git", "-C", required(config.repository, "project repository"), "merge-base", "--is-ancestor", coordinatorBase.commit, baseArgument], true); }
293
+ catch { throw new Error(`Explicit base ${baseArgument} does not descend ${coordinatorBase.branch}.`); }
294
+ base = { ...coordinatorBase, commit: baseArgument };
295
+ }
296
+ if (dryRun) {
297
+ if (!baseArgument) throw new Error("--dry-run is supported only with explicit --base task-aware spawn.");
298
+ console.log(`${address} dry-run ${id} base ${base.commit} branch ${branch} worktree ${worktree}`);
299
+ return;
300
+ }
171
301
  const portfolio = await modelPortfolio(root);
172
302
  const requestedAgent = words(args, "--agent");
173
303
  if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
@@ -184,17 +314,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
184
314
  await mkdir(dirname(worktree), { recursive: true });
185
315
  await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
186
316
  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.`);
317
+ const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", coordinatorBase.branch]);
318
+ if (currentBranch !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
319
+ if (!baseArgument) {
320
+ const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
321
+ if (current !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
322
+ }
190
323
  await atomicYaml(paths(root).seatFile(address), record);
191
324
  await atomicYaml(paths(root).taskFile(config.project, id), task);
192
325
  } catch (error) {
193
- if (created) await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
326
+ if (created) {
327
+ await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
328
+ await run(["git", "-C", required(config.repository, "project repository"), "branch", "-D", branch], true).catch(() => undefined);
329
+ }
194
330
  await rm(paths(root).seat(address), { recursive: true, force: true });
195
331
  throw error;
196
332
  }
197
- console.log(`${address} assigned ${id} return ${base.branch}`);
333
+ console.log(`${address} assigned ${id} return ${base.branch}${baseArgument ? ` base ${base.commit}` : ""}`);
198
334
  }
199
335
 
200
336
  export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {