@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 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 worktree's local node_modules/.bin so it does not depend on a
250
- developer's global executable installation.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.39",
3
+ "version": "0.3.41",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
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));
@@ -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.status !== "done")
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)?.status !== "done"),
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.status === "done") return undefined;
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(done, tasks.length)} ${counts}`);
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.blocker) next = "Resolve the recorded blocker before changing state.";
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}.\``;
@@ -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.status === "done" || !entry.task.blocker) return;
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?: string }>(paths(root).taskFile(record.project, id))).status === "done"; }
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.status !== "done");
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
- /** Exact named-coordinator or main head from which an assigned driver is governed. */
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.status !== "done").map((entry) => entry.id);
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 && entry.task.status !== "done")
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(["atdd-bun", "worktree", "finish", "--delete-branch"], owner.worktree);
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
- /** Retires a driver whose assigned tasks are all already done. */
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
- if (owner.role !== "driver") throw new Error(`Only driver seats can be retired; ${resolved} is ${owner.role}.`);
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)).filter((entry) => entry.task.assignee === resolved);
217
- if (!owned.length) throw new Error(`Cannot retire ${resolved}; it has no assigned tasks.`);
218
- const unfinished = owned.filter((entry) => entry.task.status !== "done").map((entry) => entry.id);
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
- if (actor !== `main@${owner.project}` && owned.some((entry) => entry.task.coordinator !== actor)) {
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
- if (assignee) await seat(root, assignee);
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, taskId(id), task);
251
- console.log(id);
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 seat(root, assignee);
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.status !== "done") {
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-done task in the same project (e.g. main).
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.status !== "done")
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));