@afokapu/atdd-flow 0.3.38 → 0.3.40
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
|
@@ -130,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
|
|
|
130
130
|
atdd-flow task done example-app runtime-rollout --by coordinator@example-app
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled.
|
|
133
|
+
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled. `main@<project>` may likewise retire a coordinator seat (never main, the operator, or a seat on the primary checkout) once every task it coordinates or is assigned is `done`, through the same worktree-finish, absent-and-unregistered, and fail-closed paths. Retirement runs in the seat worktree with ATDD Bun from the primary checkout's `node_modules/.bin`, else the primary checkout's own source when it is the ATDD Bun package, else `PATH`; never the seat worktree's copy, which may pin an older toolkit.
|
|
134
134
|
|
|
135
135
|
At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
|
|
136
136
|
|
|
@@ -188,7 +188,7 @@ atdd-flow multiplexer apply herdr
|
|
|
188
188
|
|
|
189
189
|
For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main/coordinator seat using it a tab and pane named `{seat.address}`. Linked coordinator worktrees and active drivers become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. A driver is active only while it has an unblocked `in_progress` assigned task or a bound Herdr pane that still runs an agent; never-started, blocked, and finished drivers get no workspace. An explicit per-seat projection such as `pi runtime launch` still projects its own seat. A missing linked worktree is skipped and reported under `unprojected` instead of aborting the Desk. Unbound worktrees are ignored, and apply never focuses or guesses about other sessions.
|
|
190
190
|
|
|
191
|
-
A workspace is stale when it is labelled with a Desk driver seat address, that driver is retired or inactive, and its checkout is a linked worktree of the seat's Desk project or no longer exists. `multiplexer status` reports the `stale` count. `multiplexer apply` closes stale workspaces only when `desk.yaml` enables it:
|
|
191
|
+
A workspace is stale when it is labelled with a Desk driver seat address, that driver is retired or inactive, and its checkout is a linked worktree of the seat's Desk project or no longer exists. `multiplexer status` reports the `stale` count and, read-only, lists each one under `stale_workspaces` with its `address`, `workspace_id`, `reason` (`retired` or `no active task`), current pane `agents` and `agent_status`, and the `outcome` apply would take (`close` or `keep`, with `because` for a kept workspace). Review that list before running apply. `multiplexer apply` closes stale workspaces only when `desk.yaml` enables it:
|
|
192
192
|
|
|
193
193
|
```yaml
|
|
194
194
|
multiplexer:
|
|
@@ -246,8 +246,9 @@ content:
|
|
|
246
246
|
review and writes a blocked checkpoint for investigation.
|
|
247
247
|
|
|
248
248
|
Before working in or retiring a driver worktree, install that repository's local dependencies there.
|
|
249
|
-
Retirement resolves ATDD Bun from the
|
|
250
|
-
|
|
249
|
+
Retirement runs in the seat worktree but resolves ATDD Bun from the primary checkout's local
|
|
250
|
+
node_modules/.bin, then the primary checkout's own source when it is the ATDD Bun package, then PATH.
|
|
251
|
+
It never uses the seat worktree's copy, which may pin an older toolkit that refuses its own retirement.
|
|
251
252
|
|
|
252
253
|
Use the atdd-flow CLI to mutate workflow records. Read task, seat, checkpoint, and thread YAML
|
|
253
254
|
before acting; do not treat terminal pane history, a multiplexer, an optional judgment helper, or an
|
package/package.json
CHANGED
package/src/multiplexer.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type HerdrPolicy = {
|
|
|
26
26
|
|
|
27
27
|
type Workspace = { workspace_id: string; label?: string; worktree?: { checkout_path?: string } };
|
|
28
28
|
type Tab = { tab_id: string; workspace_id: string; label?: string };
|
|
29
|
-
type Pane = { pane_id: string; tab_id: string; label?: string; agent_status?: string };
|
|
29
|
+
type Pane = { pane_id: string; tab_id: string; label?: string; agent?: string; agent_status?: string };
|
|
30
30
|
type Target = { project: string; worktree: string; workspaceLabel: string; seat?: Seat };
|
|
31
31
|
type WorkspaceState = { id: string; label: string; worktree: string; rootTab?: string; rootPane?: string };
|
|
32
32
|
type HerdrCommand = (command: string[]) => Promise<string>;
|
|
@@ -307,7 +307,7 @@ export async function projectHerdrSeat(root: string, address: string, session: s
|
|
|
307
307
|
return (await ensureTarget(session, target, workspaces, command, primary))!;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
type StaleWorkspace = { workspace: Workspace; address: string };
|
|
310
|
+
type StaleWorkspace = { workspace: Workspace; address: string; reason: "retired" | "no active task" };
|
|
311
311
|
|
|
312
312
|
async function commonDirectory(path: string) {
|
|
313
313
|
return runOutput(["git", "-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
@@ -344,19 +344,42 @@ async function staleWorkspaces(root: string, workspaces: Workspace[], activeDriv
|
|
|
344
344
|
for (const workspace of workspaces.filter((candidate) => candidate.label === entry.address)) {
|
|
345
345
|
const checkout = workspace.worktree?.checkout_path;
|
|
346
346
|
if (typeof checkout !== "string" || checkout === "" || resolve(checkout) === primary) continue;
|
|
347
|
-
if (await linkedOrMissing(checkout, primary)) result.push({ workspace, address: entry.address });
|
|
347
|
+
if (await linkedOrMissing(checkout, primary)) result.push({ workspace, address: entry.address, reason: entry.retired ? "retired" : "no active task" });
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
351
|
return result;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
354
|
+
type StaleDecision = {
|
|
355
|
+
address: string;
|
|
356
|
+
workspace_id: string;
|
|
357
|
+
reason: StaleWorkspace["reason"];
|
|
358
|
+
agents: Array<Pick<Pane, "pane_id" | "agent" | "agent_status">>;
|
|
359
|
+
outcome: "close" | "keep";
|
|
360
|
+
because?: string;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const closeInactiveOff = "close_inactive is off";
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Read-only decision for one stale workspace, shared by `status` and `apply`: it is closed only when
|
|
367
|
+
* close_inactive is on and none of its agents is working or blocked.
|
|
368
|
+
*/
|
|
369
|
+
async function decideStale(session: string, stale: StaleWorkspace, close: boolean, command: HerdrCommand): Promise<StaleDecision> {
|
|
370
|
+
const agents = (await panes(session, stale.workspace.workspace_id, command))
|
|
371
|
+
.map(({ pane_id, agent, agent_status }) => ({ pane_id, ...(agent ? { agent } : {}), ...(agent_status ? { agent_status } : {}) }));
|
|
372
|
+
const busy = agents.find((entry) => entry.agent_status === "working" || entry.agent_status === "blocked");
|
|
373
|
+
const because = !close ? closeInactiveOff : busy ? `agent in ${busy.pane_id} is ${busy.agent_status}` : undefined;
|
|
374
|
+
return { address: stale.address, workspace_id: stale.workspace.workspace_id, reason: stale.reason, agents, outcome: because ? "keep" : "close", ...(because ? { because } : {}) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Acts on one stale-workspace decision: closes it, or reports why it is kept. */
|
|
378
|
+
async function applyStale(session: string, decision: StaleDecision, command: HerdrCommand) {
|
|
379
|
+
const label = `${decision.address} ${decision.workspace_id}`;
|
|
380
|
+
if (decision.outcome === "keep") return `${decision.because === closeInactiveOff ? "stale" : "kept"} ${label}: ${decision.because}`;
|
|
381
|
+
await command(["herdr", "--session", session, "workspace", "close", decision.workspace_id]);
|
|
382
|
+
return `closed ${label}`;
|
|
360
383
|
}
|
|
361
384
|
|
|
362
385
|
const closeInactive = async (root: string) => (await desk(root)).multiplexer?.close_inactive === true;
|
|
@@ -374,7 +397,7 @@ export async function closeRetiredDriverWorkspace(root: string, entry: Seat, com
|
|
|
374
397
|
if (!session) return;
|
|
375
398
|
const workspaces = (await liveWorkspaces(session, command)).filter((workspace) => workspace.label === entry.address);
|
|
376
399
|
if (!workspaces.length) return;
|
|
377
|
-
for (const stale of await staleWorkspaces(root, workspaces, new Set())) console.log(await
|
|
400
|
+
for (const stale of await staleWorkspaces(root, workspaces, new Set())) console.log(await applyStale(session, await decideStale(session, stale, true, command), command));
|
|
378
401
|
} catch (error) {
|
|
379
402
|
console.log(`Workspace close for ${entry.address} was not completed: ${(error as Error).message}`);
|
|
380
403
|
}
|
|
@@ -406,15 +429,16 @@ export async function multiplexer(root: string, args: string[]) {
|
|
|
406
429
|
const activeDrivers = new Set<string>();
|
|
407
430
|
const desired = await targets(root, { unprojected, live: livePane(session, command), activeDrivers });
|
|
408
431
|
const workspaces = await liveWorkspaces(session, command);
|
|
409
|
-
const stale = await staleWorkspaces(root, workspaces, activeDrivers);
|
|
410
432
|
const close = await closeInactive(root);
|
|
433
|
+
const stale = await staleWorkspaces(root, workspaces, activeDrivers);
|
|
434
|
+
const decide = (entry: StaleWorkspace) => decideStale(session, entry, close, command);
|
|
411
435
|
if (action === "status") {
|
|
412
436
|
const present = desired.filter((target) => matchWorkspace(workspaces, target));
|
|
413
437
|
const topology = await Promise.all(desired.map(async (target) => {
|
|
414
438
|
const workspace = matchWorkspace(workspaces, target);
|
|
415
439
|
return workspace ? topologyCompliant(session, target, workspace, command) : false;
|
|
416
440
|
}));
|
|
417
|
-
console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length, stale: stale.length, close_inactive: close, unprojected }, null, 2));
|
|
441
|
+
console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length, stale: stale.length, stale_workspaces: await Promise.all(stale.map(decide)), close_inactive: close, unprojected }, null, 2));
|
|
418
442
|
return;
|
|
419
443
|
}
|
|
420
444
|
const primary = new Map<string, WorkspaceState>();
|
|
@@ -429,8 +453,7 @@ export async function multiplexer(root: string, args: string[]) {
|
|
|
429
453
|
await ensureTarget(session, target, workspaces, command, projectPrimary);
|
|
430
454
|
}
|
|
431
455
|
for (const entry of unprojected) console.log(`unprojected ${entry.address}: ${entry.reason}`);
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
456
|
+
// Decided at close time with the same read-only rule `status` reports.
|
|
457
|
+
for (const entry of stale) console.log(await applyStale(session, await decide(entry), command));
|
|
435
458
|
console.log(`Projected ${desired.length} Desk seat worktree(s) into Herdr session ${session}.`);
|
|
436
459
|
}
|
package/src/tasks.ts
CHANGED
|
@@ -172,9 +172,26 @@ async function absentUnregisteredWorktree(root: string, projectName: string, own
|
|
|
172
172
|
return `Worktree ${owner.worktree} is absent and unregistered; retired without worktree finish; branch ${owner.branch} retained.`;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Resolves the ATDD Bun that retires a seat: the primary checkout's local copy, then the primary
|
|
177
|
+
* checkout's package source when it is ATDD Bun itself, then PATH. A seat worktree copy is never used:
|
|
178
|
+
* a finished branch may pin an older toolkit that refuses the seat being retired.
|
|
179
|
+
*/
|
|
180
|
+
async function retirementBun(root: string, projectName: string) {
|
|
181
|
+
const primary = resolve(required((await project(root, projectName)).repository, `a repository for project ${projectName}`));
|
|
182
|
+
const local = join(primary, "node_modules", ".bin", "atdd-bun");
|
|
183
|
+
if (await exists(local)) return [local];
|
|
184
|
+
const source = join(primary, "src", "cli.ts");
|
|
185
|
+
try {
|
|
186
|
+
if ((await Bun.file(join(primary, "package.json")).json()).name === "@afokapu/atdd-bun" && await exists(source)) return [process.execPath, source];
|
|
187
|
+
} catch { /* no readable package manifest; fall through to PATH */ }
|
|
188
|
+
// Absolute so command execution never substitutes the seat worktree's local copy.
|
|
189
|
+
return [required(Bun.which("atdd-bun") ?? undefined, "atdd-bun in the primary checkout node_modules/.bin or on PATH")];
|
|
190
|
+
}
|
|
191
|
+
|
|
175
192
|
async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false) {
|
|
176
193
|
const unfinished = (await allTasks(root, projectName))
|
|
177
|
-
.filter((entry) => entry.id !== id && entry.task.assignee === assignee && entry.task.status !== "done")
|
|
194
|
+
.filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && entry.task.status !== "done")
|
|
178
195
|
.map((entry) => entry.id);
|
|
179
196
|
if (unfinished.length) throw new Error(`Cannot retire ${assignee}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
180
197
|
|
|
@@ -182,7 +199,7 @@ async function retireDriver(root: string, projectName: string, assignee: string,
|
|
|
182
199
|
if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
|
|
183
200
|
try {
|
|
184
201
|
const summary = (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
|
|
185
|
-
?? await runOutput([
|
|
202
|
+
?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch"], owner.worktree);
|
|
186
203
|
owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
|
|
187
204
|
await atomicYaml(paths(root).seatFile(assignee), owner);
|
|
188
205
|
const checkpoint: Checkpoint = {
|
|
@@ -206,20 +223,40 @@ async function retireDriver(root: string, projectName: string, assignee: string,
|
|
|
206
223
|
await closeRetiredDriverWorkspace(root, owner);
|
|
207
224
|
}
|
|
208
225
|
|
|
209
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* Only main retires a coordinator, and never one seated on the primary checkout; a driver may also be
|
|
228
|
+
* retired by the coordinator of every task assigned to it.
|
|
229
|
+
*/
|
|
230
|
+
async function assertRetireAuthority(root: string, owner: Seat, actor: string, owned: ListedTask[]) {
|
|
231
|
+
const main = `main@${owner.project}`;
|
|
232
|
+
if (owner.role === "coordinator") {
|
|
233
|
+
if (actor !== main) throw new Error(`Only ${main} may retire coordinator ${owner.address}.`);
|
|
234
|
+
const primary = (await project(root, owner.project)).repository;
|
|
235
|
+
if (primary && await canonicalPath(owner.worktree) === await canonicalPath(primary)) {
|
|
236
|
+
throw new Error(`Cannot retire ${owner.address}; its worktree is the primary checkout.`);
|
|
237
|
+
}
|
|
238
|
+
} else if (actor !== main && owned.some((entry) => entry.task.coordinator !== actor)) {
|
|
239
|
+
throw new Error(`Only ${main} or the coordinator of every task assigned to ${owner.address} may retire it.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Retires a driver whose assigned tasks are all already done, or, for main only, a coordinator
|
|
245
|
+
* (never main or the primary checkout) whose coordinated and assigned tasks are all done.
|
|
246
|
+
*/
|
|
210
247
|
export async function retireSeat(root: string, address: string, args: string[]) {
|
|
211
248
|
const resolved = await canonicalAddress(root, address);
|
|
212
249
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
213
250
|
const owner = await seat(root, resolved);
|
|
214
|
-
|
|
251
|
+
const coordinator = owner.role === "coordinator";
|
|
252
|
+
if (owner.role !== "driver" && !coordinator) throw new Error(`Only driver or coordinator seats can be retired; ${resolved} is ${owner.role}.`);
|
|
215
253
|
if (owner.retired) throw new Error(`Seat ${resolved} is already retired.`);
|
|
216
|
-
const owned = (await allTasks(root, owner.project))
|
|
217
|
-
|
|
254
|
+
const owned = (await allTasks(root, owner.project))
|
|
255
|
+
.filter((entry) => entry.task.assignee === resolved || (coordinator && entry.task.coordinator === resolved));
|
|
256
|
+
if (!owned.length) throw new Error(`Cannot retire ${resolved}; it has no ${coordinator ? "coordinated or " : ""}assigned tasks.`);
|
|
218
257
|
const unfinished = owned.filter((entry) => entry.task.status !== "done").map((entry) => entry.id);
|
|
219
258
|
if (unfinished.length) throw new Error(`Cannot retire ${resolved}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
220
|
-
|
|
221
|
-
throw new Error(`Only main@${owner.project} or the coordinator of every task assigned to ${resolved} may retire it.`);
|
|
222
|
-
}
|
|
259
|
+
await assertRetireAuthority(root, owner, actor, owned);
|
|
223
260
|
await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true);
|
|
224
261
|
console.log(`${resolved} retired`);
|
|
225
262
|
}
|