@afokapu/atdd-flow 0.3.39 → 0.3.41
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 +1 -1
- package/conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml +3 -2
- package/package.json +1 -1
- package/src/cli.ts +6 -1
- package/src/multiplexer.ts +2 -2
- package/src/overview.ts +11 -8
- package/src/owner-escalations.ts +2 -2
- package/src/seats.ts +3 -3
- package/src/tasks.ts +130 -21
- package/src/threads.ts +3 -3
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
|
|
|
@@ -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/cli.ts
CHANGED
|
@@ -49,6 +49,8 @@ Usage:
|
|
|
49
49
|
atdd-flow task handoff <project> <task-id> --by <assignee> --phase <plan|red|green|refactor> --evidence <message-or-reference>
|
|
50
50
|
atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
|
|
51
51
|
atdd-flow task block <project> <task-id> --by <address> --reason <text>
|
|
52
|
+
atdd-flow task supersede|reject <project> <task-id> --by <coordinator-address> --reason <text>
|
|
53
|
+
atdd-flow task defer <project> <task-id> --by <coordinator-address> --reason <text> --owner <address> --trigger <text> --review-at <ISO-8601>
|
|
52
54
|
atdd-flow task unblock <project> <task-id> --by <coordinator-address>
|
|
53
55
|
atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
|
|
54
56
|
atdd-flow task open <project> <task-id>
|
|
@@ -132,10 +134,13 @@ async function main() {
|
|
|
132
134
|
if (subcommand === "handoff") return tasks.submitHandoff(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
133
135
|
if (subcommand === "respond") return tasks.respondToHandoff(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
134
136
|
if (subcommand === "block") return tasks.block(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
137
|
+
if (subcommand === "supersede") return tasks.disposition(root, required(projectName, "project"), required(taskId, "task id"), "superseded", tail);
|
|
138
|
+
if (subcommand === "reject") return tasks.disposition(root, required(projectName, "project"), required(taskId, "task id"), "rejected", tail);
|
|
139
|
+
if (subcommand === "defer") return tasks.defer(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
135
140
|
if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
136
141
|
if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
|
|
137
142
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
138
|
-
throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
|
|
143
|
+
throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|supersede|reject|defer|unblock|list|open`.");
|
|
139
144
|
},
|
|
140
145
|
seat: async () => {
|
|
141
146
|
if (rest[0] === "retire") return tasks.retireSeat(root, required(rest[1], "address"), rest.slice(2));
|
package/src/multiplexer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { desk, exists, paths, project, readYaml, runOutput, type Project, type Seat } from "./core";
|
|
4
|
-
import { seatTasks } from "./tasks";
|
|
4
|
+
import { seatTasks, terminal } from "./tasks";
|
|
5
5
|
|
|
6
6
|
export type HerdrPolicy = {
|
|
7
7
|
schema: "atdd-workflow/multiplexer/herdr/v1";
|
|
@@ -179,7 +179,7 @@ async function targets(root: string, options: { strict?: string; unprojected?: U
|
|
|
179
179
|
if (entry.role === "driver") {
|
|
180
180
|
// An explicit per-seat projection keeps the established assigned-work rule.
|
|
181
181
|
linked = entry.address === options.strict
|
|
182
|
-
? (await seatTasks(root, name, entry.address)).some(({ task }) => task.assignee === entry.address && task
|
|
182
|
+
? (await seatTasks(root, name, entry.address)).some(({ task }) => task.assignee === entry.address && !terminal(task))
|
|
183
183
|
: await activeDriver(root, name, entry, options.live);
|
|
184
184
|
if (linked) options.activeDrivers?.add(entry.address);
|
|
185
185
|
}
|
package/src/overview.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
|
|
3
|
-
import { type Task, type TaskHandoff, type TaskStatus } from "./tasks";
|
|
3
|
+
import { type Task, type TaskHandoff, type TaskStatus, terminal } from "./tasks";
|
|
4
4
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
5
5
|
import { type Message, type Thread } from "./threads";
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ const useColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR && proces
|
|
|
14
14
|
const paint = (text: string, code: number) => useColor ? `\u001B[${code}m${text}\u001B[0m` : text;
|
|
15
15
|
const muted = (text: string) => paint(text, 2);
|
|
16
16
|
const strong = (text: string) => paint(text, 1);
|
|
17
|
-
const tone = (text: string, state: string) => paint(text, ({ BLOCKED: 31, RE_SEAT: 31, REVIEW: 33, WAITING: 33, ASSIGN: 36, VERIFY: 35, ACTIVE: 36, DONE: 32 } as Record<string, number>)[state] ?? 37);
|
|
17
|
+
const tone = (text: string, state: string) => paint(text, ({ SUPERSEDED: 90, REJECTED: 90, BLOCKED: 31, RE_SEAT: 31, REVIEW: 33, WAITING: 33, ASSIGN: 36, VERIFY: 35, ACTIVE: 36, DONE: 32 } as Record<string, number>)[state] ?? 37);
|
|
18
18
|
const truncate = (value: string, width: number) => value.length <= width ? value : `${value.slice(0, Math.max(1, width - 1))}…`;
|
|
19
19
|
const dot = " · ";
|
|
20
20
|
|
|
@@ -55,7 +55,7 @@ async function tasks(root: string, project: string): Promise<ListedTask[]> {
|
|
|
55
55
|
const byId = new Map(entries.map((entry) => [entry.id, entry.task]));
|
|
56
56
|
return entries.map((entry) => ({
|
|
57
57
|
...entry,
|
|
58
|
-
waiting: (entry.task.depends_on ?? []).filter((dependency) => byId.get(dependency)
|
|
58
|
+
waiting: (entry.task.depends_on ?? []).filter((dependency) => !(byId.get(dependency) && terminal(byId.get(dependency)!))),
|
|
59
59
|
}));
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -99,10 +99,11 @@ function bar(done: number, total: number, width = 16) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function stateName(status: TaskStatus) {
|
|
102
|
-
return ({ todo: "READY", in_progress: "ACTIVE", review: "REVIEW", done: "DONE" } as Record<TaskStatus, string>)[status];
|
|
102
|
+
return ({ todo: "READY", in_progress: "ACTIVE", review: "REVIEW", done: "DONE", superseded: "SUPERSEDED", rejected: "REJECTED" } as Record<TaskStatus, string>)[status];
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function taskPhase(entry: ListedTask) {
|
|
106
|
+
if (terminal(entry.task)) return stateName(entry.task.status);
|
|
106
107
|
if (entry.task.blocker) return "BLOCKED";
|
|
107
108
|
if (entry.waiting.length) return "WAITING";
|
|
108
109
|
return stateName(entry.task.status);
|
|
@@ -115,7 +116,7 @@ function handoffStateName(value: TaskHandoff["state"]) {
|
|
|
115
116
|
function attentionForTask(entry: ListedTask, checkpointBySeat: Map<string, Checkpoint | undefined>): Attention | undefined {
|
|
116
117
|
const { task } = entry;
|
|
117
118
|
const inspect = `atdd-flow task open ${entry.project} ${entry.id}`;
|
|
118
|
-
if (task
|
|
119
|
+
if (terminal(task)) return undefined;
|
|
119
120
|
if (task.blocker) return {
|
|
120
121
|
project: entry.project, id: entry.id, label: task.title, state: "BLOCKED", detail: task.blocker,
|
|
121
122
|
command: inspect, priority: 0,
|
|
@@ -174,11 +175,12 @@ function printProjects(projects: string[], allTasks: ListedTask[], allSeats: Lis
|
|
|
174
175
|
const tasks = allTasks.filter((entry) => entry.project === project);
|
|
175
176
|
const seats = allSeats.filter((entry) => entry.project === project);
|
|
176
177
|
const done = tasks.filter((entry) => entry.task.status === "done").length;
|
|
178
|
+
const closed = tasks.filter((entry) => terminal(entry.task)).length;
|
|
177
179
|
const active = seats.filter((entry) => entry.checkpoint?.status === "active").length;
|
|
178
180
|
const blocked = seats.filter((entry) => entry.checkpoint?.status === "blocked").length;
|
|
179
181
|
const nameWidth = Math.min(22, Math.max(14, Math.floor(width / 4)));
|
|
180
|
-
const counts = `${done}/${tasks.length} done${active ? `${dot}${active} active` : ""}${blocked ? `${dot}${blocked} blocked` : ""}`;
|
|
181
|
-
console.log(` ${strong(truncate(project, nameWidth).padEnd(nameWidth))} ${bar(
|
|
182
|
+
const counts = `${done}/${tasks.length} done${closed > done ? `${dot}${closed - done} closed without delivery` : ""}${active ? `${dot}${active} active` : ""}${blocked ? `${dot}${blocked} blocked` : ""}`;
|
|
183
|
+
console.log(` ${strong(truncate(project, nameWidth).padEnd(nameWidth))} ${bar(closed, tasks.length)} ${counts}`);
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
|
|
@@ -311,7 +313,8 @@ async function taskDashboard(root: string, projectName: string, taskId: string)
|
|
|
311
313
|
|
|
312
314
|
rule("NEXT");
|
|
313
315
|
let next: string;
|
|
314
|
-
if (entry.task
|
|
316
|
+
if (terminal(entry.task)) next = `Closed by terminal disposition (${entry.task.status}); no further action.`;
|
|
317
|
+
else if (entry.task.blocker) next = "Resolve the recorded blocker before changing state.";
|
|
315
318
|
else if (entry.waiting.length) next = "Complete the unmet dependencies first.";
|
|
316
319
|
else if (entry.task.status === "review" && reviewRequired && !latestReview) next = `Coordinator: launch final review with \`atdd-flow behavioral-review launch ${projectName} ${taskId} --by ${entry.task.coordinator} --application <application> --placement <container>.\``;
|
|
317
320
|
else if (entry.task.status === "review" && latestReview?.status === "pending") next = `Await reviewer ${latestReview.reviewer.address}; inspect with \`atdd-flow behavioral-review open ${projectName} ${taskId}.\``;
|
package/src/owner-escalations.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { atomicYaml, exists, now as workflowNow, paths, readYaml, required, seat } from "./core";
|
|
5
|
-
import { type Task } from "./tasks";
|
|
5
|
+
import { type Task, terminal } from "./tasks";
|
|
6
6
|
import { post, startThread } from "./threads";
|
|
7
7
|
|
|
8
8
|
type Option = "authorize-safe-fallback" | "provide-cross-project-decision" | "hold-or-resolve";
|
|
@@ -70,7 +70,7 @@ async function independent(root: string, entry: { project: string; id: string },
|
|
|
70
70
|
return false;
|
|
71
71
|
}
|
|
72
72
|
function candidate(entry: { project: string; id: string; task: Task }): Candidate | undefined {
|
|
73
|
-
if (entry.task
|
|
73
|
+
if (terminal(entry.task) || !entry.task.blocker) return;
|
|
74
74
|
const material = classification(entry.task.blocker); if (!material) return;
|
|
75
75
|
return { schema: "atdd-workflow/owner-alert/v1", state: "blocked", material_class: material, affected_tasks: [`${entry.project}/${entry.id}`], evidence: redactOwnerEvidence(entry.task.blocker),
|
|
76
76
|
fallback: { state: "absent", evidence: "No independent checkpoint-active task is visibly executing in the affected project.", safe_automatic_action: "none" }, owner_options: options,
|
package/src/seats.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
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 { type Task, seatTasks } from "./tasks";
|
|
12
|
+
import { type Task, seatTasks, terminal } from "./tasks";
|
|
13
13
|
import { misplacedSeats, projectHerdrSeat } from "./multiplexer";
|
|
14
14
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
15
15
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
@@ -344,7 +344,7 @@ async function launchEligibleTasks(root: string, record: Seat, address: string)
|
|
|
344
344
|
if (entry.task.status === "in_progress") return entry;
|
|
345
345
|
if (entry.task.status !== "todo") return undefined;
|
|
346
346
|
const dependencies = await Promise.all((entry.task.depends_on ?? []).map(async (id) => {
|
|
347
|
-
try { return (await readYaml<{ status
|
|
347
|
+
try { return terminal(await readYaml<{ status: Task["status"] }>(paths(root).taskFile(record.project, id))); }
|
|
348
348
|
catch { return false; }
|
|
349
349
|
}));
|
|
350
350
|
return dependencies.every(Boolean) ? entry : undefined;
|
|
@@ -355,7 +355,7 @@ async function launchEligibleTasks(root: string, record: Seat, address: string)
|
|
|
355
355
|
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
356
356
|
const candidates = availableModelCandidates(config, portfolio);
|
|
357
357
|
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
358
|
-
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => entry.task
|
|
358
|
+
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => !terminal(entry.task));
|
|
359
359
|
const selection = await select({
|
|
360
360
|
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
361
361
|
tasks: work.map((entry) => ({
|
package/src/tasks.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
|
|
5
5
|
} from "./core";
|
|
6
6
|
|
|
7
|
-
export type TaskStatus = "todo" | "in_progress" | "review" | "done";
|
|
7
|
+
export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "superseded" | "rejected";
|
|
8
8
|
/** An optional, descriptive implementation phase; task status remains the lifecycle authority. */
|
|
9
9
|
export type TaskPhase = "plan" | "red" | "green" | "refactor";
|
|
10
10
|
export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assignee";
|
|
@@ -22,6 +22,8 @@ export type CoordinatorTransfer = {
|
|
|
22
22
|
exact_head: { branch: string; commit: string };
|
|
23
23
|
effective_at: string;
|
|
24
24
|
};
|
|
25
|
+
export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
|
|
26
|
+
export type DeferredDisposition = { by: string; at: string; reason: string; owner: string; trigger: string; review_at: string };
|
|
25
27
|
export type Task = {
|
|
26
28
|
schema: "atdd-workflow/task/v1";
|
|
27
29
|
title: string;
|
|
@@ -36,20 +38,27 @@ export type Task = {
|
|
|
36
38
|
phase?: TaskPhase;
|
|
37
39
|
/** Absent on legacy tasks; evidence remains an opaque immutable message/reference. */
|
|
38
40
|
handoff?: TaskHandoff;
|
|
39
|
-
/**
|
|
41
|
+
/** Legacy topology evidence is preserved but is not created for role-neutral assignments. */
|
|
40
42
|
governed_base?: GovernedBase;
|
|
41
43
|
/** Absent on legacy tasks; later entries never rewrite earlier coordinator evidence. */
|
|
42
44
|
coordinator_transfers?: CoordinatorTransfer[];
|
|
45
|
+
/** Immutable terminal coordinator decision. */
|
|
46
|
+
disposition?: TerminalDisposition;
|
|
47
|
+
/** Bounded hold with an accountable owner, trigger, and review point. */
|
|
48
|
+
deferred?: DeferredDisposition;
|
|
43
49
|
done_when: DoneWhen[];
|
|
44
50
|
};
|
|
45
51
|
|
|
46
52
|
export type ListedTask = { id: string; task: Task };
|
|
53
|
+
export const terminal = (task: Pick<Task, "status">) => task.status === "done" || task.status === "superseded" || task.status === "rejected";
|
|
47
54
|
|
|
48
55
|
const transitions: Record<TaskStatus, TaskStatus[]> = {
|
|
49
56
|
todo: ["in_progress"],
|
|
50
57
|
in_progress: ["review"],
|
|
51
58
|
review: ["in_progress", "done"],
|
|
52
59
|
done: [],
|
|
60
|
+
superseded: [],
|
|
61
|
+
rejected: [],
|
|
53
62
|
};
|
|
54
63
|
|
|
55
64
|
function assertCoordinatorIntegrity(task: Task, id: string) {
|
|
@@ -94,7 +103,7 @@ export async function seatTasks(root: string, projectName: string, address: stri
|
|
|
94
103
|
|
|
95
104
|
async function ready(root: string, projectName: string, task: Task) {
|
|
96
105
|
const dependencies = await Promise.all((task.depends_on ?? []).map(async (id) => ({ id, task: await readTask(root, projectName, id) })));
|
|
97
|
-
return dependencies.filter((entry) => entry.task
|
|
106
|
+
return dependencies.filter((entry) => !terminal(entry.task)).map((entry) => entry.id);
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
function allProofs(task: Task) {
|
|
@@ -141,6 +150,24 @@ async function governedBase(root: string, projectName: string, coordinatorAddres
|
|
|
141
150
|
return { coordinator: coordinator.address, branch, commit };
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Assignment is accountability only: any existing, non-retired seat of the same project is eligible
|
|
155
|
+
* regardless of role. Validation happens before any task write, so a rejection leaves no partial record.
|
|
156
|
+
*/
|
|
157
|
+
async function eligibleAssignee(root: string, projectName: string, address: string) {
|
|
158
|
+
const candidate = await seat(root, address);
|
|
159
|
+
if (candidate.project !== projectName) throw new Error(`Task assignee ${candidate.address} does not belong to project ${projectName}.`);
|
|
160
|
+
if (candidate.retired) throw new Error(`Task assignee ${candidate.address} is retired.`);
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Legacy topology evidence is recorded only when it is exactly provable; it never gates assignment. */
|
|
165
|
+
async function evidenceBase(root: string, projectName: string, coordinator: string, assignee: Seat): Promise<GovernedBase | undefined> {
|
|
166
|
+
if (assignee.role !== "driver") return undefined;
|
|
167
|
+
try { return await governedBase(root, projectName, coordinator, assignee.address); }
|
|
168
|
+
catch { return undefined; }
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
async function retireAssignee(root: string, projectName: string, id: string, task: Task) {
|
|
145
172
|
return retireDriver(root, projectName, required(task.assignee, `an assignee for task ${id}`), id);
|
|
146
173
|
}
|
|
@@ -172,9 +199,26 @@ async function absentUnregisteredWorktree(root: string, projectName: string, own
|
|
|
172
199
|
return `Worktree ${owner.worktree} is absent and unregistered; retired without worktree finish; branch ${owner.branch} retained.`;
|
|
173
200
|
}
|
|
174
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Resolves the ATDD Bun that retires a seat: the primary checkout's local copy, then the primary
|
|
204
|
+
* checkout's package source when it is ATDD Bun itself, then PATH. A seat worktree copy is never used:
|
|
205
|
+
* a finished branch may pin an older toolkit that refuses the seat being retired.
|
|
206
|
+
*/
|
|
207
|
+
async function retirementBun(root: string, projectName: string) {
|
|
208
|
+
const primary = resolve(required((await project(root, projectName)).repository, `a repository for project ${projectName}`));
|
|
209
|
+
const local = join(primary, "node_modules", ".bin", "atdd-bun");
|
|
210
|
+
if (await exists(local)) return [local];
|
|
211
|
+
const source = join(primary, "src", "cli.ts");
|
|
212
|
+
try {
|
|
213
|
+
if ((await Bun.file(join(primary, "package.json")).json()).name === "@afokapu/atdd-bun" && await exists(source)) return [process.execPath, source];
|
|
214
|
+
} catch { /* no readable package manifest; fall through to PATH */ }
|
|
215
|
+
// Absolute so command execution never substitutes the seat worktree's local copy.
|
|
216
|
+
return [required(Bun.which("atdd-bun") ?? undefined, "atdd-bun in the primary checkout node_modules/.bin or on PATH")];
|
|
217
|
+
}
|
|
218
|
+
|
|
175
219
|
async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false) {
|
|
176
220
|
const unfinished = (await allTasks(root, projectName))
|
|
177
|
-
.filter((entry) => entry.id !== id && entry.task.assignee === assignee
|
|
221
|
+
.filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && !terminal(entry.task))
|
|
178
222
|
.map((entry) => entry.id);
|
|
179
223
|
if (unfinished.length) throw new Error(`Cannot retire ${assignee}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
180
224
|
|
|
@@ -182,7 +226,7 @@ async function retireDriver(root: string, projectName: string, assignee: string,
|
|
|
182
226
|
if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
|
|
183
227
|
try {
|
|
184
228
|
const summary = (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
|
|
185
|
-
?? await runOutput([
|
|
229
|
+
?? await runOutput([...await retirementBun(root, projectName), "worktree", "finish", "--delete-branch"], owner.worktree);
|
|
186
230
|
owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
|
|
187
231
|
await atomicYaml(paths(root).seatFile(assignee), owner);
|
|
188
232
|
const checkpoint: Checkpoint = {
|
|
@@ -206,32 +250,53 @@ async function retireDriver(root: string, projectName: string, assignee: string,
|
|
|
206
250
|
await closeRetiredDriverWorkspace(root, owner);
|
|
207
251
|
}
|
|
208
252
|
|
|
209
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Only main retires a coordinator, and never one seated on the primary checkout; a driver may also be
|
|
255
|
+
* retired by the coordinator of every task assigned to it.
|
|
256
|
+
*/
|
|
257
|
+
async function assertRetireAuthority(root: string, owner: Seat, actor: string, owned: ListedTask[]) {
|
|
258
|
+
const main = `main@${owner.project}`;
|
|
259
|
+
if (owner.role === "coordinator") {
|
|
260
|
+
if (actor !== main) throw new Error(`Only ${main} may retire coordinator ${owner.address}.`);
|
|
261
|
+
const primary = (await project(root, owner.project)).repository;
|
|
262
|
+
if (primary && await canonicalPath(owner.worktree) === await canonicalPath(primary)) {
|
|
263
|
+
throw new Error(`Cannot retire ${owner.address}; its worktree is the primary checkout.`);
|
|
264
|
+
}
|
|
265
|
+
} else if (actor !== main && owned.some((entry) => entry.task.coordinator !== actor)) {
|
|
266
|
+
throw new Error(`Only ${main} or the coordinator of every task assigned to ${owner.address} may retire it.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Retires a driver whose assigned tasks are all terminal (done, superseded, or rejected), or, for main only, a coordinator
|
|
272
|
+
* (never main or the primary checkout) whose coordinated and assigned tasks are all terminal.
|
|
273
|
+
*/
|
|
210
274
|
export async function retireSeat(root: string, address: string, args: string[]) {
|
|
211
275
|
const resolved = await canonicalAddress(root, address);
|
|
212
276
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
213
277
|
const owner = await seat(root, resolved);
|
|
214
|
-
|
|
278
|
+
const coordinator = owner.role === "coordinator";
|
|
279
|
+
if (owner.role !== "driver" && !coordinator) throw new Error(`Only driver or coordinator seats can be retired; ${resolved} is ${owner.role}.`);
|
|
215
280
|
if (owner.retired) throw new Error(`Seat ${resolved} is already retired.`);
|
|
216
|
-
const owned = (await allTasks(root, owner.project))
|
|
217
|
-
|
|
218
|
-
|
|
281
|
+
const owned = (await allTasks(root, owner.project))
|
|
282
|
+
.filter((entry) => entry.task.assignee === resolved || (coordinator && entry.task.coordinator === resolved));
|
|
283
|
+
if (!owned.length) throw new Error(`Cannot retire ${resolved}; it has no ${coordinator ? "coordinated or " : ""}assigned tasks.`);
|
|
284
|
+
const unfinished = owned.filter((entry) => !terminal(entry.task)).map((entry) => entry.id);
|
|
219
285
|
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
|
-
}
|
|
286
|
+
await assertRetireAuthority(root, owner, actor, owned);
|
|
223
287
|
await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true);
|
|
224
288
|
console.log(`${resolved} retired`);
|
|
225
289
|
}
|
|
226
290
|
|
|
227
291
|
export async function add(root: string, projectName: string, id: string, args: string[]) {
|
|
292
|
+
const normalizedId = taskId(id);
|
|
228
293
|
await project(root, projectName);
|
|
294
|
+
if (await exists(paths(root).taskFile(projectName, normalizedId))) throw new Error(`Task ${normalizedId} already exists and cannot be replaced.`);
|
|
229
295
|
const coordinator = await canonicalAddress(root, required(words(args, "--coordinator"), "--coordinator"));
|
|
230
296
|
const assigneeValue = words(args, "--assignee");
|
|
231
297
|
const assignee = assigneeValue ? await canonicalAddress(root, assigneeValue) : undefined;
|
|
232
298
|
await seat(root, coordinator);
|
|
233
|
-
|
|
234
|
-
const base = await governedBase(root, projectName, coordinator, assignee);
|
|
299
|
+
const base = assignee ? await evidenceBase(root, projectName, coordinator, await eligibleAssignee(root, projectName, assignee)) : undefined;
|
|
235
300
|
const doneWhen = values(args, "--done-when").map((text) => ({ text }));
|
|
236
301
|
if (!doneWhen.length) throw new Error("A task needs at least one --done-when criterion.");
|
|
237
302
|
const dependsOn = words(args, "--depends-on")?.split(",").filter(Boolean).map(taskId);
|
|
@@ -247,8 +312,8 @@ export async function add(root: string, projectName: string, id: string, args: s
|
|
|
247
312
|
...(dependsOn?.length ? { depends_on: dependsOn } : {}),
|
|
248
313
|
done_when: doneWhen,
|
|
249
314
|
};
|
|
250
|
-
await writeTask(root, projectName,
|
|
251
|
-
console.log(
|
|
315
|
+
await writeTask(root, projectName, normalizedId, task);
|
|
316
|
+
console.log(normalizedId);
|
|
252
317
|
}
|
|
253
318
|
|
|
254
319
|
export async function assign(root: string, projectName: string, id: string, args: string[]) {
|
|
@@ -258,8 +323,7 @@ export async function assign(root: string, projectName: string, id: string, args
|
|
|
258
323
|
if (task.status !== "todo") throw new Error(`Task ${id} can only be assigned while todo.`);
|
|
259
324
|
if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
|
|
260
325
|
const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
|
|
261
|
-
await
|
|
262
|
-
const base = await governedBase(root, projectName, task.coordinator, assignee);
|
|
326
|
+
const base = await evidenceBase(root, projectName, task.coordinator, await eligibleAssignee(root, projectName, assignee));
|
|
263
327
|
task.assignee = assignee;
|
|
264
328
|
if (base) task.governed_base = base;
|
|
265
329
|
await writeTask(root, projectName, id, task);
|
|
@@ -354,6 +418,7 @@ export async function transfer(root: string, projectName: string, id: string, ar
|
|
|
354
418
|
|
|
355
419
|
export async function amend(root: string, projectName: string, id: string, args: string[]) {
|
|
356
420
|
const task = await readTask(root, projectName, taskId(id));
|
|
421
|
+
if (task.disposition) throw new Error(`Task ${id} has a terminal disposition and cannot be amended.`);
|
|
357
422
|
const title = words(args, "--title");
|
|
358
423
|
const body = words(args, "--body");
|
|
359
424
|
const source = words(args, "--source");
|
|
@@ -376,6 +441,7 @@ export async function amend(root: string, projectName: string, id: string, args:
|
|
|
376
441
|
*/
|
|
377
442
|
export async function importCompleted(root: string, projectName: string, id: string, args: string[]) {
|
|
378
443
|
const task = await readTask(root, projectName, taskId(id));
|
|
444
|
+
if (task.disposition) throw new Error(`Task ${id} has a terminal disposition and cannot be imported.`);
|
|
379
445
|
const proofs = values(args, "--proof");
|
|
380
446
|
if (!proofs.length) throw new Error("An imported completion needs at least one --proof reference.");
|
|
381
447
|
|
|
@@ -422,7 +488,7 @@ async function transition(root: string, projectName: string, id: string, next: T
|
|
|
422
488
|
if (retire) await retireAssignee(root, projectName, id, task);
|
|
423
489
|
}
|
|
424
490
|
task.status = next;
|
|
425
|
-
if (next !== "in_progress") delete task.blocker;
|
|
491
|
+
if (next !== "in_progress") { delete task.blocker; delete task.deferred; }
|
|
426
492
|
await writeTask(root, projectName, id, task);
|
|
427
493
|
console.log(`${id} ${next}`);
|
|
428
494
|
}
|
|
@@ -513,6 +579,7 @@ export async function prove(root: string, projectName: string, id: string, args:
|
|
|
513
579
|
|
|
514
580
|
export async function block(root: string, projectName: string, id: string, args: string[]) {
|
|
515
581
|
const task = await readTask(root, projectName, taskId(id));
|
|
582
|
+
if (task.disposition) throw new Error(`Task ${id} has a terminal disposition and cannot be blocked.`);
|
|
516
583
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
517
584
|
if (actor !== task.assignee && actor !== task.coordinator) throw new Error(`Only the assignee or coordinator may block task ${id}.`);
|
|
518
585
|
if (task.status === "done") throw new Error(`Completed task ${id} cannot be blocked.`);
|
|
@@ -521,6 +588,46 @@ export async function block(root: string, projectName: string, id: string, args:
|
|
|
521
588
|
console.log(`${id} blocked`);
|
|
522
589
|
}
|
|
523
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Records a terminal coordinator decision (superseded or rejected). Only the task coordinator may
|
|
593
|
+
* decide, a completed or already-disposed task is never rewritten, and the decision keeps its
|
|
594
|
+
* author, time, and reason as immutable audit history.
|
|
595
|
+
*/
|
|
596
|
+
export async function disposition(root: string, projectName: string, id: string, kind: TerminalDisposition["kind"], args: string[]) {
|
|
597
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
598
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
599
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may ${kind} task ${id}.`);
|
|
600
|
+
if (task.disposition || task.status === "done") throw new Error(`Task ${id} already has terminal history and cannot be ${kind}.`);
|
|
601
|
+
task.status = kind;
|
|
602
|
+
task.disposition = { kind, by: actor, at: now(), reason: required(words(args, "--reason"), "--reason") };
|
|
603
|
+
delete task.blocker;
|
|
604
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
605
|
+
console.log(`${id} ${kind}`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Holds a task behind a bounded deferral. The accountable owner, the trigger, and an ISO-8601 review
|
|
610
|
+
* point are mandatory, so a deferral can never become an indefinite open blocker; unblocking clears it.
|
|
611
|
+
*/
|
|
612
|
+
export async function defer(root: string, projectName: string, id: string, args: string[]) {
|
|
613
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
614
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
615
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may defer task ${id}.`);
|
|
616
|
+
if (task.disposition || task.status === "done") throw new Error(`Task ${id} has terminal history and cannot be deferred.`);
|
|
617
|
+
const ownerValue = words(args, "--owner");
|
|
618
|
+
const trigger = words(args, "--trigger");
|
|
619
|
+
const reviewAt = words(args, "--review-at");
|
|
620
|
+
if (!ownerValue || !trigger || !reviewAt) throw new Error("A defer needs --owner, --trigger, and --review-at.");
|
|
621
|
+
if (Number.isNaN(Date.parse(reviewAt))) throw new Error("--review-at must be an ISO-8601 timestamp.");
|
|
622
|
+
const owner = await canonicalAddress(root, ownerValue);
|
|
623
|
+
if ((await seat(root, owner)).project !== projectName) throw new Error(`Deferred owner ${owner} does not belong to project ${projectName}.`);
|
|
624
|
+
const reason = required(words(args, "--reason"), "--reason");
|
|
625
|
+
task.deferred = { by: actor, at: now(), reason, owner, trigger, review_at: reviewAt };
|
|
626
|
+
task.blocker = `Deferred until ${reviewAt}: ${reason}`;
|
|
627
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
628
|
+
console.log(`${id} deferred`);
|
|
629
|
+
}
|
|
630
|
+
|
|
524
631
|
export async function unblock(root: string, projectName: string, id: string, args: string[]) {
|
|
525
632
|
const task = await readTask(root, projectName, taskId(id));
|
|
526
633
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
@@ -532,6 +639,7 @@ export async function unblock(root: string, projectName: string, id: string, arg
|
|
|
532
639
|
const { assertDependentsMayUnblock } = await import("./ephemeral-resources");
|
|
533
640
|
await assertDependentsMayUnblock(root, { project: projectName, task: id });
|
|
534
641
|
delete task.blocker;
|
|
642
|
+
delete task.deferred;
|
|
535
643
|
await writeTask(root, projectName, id, task);
|
|
536
644
|
console.log(`${id} unblocked`);
|
|
537
645
|
}
|
|
@@ -573,6 +681,7 @@ async function directiveFor(root: string, projectName: string, id: string, task:
|
|
|
573
681
|
if (task.assignee === address) {
|
|
574
682
|
const as = { ...base, role: "assignee" as const };
|
|
575
683
|
if (task.status === "done") return { ...as, lifecycle: "DONE", action: "Perform DONE housekeeping only; start no new implementation." };
|
|
684
|
+
if (terminal(task)) return undefined;
|
|
576
685
|
if (task.blocker) return { ...as, lifecycle: "BLOCKED", action: `Remain blocked and await coordinator resolution (${task.blocker}). Continue only independent required work.` };
|
|
577
686
|
if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Await coordinator review; do not continue implementation." };
|
|
578
687
|
if (task.status === "todo") {
|
|
@@ -589,7 +698,7 @@ async function directiveFor(root: string, projectName: string, id: string, task:
|
|
|
589
698
|
command: `atdd-flow task handoff ${projectName} ${id} --by ${address} --phase ${current} --evidence <message-or-reference>`,
|
|
590
699
|
};
|
|
591
700
|
}
|
|
592
|
-
if (task.coordinator === address && task
|
|
701
|
+
if (task.coordinator === address && !terminal(task)) {
|
|
593
702
|
const as = { ...base, role: "coordinator" as const };
|
|
594
703
|
if (task.blocker) return { ...as, lifecycle: "BLOCKED", action: `Resolve the blocker (${task.blocker}) and then unblock the task.`, command: `atdd-flow task unblock ${projectName} ${id} --by ${address}` };
|
|
595
704
|
if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Review the submitted task; complete it or return it to work.", command: `atdd-flow task open ${projectName} ${id}` };
|
package/src/threads.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { notify } from "./adapters";
|
|
4
|
-
import { seatTasks } from "./tasks";
|
|
4
|
+
import { seatTasks, terminal } from "./tasks";
|
|
5
5
|
import {
|
|
6
6
|
atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
|
|
7
7
|
required, runtimeAddress, seat, words,
|
|
@@ -83,9 +83,9 @@ async function assertRoute(root: string, from: string, recipients: string[]) {
|
|
|
83
83
|
throw new Error(`${from} may not directly address operator@desk; route through the responsible coordinator and main seat.`);
|
|
84
84
|
}
|
|
85
85
|
if (sender.role === "driver" && targets.some((target) => target.role !== "coordinator")) {
|
|
86
|
-
// A driver may also reach the actual coordinator of its own non-
|
|
86
|
+
// A driver may also reach the actual coordinator of its own non-terminal task in the same project (e.g. main).
|
|
87
87
|
const owners = new Set((await seatTasks(root, sender.project, from))
|
|
88
|
-
.filter(({ task }) => task.assignee === from && task
|
|
88
|
+
.filter(({ task }) => task.assignee === from && !terminal(task))
|
|
89
89
|
.map(({ task }) => task.coordinator));
|
|
90
90
|
const permitted = (target: { address: string; role: string; project: string }) =>
|
|
91
91
|
target.role === "coordinator" || (target.project === sender.project && owners.has(target.address));
|