@afokapu/atdd-flow 0.3.46 → 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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/seats.ts +49 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.46",
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/seats.ts CHANGED
@@ -213,7 +213,7 @@ async function noBranch(config: Project, branch: string) {
213
213
  if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
214
214
  }
215
215
 
216
- async function taskCoordinatorBase(root: string, config: Project, task: Task) {
216
+ async function taskCoordinatorBase(root: string, config: Project, task: Task, requireCleanCheckout = true) {
217
217
  const coordinatorAddress = await canonicalAddress(root, task.coordinator);
218
218
  const suffix = `@${config.project}`;
219
219
  if (!coordinatorAddress.endsWith(suffix)) throw new Error(`Task coordinator ${coordinatorAddress} is outside project ${config.project}.`);
@@ -235,15 +235,17 @@ async function taskCoordinatorBase(root: string, config: Project, task: Task) {
235
235
  throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
236
236
  }
237
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 };
238
241
  if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
239
242
  throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
240
243
  }
241
244
  const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
242
245
  if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
243
246
  const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
244
- const branchHead = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", branch]);
245
247
  if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
246
- return { coordinator, branch, commit: head };
248
+ return { coordinator, branch, commit: branchHead };
247
249
  }
248
250
 
249
251
  /** Creates a fresh driver and assigns its existing todo task from the task coordinator's exact head. */
@@ -252,6 +254,22 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
252
254
  throw new Error("Task-aware driver spawn requires the canonical driver role topology.");
253
255
  }
254
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
+ }
255
273
  const id = taskId(required(words(args, "--task"), "--task"));
256
274
  const task = await readYaml<Task>(paths(root).taskFile(config.project, id));
257
275
  if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
@@ -263,7 +281,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
263
281
  if (await exists(paths(root).seatFile(address))) throw new Error(`Driver seat ${address} already exists; refusing to reuse it.`);
264
282
  if (await exists(worktree)) throw new Error(`Driver worktree ${worktree} already exists; refusing to reuse it.`);
265
283
  await noBranch(config, branch);
266
- 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
+ }
267
301
  const portfolio = await modelPortfolio(root);
268
302
  const requestedAgent = words(args, "--agent");
269
303
  if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
@@ -280,17 +314,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
280
314
  await mkdir(dirname(worktree), { recursive: true });
281
315
  await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
282
316
  created = true;
283
- const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
284
- const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", base.branch]);
285
- 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
+ }
286
323
  await atomicYaml(paths(root).seatFile(address), record);
287
324
  await atomicYaml(paths(root).taskFile(config.project, id), task);
288
325
  } catch (error) {
289
- 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
+ }
290
330
  await rm(paths(root).seat(address), { recursive: true, force: true });
291
331
  throw error;
292
332
  }
293
- console.log(`${address} assigned ${id} return ${base.branch}`);
333
+ console.log(`${address} assigned ${id} return ${base.branch}${baseArgument ? ` base ${base.commit}` : ""}`);
294
334
  }
295
335
 
296
336
  export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {