@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
|
package/extensions/pi/index.ts
CHANGED
|
@@ -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:
|
|
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
|
@@ -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.
|