@afokapu/atdd-flow 0.3.33 → 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 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
@@ -0,0 +1,44 @@
1
+ schema_version: 1.0.0
2
+ rule_id: atdd-flow.collaboration
3
+ kind: policy
4
+ status: active
5
+ name: Collaborate through durable accountable delegation
6
+ statement: >-
7
+ Agents use accountable delegation, evidence-based escalation, economical delivery,
8
+ and bounded two-way resolution while keeping work within its assigned project and task.
9
+ content:
10
+ canonical_policy: >-
11
+ Delegation flows operator → main → named coordinator → driver. Each level proactively
12
+ dispatches safe independent work, verifies durable delivery and visible execution, and
13
+ escalates material blockers upward with exact evidence, an economical recommendation,
14
+ viable alternatives and tradeoffs, and safe work that can continue. Choose the smallest
15
+ non-duplicative compliant implementation; do not skip lifecycle or gates. Use bounded
16
+ two-way evidence dialogue with the responsible actor to resolve assumptions or
17
+ disagreement; time-box it and escalate only unresolved decisions. Distinguish persisted,
18
+ delivered, acknowledged, awaiting actor, and executing. Never infer authorization or
19
+ restart productive sessions merely to relay policy. Proactive next-task discovery stays
20
+ within the same project. Keep one bounded active task and worktree per driver; assign
21
+ adjacent work separately. Cross-project dialogue is only for a real dependency or
22
+ interface through counterpart authority. Prefer atomic assignment; unassigned work needs
23
+ a project-main decision proposal with purpose, priority, recommended assignee,
24
+ alternatives, consequences, dependencies, and safe work meanwhile. Promptly complete,
25
+ supersede, reject, or time-bound defer work. Coordinating actors reconcile their durable
26
+ inbox as the first action at every session, turn, and task resumption, and after every
27
+ bounded action, phase completion, or wait before the next action. An actionable
28
+ expects_result remains visible until answered, executed, delegated with verified delivery
29
+ and follow-up, or explicitly deferred, superseded, or closed. Messages only communicate,
30
+ evidence, consensus, authorization, or status; they never request executable work unless it
31
+ references an existing assigned task. Tasks define, assign, execute, evidence, and complete
32
+ work; every code or repository mutation requires that task first. The operator accepts and
33
+ receipts inbound communication from any seat, but sends substantive direction, results, and
34
+ wakes only to main@project. Only main@project executes that project repository merge; the
35
+ operator may authorize, relay, or inspect but never merges.
36
+ projection: >-
37
+ Keep this full policy only in this convention. Managed AGENTS.md and CLAUDE.md blocks
38
+ contain only its canonical pointer and preserve all user-authored content outside the
39
+ marked block.
40
+ metadata:
41
+ aliases:
42
+ - ATDD-FLOW-COLLABORATION
43
+ implementation:
44
+ type: source-resolved
@@ -6,6 +6,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
7
7
  import { paths, readYaml, type Seat } from "../../src/core";
8
8
  import { directiveNotice, nextDirective } from "../../src/tasks";
9
+ import { runtimeStartupInstruction } from "../../src/collaboration-policy";
9
10
 
10
11
  type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
11
12
  export type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown; created_at?: unknown };
@@ -63,7 +64,6 @@ const defaultHeartbeatMs = 15_000;
63
64
  const defaultStaleAfterMs = 60_000;
64
65
  const defaultActivationIntervalMs = 25;
65
66
  const defaultActivationTimeoutMs = 60_000;
66
- const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
67
67
 
68
68
  function participants(thread: Thread) {
69
69
  return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string") ? thread.participants as string[] : [];
@@ -358,7 +358,7 @@ export default function (pi: ExtensionAPI) {
358
358
  root, seat, piSession, herdrSession, pane,
359
359
  activate: async () => {
360
360
  if (!await current.activate()) return;
361
- 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" });
361
+ pi.sendMessage({ customType: "atdd-flow-start", content: runtimeStartupInstruction(seat), display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
362
362
  await sendTaskDirective(pi, root, seat);
363
363
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
364
364
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.33",
3
+ "version": "0.3.35",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,76 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { parse } from "yaml";
5
+
6
+ export const collaborationConventionPath = "conventions/atdd-flow.collaboration/atdd-flow.collaboration.convention.yaml";
7
+ const startMarker = "<!-- atdd-flow:collaboration-policy:start -->";
8
+ const endMarker = "<!-- atdd-flow:collaboration-policy:end -->";
9
+ export const collaborationPolicyPointer = `ATDD Flow collaboration policy: node_modules/@afokapu/atdd-flow/${collaborationConventionPath} (source checkout: ${collaborationConventionPath})`;
10
+
11
+ type Convention = { content?: { canonical_policy?: unknown } };
12
+
13
+ function conventionFile() {
14
+ return join(import.meta.dir, "..", collaborationConventionPath);
15
+ }
16
+
17
+ /** The convention is the sole policy text; projections and runtimes resolve it from here. */
18
+ export function canonicalCollaborationPolicy() {
19
+ const convention = parse(readFileSync(conventionFile(), "utf8")) as Convention;
20
+ const policy = convention.content?.canonical_policy;
21
+ if (typeof policy !== "string" || !policy.trim()) throw new Error("Collaboration convention requires content.canonical_policy.");
22
+ return policy.trim();
23
+ }
24
+
25
+ function block() {
26
+ return `${startMarker}\n${collaborationPolicyPointer}\n${endMarker}\n`;
27
+ }
28
+
29
+ function escaped(value: string) {
30
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ }
32
+
33
+ const existingBlock = new RegExp(`${escaped(startMarker)}\\r?\\n[\\s\\S]*?${escaped(endMarker)}(?:\\r?\\n)?`, "g");
34
+
35
+ /** Replaces every prior managed block with one canonical pointer while retaining all other bytes. */
36
+ export function projectCollaborationPolicyText(source: string) {
37
+ const matches = [...source.matchAll(existingBlock)];
38
+ if (matches.length) {
39
+ const first = matches[0]!;
40
+ const before = source.slice(0, first.index);
41
+ const after = source.slice((first.index ?? 0) + first[0].length).replace(existingBlock, "");
42
+ return `${before}${block()}${after}`;
43
+ }
44
+ if (!source) return block();
45
+ return `${source}${source.endsWith("\n") ? "\n" : "\n\n"}${block()}`;
46
+ }
47
+
48
+ function lineCount(text: string) {
49
+ return text ? text.replace(/\r?\n$/, "").split(/\r?\n/).length : 0;
50
+ }
51
+
52
+ /** Idempotently projects the canonical pointer while preserving user content and refusing oversized instruction files. */
53
+ export async function projectCollaborationPolicy(root: string) {
54
+ const projections = await Promise.all(["AGENTS.md", "CLAUDE.md"].map(async (name) => {
55
+ const file = join(root, name);
56
+ let source = "";
57
+ try { source = await readFile(file, "utf8"); }
58
+ catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
60
+ }
61
+ return { name, file, source, next: projectCollaborationPolicyText(source) };
62
+ }));
63
+ for (const { name, source, next } of projections) {
64
+ if (lineCount(source) > 20 || lineCount(next) > 20) {
65
+ throw new Error(`${name} exceeds the 20-line cap; refusing projection without changing user content.`);
66
+ }
67
+ }
68
+ await Promise.all(projections.map(async ({ file, source, next }) => {
69
+ if (next !== source) await writeFile(file, next, "utf8");
70
+ }));
71
+ }
72
+
73
+ /** The one startup handoff for supported Flow-managed runtimes, resolved from the canonical convention. */
74
+ export function runtimeStartupInstruction(seat: string) {
75
+ return `SYSTEM: you are ${seat}. Collaboration policy: ${canonicalCollaborationPolicy()} Canonical convention: ${collaborationConventionPath}. 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.`;
76
+ }
package/src/seats.ts CHANGED
@@ -1,18 +1,19 @@
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";
16
+ import { projectCollaborationPolicy } from "./collaboration-policy";
16
17
 
17
18
  const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
18
19
 
@@ -51,6 +52,7 @@ export async function init(root: string, name: string, args: string[]) {
51
52
  const config = { schema: "atdd-workflow/desk/v1" as const, desk: name, application: "herdr", executables: defaultExecutables() };
52
53
  await Promise.all([mkdir(paths(root).work, { recursive: true }), mkdir(paths(root).threads, { recursive: true })]);
53
54
  await Promise.all([atomicYaml(paths(root).desk, config), atomicYaml(paths(root).models, defaultModels())]);
55
+ await projectCollaborationPolicy(root);
54
56
  if (args.includes("--git") && !await exists(join(root, ".git"))) await run(["git", "init", "--initial-branch=main", root]);
55
57
  console.log(`Initialized Desk ${root}`);
56
58
  }
@@ -58,6 +60,7 @@ export async function init(root: string, name: string, args: string[]) {
58
60
  export async function migrate(root: string) {
59
61
  if (await migrateDesk(root)) console.log("Migrated legacy coordination registry to desk.yaml");
60
62
  else console.log("Desk registry already exists");
63
+ await projectCollaborationPolicy(root);
61
64
  }
62
65
 
63
66
  export async function initProject(root: string, name: string) {
@@ -109,9 +112,98 @@ async function ensureWorktree(config: Project, role: Role, worktree: string, bra
109
112
  await run(command, true);
110
113
  }
111
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
+
112
200
  export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {
113
201
  const config = await project(root, projectName);
114
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
+ }
115
207
  const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
116
208
  // Retain the historical primary coordinator command for existing Desks while
117
209
  // new defaults make main@project the only new primary identity.