@afokapu/atdd-flow 0.3.17 → 0.3.18

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.
@@ -69,6 +69,19 @@ content:
69
69
  messages to their coordinator; they do not directly message the operator or main seat. A receipt
70
70
  or result addresses the seat that made the request, not every historical participant in the thread.
71
71
 
72
+ For new project topology, `main@project` is the sole primary-checkout and `main`-branch
73
+ coordinator. A named `coordinator.stream@project` works only from its own linked
74
+ `integration/stream` branch and worktree; it integrates bounded driver delivery rather than acting
75
+ as a hidden feature driver. A driver coordinated by that seat derives its governed base from that
76
+ exact integration head, while a main-coordinated driver derives from main. Reject nested
77
+ coordinator lineage, mismatched base/worktree placement, and ambiguous placement rather than
78
+ guessing. Legacy seat, task, and thread identities remain readable without rename or mutation.
79
+
80
+ Drivers hand work back to their named coordinator for integration and validation. The named
81
+ coordinator hands a reviewed integration candidate to main; only main completes normal
82
+ integration-to-main gates and publication. No automatic merge executor, branch graph, daemon, or
83
+ scheduler is introduced by this workflow.
84
+
72
85
  A driver starts only its assigned ready task. Treat the task's done_when criteria as the required
73
86
  delivery scope. Prefer existing repository patterns, components, dependencies, contracts, and
74
87
  abstractions. When multiple implementations are correct, choose the smallest effective change that
@@ -62,6 +62,7 @@ const defaultHeartbeatMs = 15_000;
62
62
  const defaultStaleAfterMs = 60_000;
63
63
  const defaultActivationIntervalMs = 25;
64
64
  const defaultActivationTimeoutMs = 60_000;
65
+ const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
65
66
 
66
67
  function participants(thread: Thread) {
67
68
  return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string") ? thread.participants as string[] : [];
@@ -342,7 +343,7 @@ export default function (pi: ExtensionAPI) {
342
343
  root, seat, piSession, herdrSession, pane,
343
344
  activate: async () => {
344
345
  if (!await current.activate()) return;
345
- pi.sendMessage({ customType: "atdd-flow-start", content: `SYSTEM: you are ${seat}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
346
+ pi.sendMessage({ customType: "atdd-flow-start", content: `SYSTEM: you are ${seat}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Convention: ${lifecycleConventionPath}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
346
347
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
347
348
  },
348
349
  }).catch(() => undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -89,6 +89,28 @@ async function projectSeats(root: string, projectName: string) {
89
89
  return seats.filter((entry): entry is Seat => Boolean(entry && entry.schema === "atdd-workflow/seat/v2" && !entry.retired));
90
90
  }
91
91
 
92
+ function assertNewTopologyPlacement(config: Project, entry: Seat, primary: string) {
93
+ const coordinator = config.roles.coordinator;
94
+ const namedCoordinatorDefaults = coordinator?.address === "coordinator.{name}@{project}" && coordinator.branch === "integration/{name}";
95
+ const mainDefaults = config.roles.main?.address === "main@{project}" && config.roles.main.branch === "main";
96
+ if (entry.role === "main" && mainDefaults && (entry.address !== `main@${config.project}` || entry.branch !== "main" || resolve(entry.worktree) !== primary)) {
97
+ throw new Error(`Main seat ${entry.address} must use the primary main worktree and branch.`);
98
+ }
99
+ if (entry.role !== "coordinator" || !namedCoordinatorDefaults) return;
100
+ // coordinator@project remains the preserved historical primary identity;
101
+ // every newly named coordinator must be a single linked integration stream.
102
+ if (entry.address === `coordinator@${config.project}`) {
103
+ if (entry.branch !== "main" || resolve(entry.worktree) !== primary) throw new Error(`Legacy primary coordinator ${entry.address} has mismatched placement.`);
104
+ return;
105
+ }
106
+ const suffix = `@${config.project}`;
107
+ const local = entry.address.endsWith(suffix) ? entry.address.slice(0, -suffix.length) : "";
108
+ const match = local.match(/^coordinator\.([a-z0-9_-]+)$/);
109
+ if (!match || entry.branch !== `integration/${match[1]}` || resolve(entry.worktree) === primary) {
110
+ throw new Error(`Named coordinator ${entry.address} must use one linked integration/<stream> worktree and branch.`);
111
+ }
112
+ }
113
+
92
114
  async function targets(root: string): Promise<Target[]> {
93
115
  let projects: string[];
94
116
  try { projects = await readdir(paths(root).work); }
@@ -102,6 +124,7 @@ async function targets(root: string): Promise<Target[]> {
102
124
  const primary = resolve(config.repository);
103
125
  result.push({ project: name, worktree: primary, workspaceLabel: name });
104
126
  for (const entry of await projectSeats(root, name)) {
127
+ assertNewTopologyPlacement(config, entry, primary);
105
128
  if ((entry.role === "main" || entry.role === "coordinator") && resolve(entry.worktree) === primary) {
106
129
  result.push({ project: name, worktree: primary, workspaceLabel: name, seat: entry });
107
130
  continue;
@@ -148,7 +171,7 @@ async function ensureWorkspace(session: string, target: Target, workspaces: Work
148
171
  if (!current) {
149
172
  const args = target.workspaceLabel === target.project
150
173
  ? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]
151
- : ["worktree", "open", "--workspace", primary?.id ?? "", "--cwd", primary?.worktree ?? "", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"];
174
+ : ["worktree", "open", "--workspace", primary?.id ?? "", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"];
152
175
  if (args.includes("")) throw new Error(`Project primary workspace is required to open linked worktree ${target.worktree}.`);
153
176
  const created = json(await command(["herdr", "--session", session, ...args]));
154
177
  current = { workspace_id: workspaceId(created), label: target.workspaceLabel, worktree: { checkout_path: target.worktree } };
package/src/seats.ts CHANGED
@@ -10,10 +10,17 @@ import {
10
10
  import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
11
11
  import { seatTasks } from "./tasks";
12
12
  import { projectHerdrSeat } from "./multiplexer";
13
+ import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
14
+
15
+ const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
13
16
 
14
17
  const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
18
+ main: {
19
+ address: "main@{project}", branch: "main", worktree: "{repository}",
20
+ ...(dynamicModels ? {} : { agent: "pi" }),
21
+ },
15
22
  coordinator: {
16
- address: "coordinator@{project}", branch: "main", worktree: "{repository}",
23
+ address: "coordinator.{name}@{project}", branch: "integration/{name}", base: "main", worktree: "{worktree_root}/{name}",
17
24
  ...(dynamicModels ? {} : { agent: "pi" }),
18
25
  },
19
26
  driver: {
@@ -75,10 +82,27 @@ export async function spawn(root: string, projectName: string, roleName: string,
75
82
  const config = await project(root, projectName);
76
83
  const role = required(config.roles[roleName], `role ${roleName}`);
77
84
  const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
78
- const address = fill(role.address, entries);
79
- const configuredPath = role.worktree ? fill(role.worktree, { ...entries, repository: config.repository ?? "" }) : undefined;
85
+ // Retain the historical primary coordinator command for existing Desks while
86
+ // new defaults make main@project the only new primary identity.
87
+ const legacyPrimaryCoordinator = roleName === "coordinator" && name === "main"
88
+ && role.address === "coordinator.{name}@{project}" && role.branch === "integration/{name}";
89
+ const address = legacyPrimaryCoordinator ? `coordinator@${config.project}` : fill(role.address, entries);
90
+ const configuredPath = legacyPrimaryCoordinator ? config.repository : role.worktree ? fill(role.worktree, { ...entries, repository: config.repository ?? "" }) : undefined;
80
91
  const worktree = resolve(required(words(args, "--worktree") ?? configuredPath, "--worktree or role worktree template"));
81
- const branch = words(args, "--branch") ?? fill(role.branch, { project: config.project, name });
92
+ const branch = words(args, "--branch") ?? (legacyPrimaryCoordinator ? "main" : fill(role.branch, { project: config.project, name }));
93
+ const newMainDefaults = roleName === "main" && role.address === "main@{project}" && role.branch === "main";
94
+ const newNamedCoordinatorDefaults = roleName === "coordinator" && !legacyPrimaryCoordinator
95
+ && role.address === "coordinator.{name}@{project}" && role.branch === "integration/{name}";
96
+ if (newMainDefaults && (branch !== "main" || (config.repository && worktree !== resolve(config.repository)))) {
97
+ throw new Error(`Main seat main@${config.project} must use the declared primary main worktree and branch.`);
98
+ }
99
+ if (newNamedCoordinatorDefaults) {
100
+ if (!/^[a-z0-9_-]+$/.test(name)) throw new Error("Named coordinator must use one single stream name.");
101
+ const expectedWorktree = resolve(fill(required(role.worktree, "named coordinator worktree template"), { ...entries, repository: config.repository ?? "" }));
102
+ if (branch !== `integration/${name}` || worktree !== expectedWorktree) {
103
+ throw new Error(`Named coordinator ${address} must use its declared integration/${name} branch and linked worktree.`);
104
+ }
105
+ }
82
106
  await ensureWorktree(config, role, worktree, branch);
83
107
  const purpose = words(args, "--purpose") ?? (role.purpose ? fill(role.purpose, entries) : undefined);
84
108
  const portfolio = await modelPortfolio(root);
@@ -360,6 +384,17 @@ export async function openSeat(root: string, address: string) {
360
384
  // legacy scalar form while the durable record now carries its session too.
361
385
  const herdr = record.runtime?.addresses.herdr;
362
386
  if (herdr && typeof herdr !== "string") console.log(`herdr: ${herdr.pane} (session: ${herdr.session})`);
387
+ if (typeof herdr === "string") console.log(`herdr: ${herdr} (unscoped; unverified)`);
388
+ if (record.runtime?.application === "herdr") {
389
+ const advisory = await readRuntimeState(root, resolved);
390
+ const missing = [
391
+ typeof herdr === "string" || !herdr ? "Herdr session" : undefined,
392
+ !record.runtime.pi_session || !record.runtime.pi_session_path ? "Pi session/path" : undefined,
393
+ !record.runtime.launch_receipt || !await exists(record.runtime.launch_receipt) ? "launch receipt" : undefined,
394
+ !advisory || isRuntimeStateStale(advisory, 60_000) ? "advisory heartbeat" : undefined,
395
+ ].filter((entry): entry is string => Boolean(entry));
396
+ console.log(`runtime verification: ${missing.length ? `unverified (${missing.join(", ")})` : "bounded metadata and fresh advisory heartbeat"}`);
397
+ }
363
398
  const checkpointFile = paths(root).checkpointFile(resolved);
364
399
  if (await exists(checkpointFile)) console.log(yaml.print(await readYaml<Checkpoint>(checkpointFile)));
365
400
  const threadIds = await readdir(paths(root).threads);
@@ -369,4 +404,5 @@ export async function openSeat(root: string, address: string) {
369
404
  const entry = await readYaml<{ participants: string[]; state: string; subject: string }>(file);
370
405
  if (entry.participants.includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
371
406
  }
407
+ console.log(`Convention: ${lifecycleConventionPath}`);
372
408
  }
package/src/tasks.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readdir } from "node:fs/promises";
2
+ import { resolve } from "node:path";
2
3
  import {
3
4
  type Checkpoint, atomicYaml, canonicalAddress, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
4
5
  } from "./core";
@@ -10,6 +11,7 @@ export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assi
10
11
  /** A mutable projection of an immutable evidence message/reference, not a second task lifecycle. */
11
12
  export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string };
12
13
  export type DoneWhen = { text: string; proof?: string };
14
+ export type GovernedBase = { coordinator: string; branch: string; commit: string };
13
15
  export type Task = {
14
16
  schema: "atdd-workflow/task/v1";
15
17
  title: string;
@@ -24,6 +26,8 @@ export type Task = {
24
26
  phase?: TaskPhase;
25
27
  /** Absent on legacy tasks; evidence remains an opaque immutable message/reference. */
26
28
  handoff?: TaskHandoff;
29
+ /** Exact named-coordinator or main head from which an assigned driver is governed. */
30
+ governed_base?: GovernedBase;
27
31
  done_when: DoneWhen[];
28
32
  };
29
33
 
@@ -69,6 +73,42 @@ async function writeTask(root: string, projectName: string, id: string, task: Ta
69
73
  await atomicYaml(paths(root).taskFile(projectName, id), task);
70
74
  }
71
75
 
76
+ /** Records the exact parent head for new bounded topology without rewriting legacy tasks. */
77
+ async function governedBase(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<GovernedBase | undefined> {
78
+ if (!assignee) return undefined;
79
+ const config = await project(root, projectName);
80
+ if (!config.repository) return undefined;
81
+ const coordinator = await seat(root, coordinatorAddress);
82
+ const driver = await seat(root, assignee);
83
+ if (driver.role !== "driver") throw new Error(`${assignee} is not a driver seat.`);
84
+ const primary = resolve(config.repository);
85
+ let branch: string;
86
+ if (coordinator.role === "main") {
87
+ if (coordinator.address !== `main@${config.project}` || coordinator.branch !== "main" || resolve(coordinator.worktree) !== primary) {
88
+ throw new Error(`Main coordinator ${coordinator.address} has mismatched primary lineage.`);
89
+ }
90
+ branch = "main";
91
+ } else if (coordinator.role === "coordinator") {
92
+ const configured = config.roles.coordinator;
93
+ // Existing coordinator role templates and coordinator@project records
94
+ // predate bounded integration lineage and remain readable unchanged.
95
+ if (configured?.address !== "coordinator.{name}@{project}" || configured.branch !== "integration/{name}" || coordinator.address === `coordinator@${config.project}`) return undefined;
96
+ const suffix = `@${config.project}`;
97
+ const local = coordinator.address.endsWith(suffix) ? coordinator.address.slice(0, -suffix.length) : "";
98
+ const match = local.match(/^coordinator\.([a-z0-9_-]+)$/);
99
+ if (!match || coordinator.branch !== `integration/${match[1]}` || resolve(coordinator.worktree) === primary) {
100
+ throw new Error(`Coordinator ${coordinator.address} has mismatched or nested integration lineage.`);
101
+ }
102
+ branch = coordinator.branch;
103
+ } else {
104
+ throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
105
+ }
106
+ const commit = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", branch]);
107
+ const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, driver.branch]);
108
+ if (common !== commit) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
109
+ return { coordinator: coordinator.address, branch, commit };
110
+ }
111
+
72
112
  async function retireAssignee(root: string, projectName: string, id: string, task: Task) {
73
113
  const assignee = required(task.assignee, `an assignee for task ${id}`);
74
114
  const unfinished = (await allTasks(root, projectName))
@@ -107,6 +147,7 @@ export async function add(root: string, projectName: string, id: string, args: s
107
147
  const assignee = assigneeValue ? await canonicalAddress(root, assigneeValue) : undefined;
108
148
  await seat(root, coordinator);
109
149
  if (assignee) await seat(root, assignee);
150
+ const base = await governedBase(root, projectName, coordinator, assignee);
110
151
  const doneWhen = values(args, "--done-when").map((text) => ({ text }));
111
152
  if (!doneWhen.length) throw new Error("A task needs at least one --done-when criterion.");
112
153
  const dependsOn = words(args, "--depends-on")?.split(",").filter(Boolean).map(taskId);
@@ -116,6 +157,7 @@ export async function add(root: string, projectName: string, id: string, args: s
116
157
  status: "todo",
117
158
  coordinator,
118
159
  ...(assignee ? { assignee } : {}),
160
+ ...(base ? { governed_base: base } : {}),
119
161
  ...(words(args, "--body") ? { body: words(args, "--body") } : {}),
120
162
  ...(words(args, "--source") ? { source: words(args, "--source") } : {}),
121
163
  ...(dependsOn?.length ? { depends_on: dependsOn } : {}),
@@ -133,7 +175,9 @@ export async function assign(root: string, projectName: string, id: string, args
133
175
  if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
134
176
  const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
135
177
  await seat(root, assignee);
178
+ const base = await governedBase(root, projectName, task.coordinator, assignee);
136
179
  task.assignee = assignee;
180
+ if (base) task.governed_base = base;
137
181
  await writeTask(root, projectName, id, task);
138
182
  console.log(`${id} assigned ${assignee}`);
139
183
  }