@afokapu/atdd-flow 0.3.40 → 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/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 +87 -15
- package/src/threads.ts +3 -3
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
|
}
|
|
@@ -191,7 +218,7 @@ async function retirementBun(root: string, projectName: string) {
|
|
|
191
218
|
|
|
192
219
|
async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false) {
|
|
193
220
|
const unfinished = (await allTasks(root, projectName))
|
|
194
|
-
.filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && entry.task
|
|
221
|
+
.filter((entry) => entry.id !== id && (entry.task.assignee === assignee || entry.task.coordinator === assignee) && !terminal(entry.task))
|
|
195
222
|
.map((entry) => entry.id);
|
|
196
223
|
if (unfinished.length) throw new Error(`Cannot retire ${assignee}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
197
224
|
|
|
@@ -241,8 +268,8 @@ async function assertRetireAuthority(root: string, owner: Seat, actor: string, o
|
|
|
241
268
|
}
|
|
242
269
|
|
|
243
270
|
/**
|
|
244
|
-
* Retires a driver whose assigned tasks are all
|
|
245
|
-
* (never main or the primary checkout) whose coordinated and assigned tasks are all
|
|
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.
|
|
246
273
|
*/
|
|
247
274
|
export async function retireSeat(root: string, address: string, args: string[]) {
|
|
248
275
|
const resolved = await canonicalAddress(root, address);
|
|
@@ -254,7 +281,7 @@ export async function retireSeat(root: string, address: string, args: string[])
|
|
|
254
281
|
const owned = (await allTasks(root, owner.project))
|
|
255
282
|
.filter((entry) => entry.task.assignee === resolved || (coordinator && entry.task.coordinator === resolved));
|
|
256
283
|
if (!owned.length) throw new Error(`Cannot retire ${resolved}; it has no ${coordinator ? "coordinated or " : ""}assigned tasks.`);
|
|
257
|
-
const unfinished = owned.filter((entry) => entry.task
|
|
284
|
+
const unfinished = owned.filter((entry) => !terminal(entry.task)).map((entry) => entry.id);
|
|
258
285
|
if (unfinished.length) throw new Error(`Cannot retire ${resolved}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
259
286
|
await assertRetireAuthority(root, owner, actor, owned);
|
|
260
287
|
await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true);
|
|
@@ -262,13 +289,14 @@ export async function retireSeat(root: string, address: string, args: string[])
|
|
|
262
289
|
}
|
|
263
290
|
|
|
264
291
|
export async function add(root: string, projectName: string, id: string, args: string[]) {
|
|
292
|
+
const normalizedId = taskId(id);
|
|
265
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.`);
|
|
266
295
|
const coordinator = await canonicalAddress(root, required(words(args, "--coordinator"), "--coordinator"));
|
|
267
296
|
const assigneeValue = words(args, "--assignee");
|
|
268
297
|
const assignee = assigneeValue ? await canonicalAddress(root, assigneeValue) : undefined;
|
|
269
298
|
await seat(root, coordinator);
|
|
270
|
-
|
|
271
|
-
const base = await governedBase(root, projectName, coordinator, assignee);
|
|
299
|
+
const base = assignee ? await evidenceBase(root, projectName, coordinator, await eligibleAssignee(root, projectName, assignee)) : undefined;
|
|
272
300
|
const doneWhen = values(args, "--done-when").map((text) => ({ text }));
|
|
273
301
|
if (!doneWhen.length) throw new Error("A task needs at least one --done-when criterion.");
|
|
274
302
|
const dependsOn = words(args, "--depends-on")?.split(",").filter(Boolean).map(taskId);
|
|
@@ -284,8 +312,8 @@ export async function add(root: string, projectName: string, id: string, args: s
|
|
|
284
312
|
...(dependsOn?.length ? { depends_on: dependsOn } : {}),
|
|
285
313
|
done_when: doneWhen,
|
|
286
314
|
};
|
|
287
|
-
await writeTask(root, projectName,
|
|
288
|
-
console.log(
|
|
315
|
+
await writeTask(root, projectName, normalizedId, task);
|
|
316
|
+
console.log(normalizedId);
|
|
289
317
|
}
|
|
290
318
|
|
|
291
319
|
export async function assign(root: string, projectName: string, id: string, args: string[]) {
|
|
@@ -295,8 +323,7 @@ export async function assign(root: string, projectName: string, id: string, args
|
|
|
295
323
|
if (task.status !== "todo") throw new Error(`Task ${id} can only be assigned while todo.`);
|
|
296
324
|
if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
|
|
297
325
|
const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
|
|
298
|
-
await
|
|
299
|
-
const base = await governedBase(root, projectName, task.coordinator, assignee);
|
|
326
|
+
const base = await evidenceBase(root, projectName, task.coordinator, await eligibleAssignee(root, projectName, assignee));
|
|
300
327
|
task.assignee = assignee;
|
|
301
328
|
if (base) task.governed_base = base;
|
|
302
329
|
await writeTask(root, projectName, id, task);
|
|
@@ -391,6 +418,7 @@ export async function transfer(root: string, projectName: string, id: string, ar
|
|
|
391
418
|
|
|
392
419
|
export async function amend(root: string, projectName: string, id: string, args: string[]) {
|
|
393
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.`);
|
|
394
422
|
const title = words(args, "--title");
|
|
395
423
|
const body = words(args, "--body");
|
|
396
424
|
const source = words(args, "--source");
|
|
@@ -413,6 +441,7 @@ export async function amend(root: string, projectName: string, id: string, args:
|
|
|
413
441
|
*/
|
|
414
442
|
export async function importCompleted(root: string, projectName: string, id: string, args: string[]) {
|
|
415
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.`);
|
|
416
445
|
const proofs = values(args, "--proof");
|
|
417
446
|
if (!proofs.length) throw new Error("An imported completion needs at least one --proof reference.");
|
|
418
447
|
|
|
@@ -459,7 +488,7 @@ async function transition(root: string, projectName: string, id: string, next: T
|
|
|
459
488
|
if (retire) await retireAssignee(root, projectName, id, task);
|
|
460
489
|
}
|
|
461
490
|
task.status = next;
|
|
462
|
-
if (next !== "in_progress") delete task.blocker;
|
|
491
|
+
if (next !== "in_progress") { delete task.blocker; delete task.deferred; }
|
|
463
492
|
await writeTask(root, projectName, id, task);
|
|
464
493
|
console.log(`${id} ${next}`);
|
|
465
494
|
}
|
|
@@ -550,6 +579,7 @@ export async function prove(root: string, projectName: string, id: string, args:
|
|
|
550
579
|
|
|
551
580
|
export async function block(root: string, projectName: string, id: string, args: string[]) {
|
|
552
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.`);
|
|
553
583
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
554
584
|
if (actor !== task.assignee && actor !== task.coordinator) throw new Error(`Only the assignee or coordinator may block task ${id}.`);
|
|
555
585
|
if (task.status === "done") throw new Error(`Completed task ${id} cannot be blocked.`);
|
|
@@ -558,6 +588,46 @@ export async function block(root: string, projectName: string, id: string, args:
|
|
|
558
588
|
console.log(`${id} blocked`);
|
|
559
589
|
}
|
|
560
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
|
+
|
|
561
631
|
export async function unblock(root: string, projectName: string, id: string, args: string[]) {
|
|
562
632
|
const task = await readTask(root, projectName, taskId(id));
|
|
563
633
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
@@ -569,6 +639,7 @@ export async function unblock(root: string, projectName: string, id: string, arg
|
|
|
569
639
|
const { assertDependentsMayUnblock } = await import("./ephemeral-resources");
|
|
570
640
|
await assertDependentsMayUnblock(root, { project: projectName, task: id });
|
|
571
641
|
delete task.blocker;
|
|
642
|
+
delete task.deferred;
|
|
572
643
|
await writeTask(root, projectName, id, task);
|
|
573
644
|
console.log(`${id} unblocked`);
|
|
574
645
|
}
|
|
@@ -610,6 +681,7 @@ async function directiveFor(root: string, projectName: string, id: string, task:
|
|
|
610
681
|
if (task.assignee === address) {
|
|
611
682
|
const as = { ...base, role: "assignee" as const };
|
|
612
683
|
if (task.status === "done") return { ...as, lifecycle: "DONE", action: "Perform DONE housekeeping only; start no new implementation." };
|
|
684
|
+
if (terminal(task)) return undefined;
|
|
613
685
|
if (task.blocker) return { ...as, lifecycle: "BLOCKED", action: `Remain blocked and await coordinator resolution (${task.blocker}). Continue only independent required work.` };
|
|
614
686
|
if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Await coordinator review; do not continue implementation." };
|
|
615
687
|
if (task.status === "todo") {
|
|
@@ -626,7 +698,7 @@ async function directiveFor(root: string, projectName: string, id: string, task:
|
|
|
626
698
|
command: `atdd-flow task handoff ${projectName} ${id} --by ${address} --phase ${current} --evidence <message-or-reference>`,
|
|
627
699
|
};
|
|
628
700
|
}
|
|
629
|
-
if (task.coordinator === address && task
|
|
701
|
+
if (task.coordinator === address && !terminal(task)) {
|
|
630
702
|
const as = { ...base, role: "coordinator" as const };
|
|
631
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}` };
|
|
632
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));
|