@afokapu/atdd-flow 0.2.0
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/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/atdd-flow +3 -0
- package/conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml +183 -0
- package/package.json +47 -0
- package/src/adapters.ts +88 -0
- package/src/cli.ts +134 -0
- package/src/core.ts +226 -0
- package/src/judgment.ts +333 -0
- package/src/overview.ts +403 -0
- package/src/reviews.ts +448 -0
- package/src/seats.ts +233 -0
- package/src/tasks.ts +243 -0
- package/src/threads.ts +153 -0
package/src/overview.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
|
|
3
|
+
import { type Task, type TaskStatus } from "./tasks";
|
|
4
|
+
import { type Message, type Thread } from "./threads";
|
|
5
|
+
|
|
6
|
+
type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
|
|
7
|
+
type ListedSeat = { project: string; record: Seat; checkpoint?: Checkpoint };
|
|
8
|
+
type ListedThread = { record: Thread; messages: Message[]; pending: string[] };
|
|
9
|
+
type Attention = { project?: string; id: string; label: string; state: string; detail: string; command: string; priority: number };
|
|
10
|
+
|
|
11
|
+
const terminalWidth = () => Math.max(72, Math.min(process.stdout.columns || 100, 120));
|
|
12
|
+
const useColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb");
|
|
13
|
+
const paint = (text: string, code: number) => useColor ? `\u001B[${code}m${text}\u001B[0m` : text;
|
|
14
|
+
const muted = (text: string) => paint(text, 2);
|
|
15
|
+
const strong = (text: string) => paint(text, 1);
|
|
16
|
+
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 truncate = (value: string, width: number) => value.length <= width ? value : `${value.slice(0, Math.max(1, width - 1))}…`;
|
|
18
|
+
const dot = " · ";
|
|
19
|
+
|
|
20
|
+
async function names(folder: string) {
|
|
21
|
+
try { return (await readdir(folder)).sort(); }
|
|
22
|
+
catch { return []; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function projectNames(root: string) {
|
|
26
|
+
const candidates = await names(paths(root).work);
|
|
27
|
+
return (await Promise.all(candidates.map(async (name) => ({ name, valid: await exists(paths(root).projectFile(name)) }))))
|
|
28
|
+
.filter((entry) => entry.valid)
|
|
29
|
+
.map((entry) => entry.name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function seats(root: string, project: string): Promise<ListedSeat[]> {
|
|
33
|
+
const locals = await names(paths(root).seats(project));
|
|
34
|
+
return (await Promise.all(locals.map(async (local) => {
|
|
35
|
+
const address = `${local}@${project}`;
|
|
36
|
+
const file = paths(root).seatFile(address);
|
|
37
|
+
if (!await exists(file)) return undefined;
|
|
38
|
+
const checkpointFile = paths(root).checkpointFile(address);
|
|
39
|
+
return {
|
|
40
|
+
project,
|
|
41
|
+
record: await readYaml<Seat>(file),
|
|
42
|
+
...(await exists(checkpointFile) ? { checkpoint: await readYaml<Checkpoint>(checkpointFile) } : {}),
|
|
43
|
+
};
|
|
44
|
+
}))).filter((entry): entry is ListedSeat => Boolean(entry));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function tasks(root: string, project: string): Promise<ListedTask[]> {
|
|
48
|
+
const folder = paths(root).tasks(project);
|
|
49
|
+
const entries = await Promise.all((await names(folder)).filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).map(async (file) => ({
|
|
50
|
+
project,
|
|
51
|
+
id: file.slice(0, -5),
|
|
52
|
+
task: await readYaml<Task>(`${folder}/${file}`),
|
|
53
|
+
})));
|
|
54
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry.task]));
|
|
55
|
+
return entries.map((entry) => ({
|
|
56
|
+
...entry,
|
|
57
|
+
waiting: (entry.task.depends_on ?? []).filter((dependency) => byId.get(dependency)?.status !== "done"),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function threads(root: string): Promise<ListedThread[]> {
|
|
62
|
+
const ids = await names(paths(root).threads);
|
|
63
|
+
return (await Promise.all(ids.map(async (id) => {
|
|
64
|
+
const threadFile = paths(root).threadFile(id);
|
|
65
|
+
if (!await exists(threadFile)) return undefined;
|
|
66
|
+
const record = await readYaml<Thread>(threadFile);
|
|
67
|
+
const messages = await Promise.all((await names(paths(root).thread(id)))
|
|
68
|
+
.filter((file) => file.startsWith("M-") && file.endsWith(".yaml"))
|
|
69
|
+
.map((file) => readYaml<Message>(`${paths(root).thread(id)}/${file}`)));
|
|
70
|
+
const pending = messages.flatMap((message) => {
|
|
71
|
+
if (!message.expects_result) return [];
|
|
72
|
+
const recipients = message.to === "all" ? record.participants.filter((address) => address !== message.from) : message.to;
|
|
73
|
+
return recipients
|
|
74
|
+
.filter((address) => !messages.some((reply) => reply.kind === "result" && reply.in_reply_to === message.id && reply.from === address))
|
|
75
|
+
.map((address) => `${message.id}@${address}`);
|
|
76
|
+
});
|
|
77
|
+
return { record, messages, pending };
|
|
78
|
+
}))).filter((entry): entry is ListedThread => Boolean(entry));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rule(title: string, suffix = "") {
|
|
82
|
+
const width = terminalWidth();
|
|
83
|
+
const text = ` ${title}${suffix ? ` ${suffix}` : ""} `;
|
|
84
|
+
console.log(`${muted("─".repeat(2))}${strong(text)}${muted("─".repeat(Math.max(2, width - text.length - 2)))}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function header(title: string, deskName: string, facts: string, application: string) {
|
|
88
|
+
const width = terminalWidth();
|
|
89
|
+
console.log(`${muted("╭─")} ${strong(title)} ${muted("─".repeat(Math.max(2, width - deskName.length - title.length - 9)))} ${strong(deskName)} ${muted("─╮")}`);
|
|
90
|
+
console.log(`${muted("│")} ${facts.padEnd(width - 4)}${muted("│")}`);
|
|
91
|
+
console.log(`${muted("│")} ${muted(`application: ${application}`.padEnd(width - 4))}${muted("│")}`);
|
|
92
|
+
console.log(`${muted("╰")}${muted("─".repeat(width - 2))}${muted("╯")}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function bar(done: number, total: number, width = 16) {
|
|
96
|
+
const filled = total ? Math.round((done / total) * width) : 0;
|
|
97
|
+
return `${paint("█".repeat(filled), 36)}${muted("░".repeat(width - filled))}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stateName(status: TaskStatus) {
|
|
101
|
+
return ({ todo: "READY", in_progress: "ACTIVE", review: "REVIEW", done: "DONE" } as Record<TaskStatus, string>)[status];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function taskPhase(entry: ListedTask) {
|
|
105
|
+
if (entry.task.blocker) return "BLOCKED";
|
|
106
|
+
if (entry.waiting.length) return "WAITING";
|
|
107
|
+
return stateName(entry.task.status);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function attentionForTask(entry: ListedTask, checkpointBySeat: Map<string, Checkpoint | undefined>): Attention | undefined {
|
|
111
|
+
const { task } = entry;
|
|
112
|
+
const inspect = `atdd-flow task open ${entry.project} ${entry.id}`;
|
|
113
|
+
if (task.status === "done") return undefined;
|
|
114
|
+
if (task.blocker) return {
|
|
115
|
+
project: entry.project, id: entry.id, label: task.title, state: "BLOCKED", detail: task.blocker,
|
|
116
|
+
command: inspect, priority: 0,
|
|
117
|
+
};
|
|
118
|
+
if (task.status === "review") return {
|
|
119
|
+
project: entry.project, id: entry.id, label: task.title, state: "REVIEW",
|
|
120
|
+
detail: `${task.done_when.filter((item) => item.proof).length}/${task.done_when.length} proof items supplied; coordinator action required.`,
|
|
121
|
+
command: inspect, priority: 1,
|
|
122
|
+
};
|
|
123
|
+
if (entry.waiting.length) return {
|
|
124
|
+
project: entry.project, id: entry.id, label: task.title, state: "WAITING",
|
|
125
|
+
detail: `Waiting on ${entry.waiting.join(", ")}.`, command: inspect, priority: 2,
|
|
126
|
+
};
|
|
127
|
+
if (!task.assignee) return {
|
|
128
|
+
project: entry.project, id: entry.id, label: task.title, state: "ASSIGN",
|
|
129
|
+
detail: "Ready to start, but no driver owns it.", command: inspect, priority: 3,
|
|
130
|
+
};
|
|
131
|
+
const checkpoint = checkpointBySeat.get(task.assignee);
|
|
132
|
+
if (task.status === "in_progress" && checkpoint?.status === "blocked") return {
|
|
133
|
+
project: entry.project, id: entry.id, label: task.title, state: "RE_SEAT",
|
|
134
|
+
detail: checkpoint.next_action || checkpoint.summary, command: `atdd-flow open ${task.assignee}`, priority: 1,
|
|
135
|
+
};
|
|
136
|
+
if (task.status === "in_progress" && checkpoint?.status === "unverified") return {
|
|
137
|
+
project: entry.project, id: entry.id, label: task.title, state: "VERIFY",
|
|
138
|
+
detail: "The assigned seat has not been reconciled with a current holder.", command: `atdd-flow open ${task.assignee}`, priority: 2,
|
|
139
|
+
};
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function attentionForThread(entry: ListedThread): Attention[] {
|
|
144
|
+
return entry.pending.map((pending) => ({
|
|
145
|
+
id: entry.record.id, label: entry.record.subject, state: "WAITING", detail: `A result is still expected from ${pending.slice(pending.indexOf("@") + 1)}.`,
|
|
146
|
+
command: `atdd-flow thread open ${entry.record.id}`, priority: 4,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function printAttention(items: Attention[]) {
|
|
151
|
+
rule("ATTENTION", String(items.length));
|
|
152
|
+
if (!items.length) return console.log(` ${tone("✓", "DONE")} ${muted("Nothing needs a coordinator decision right now.")}`);
|
|
153
|
+
const displayed = items.slice(0, 6);
|
|
154
|
+
const width = terminalWidth();
|
|
155
|
+
for (const item of displayed) {
|
|
156
|
+
const heading = `${item.project ? `${item.project}${dot}` : ""}${item.id}`;
|
|
157
|
+
const state = tone(item.state.replace("_", "-"), item.state);
|
|
158
|
+
console.log(` ${tone("!", item.state)} ${strong(truncate(heading, 33))} ${muted("·")} ${state}`);
|
|
159
|
+
console.log(` ${truncate(item.label, width - 4)}`);
|
|
160
|
+
console.log(` ${muted(truncate(item.detail, width - 4))}`);
|
|
161
|
+
}
|
|
162
|
+
if (items.length > displayed.length) console.log(` ${muted(`+ ${items.length - displayed.length} more items; run \`atdd-flow status --all\` for the full audit.`)}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function printProjects(projects: string[], allTasks: ListedTask[], allSeats: ListedSeat[]) {
|
|
166
|
+
rule(projects.length === 1 ? "WORKSTREAM" : "WORKSTREAMS");
|
|
167
|
+
const width = terminalWidth();
|
|
168
|
+
for (const project of projects) {
|
|
169
|
+
const tasks = allTasks.filter((entry) => entry.project === project);
|
|
170
|
+
const seats = allSeats.filter((entry) => entry.project === project);
|
|
171
|
+
const done = tasks.filter((entry) => entry.task.status === "done").length;
|
|
172
|
+
const active = seats.filter((entry) => entry.checkpoint?.status === "active").length;
|
|
173
|
+
const blocked = seats.filter((entry) => entry.checkpoint?.status === "blocked").length;
|
|
174
|
+
const nameWidth = Math.min(22, Math.max(14, Math.floor(width / 4)));
|
|
175
|
+
const counts = `${done}/${tasks.length} done${active ? `${dot}${active} active` : ""}${blocked ? `${dot}${blocked} blocked` : ""}`;
|
|
176
|
+
console.log(` ${strong(truncate(project, nameWidth).padEnd(nameWidth))} ${bar(done, tasks.length)} ${counts}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function printInFlight(allTasks: ListedTask[]) {
|
|
181
|
+
const active = allTasks.filter((entry) => ["in_progress", "review"].includes(entry.task.status));
|
|
182
|
+
rule("IN FLIGHT", String(active.length));
|
|
183
|
+
if (!active.length) return console.log(` ${muted("No task is currently in progress or awaiting review.")}`);
|
|
184
|
+
for (const entry of active.slice(0, 5)) {
|
|
185
|
+
const proof = `${entry.task.done_when.filter((item) => item.proof).length}/${entry.task.done_when.length} proof`;
|
|
186
|
+
const suffix = ` · ${proof} · ${entry.task.assignee ?? "unassigned"}`;
|
|
187
|
+
console.log(` ${tone("●", stateName(entry.task.status))} ${strong(truncate(`${entry.project}/${entry.id}`, 42))} ${tone(stateName(entry.task.status), stateName(entry.task.status))}`);
|
|
188
|
+
console.log(` ${truncate(entry.task.title, terminalWidth() - 4 - suffix.length)}${muted(suffix)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readyTasks(allTasks: ListedTask[]) {
|
|
193
|
+
return allTasks.filter((entry) => entry.task.status === "todo" && !entry.task.blocker && !entry.waiting.length && entry.task.assignee);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function printReady(allTasks: ListedTask[]) {
|
|
197
|
+
const ready = readyTasks(allTasks);
|
|
198
|
+
rule("READY QUEUE", String(ready.length));
|
|
199
|
+
if (!ready.length) return console.log(` ${muted("No assigned task is clear to start yet.")}`);
|
|
200
|
+
for (const entry of ready.slice(0, 5)) {
|
|
201
|
+
console.log(` ${tone("→", "ACTIVE")} ${strong(truncate(`${entry.project}/${entry.id}`, 42))} ${tone("READY", "ACTIVE")}`);
|
|
202
|
+
console.log(` ${truncate(entry.task.title, terminalWidth() - 4)} ${muted(`· ${entry.task.assignee}`)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function printNext(items: Attention[], allTasks: ListedTask[]) {
|
|
207
|
+
const ready = readyTasks(allTasks);
|
|
208
|
+
rule("NEXT");
|
|
209
|
+
if (items.length) return void items.slice(0, 3).forEach((item, index) => console.log(` ${strong(`${index + 1}.`)} ${item.command}`));
|
|
210
|
+
if (ready.length) return console.log(` ${strong("1.")} atdd-flow task open ${ready[0].project} ${ready[0].id}`);
|
|
211
|
+
console.log(` ${muted("The Desk is clear. Use `status --all` to inspect the full record.")}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function threadMatchesProject(entry: ListedThread, project: string) {
|
|
215
|
+
return entry.record.task?.startsWith(`${project}/`) || entry.record.participants.some((address) => address.slice(address.lastIndexOf("@") + 1) === project);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function selectedProjects(root: string, requested?: string) {
|
|
219
|
+
const all = await projectNames(root);
|
|
220
|
+
if (!requested) return all;
|
|
221
|
+
if (!all.includes(requested)) throw new Error(`Project does not exist in this Desk: ${requested}`);
|
|
222
|
+
return [requested];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function dashboard(root: string, requestedProject?: string) {
|
|
226
|
+
const [record, projectList, allThreads] = await Promise.all([desk(root), selectedProjects(root, requestedProject), threads(root)]);
|
|
227
|
+
const threadList = requestedProject ? allThreads.filter((entry) => threadMatchesProject(entry, requestedProject)) : allThreads;
|
|
228
|
+
const [allSeats, allTasks] = await Promise.all([
|
|
229
|
+
Promise.all(projectList.map((project) => seats(root, project))).then((entries) => entries.flat()),
|
|
230
|
+
Promise.all(projectList.map((project) => tasks(root, project))).then((entries) => entries.flat()),
|
|
231
|
+
]);
|
|
232
|
+
const checkpointBySeat = new Map(allSeats.map((entry) => [entry.record.address, entry.checkpoint]));
|
|
233
|
+
const attention = [
|
|
234
|
+
...allTasks.flatMap((entry) => {
|
|
235
|
+
const item = attentionForTask(entry, checkpointBySeat);
|
|
236
|
+
return item ? [item] : [];
|
|
237
|
+
}),
|
|
238
|
+
...threadList.flatMap(attentionForThread),
|
|
239
|
+
].sort((left, right) => left.priority - right.priority || left.label.localeCompare(right.label));
|
|
240
|
+
const openThreads = threadList.filter((entry) => entry.record.state === "open").length;
|
|
241
|
+
const facts = `${projectList.length} workstreams${dot}${allSeats.length} seats${dot}${allTasks.length} tasks${dot}${openThreads} live threads`;
|
|
242
|
+
const title = requestedProject ? `DESK / ${requestedProject}` : "DESK";
|
|
243
|
+
header(title, record.desk, facts, record.application);
|
|
244
|
+
console.log("");
|
|
245
|
+
printAttention(attention);
|
|
246
|
+
console.log("");
|
|
247
|
+
printProjects(projectList, allTasks, allSeats);
|
|
248
|
+
console.log("");
|
|
249
|
+
printInFlight(allTasks);
|
|
250
|
+
console.log("");
|
|
251
|
+
printReady(allTasks);
|
|
252
|
+
console.log("");
|
|
253
|
+
printNext(attention, allTasks);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function taskDashboard(root: string, projectName: string, taskId: string) {
|
|
257
|
+
await selectedProjects(root, projectName);
|
|
258
|
+
const entry = (await tasks(root, projectName)).find((item) => item.id === taskId);
|
|
259
|
+
if (!entry) throw new Error(`Task does not exist in ${projectName}: ${taskId}`);
|
|
260
|
+
const record = await desk(root);
|
|
261
|
+
const threadList = (await threads(root)).filter((item) => item.record.task === `${projectName}/${taskId}`);
|
|
262
|
+
const proof = `${entry.task.done_when.filter((item) => item.proof).length}/${entry.task.done_when.length} proof`;
|
|
263
|
+
header(`TASK / ${projectName}/${taskId}`, record.desk, `${taskPhase(entry)}${dot}${proof}`, record.application);
|
|
264
|
+
console.log(`\n ${strong(entry.task.title)}`);
|
|
265
|
+
rule("OWNERSHIP");
|
|
266
|
+
console.log(` coordinator ${entry.task.coordinator}`);
|
|
267
|
+
console.log(` assignee ${entry.task.assignee ?? muted("unassigned")}`);
|
|
268
|
+
rule("DEPENDENCIES", String((entry.task.depends_on ?? []).length));
|
|
269
|
+
if (!entry.task.depends_on?.length) console.log(` ${muted("Independent; it may proceed in parallel.")}`);
|
|
270
|
+
for (const dependency of entry.task.depends_on ?? []) console.log(` ${entry.waiting.includes(dependency) ? tone("○", "WAITING") : tone("✓", "DONE")} ${dependency}${entry.waiting.includes(dependency) ? ` ${tone("WAITING", "WAITING")}` : ""}`);
|
|
271
|
+
rule("DEFINITION OF DONE", proof);
|
|
272
|
+
for (const [index, item] of entry.task.done_when.entries()) {
|
|
273
|
+
const mark = item.proof ? tone("✓", "DONE") : muted("○");
|
|
274
|
+
console.log(` ${mark} ${index + 1}. ${item.text}${item.proof ? `\n ${muted(item.proof)}` : ""}`);
|
|
275
|
+
}
|
|
276
|
+
rule("CONTEXT");
|
|
277
|
+
console.log(` source ${entry.task.source ?? muted("none")}`);
|
|
278
|
+
console.log(` brief ${entry.task.body ? truncate(entry.task.body.replace(/\s+/g, " "), terminalWidth() - 10) : muted("none")}`);
|
|
279
|
+
if (entry.task.blocker) console.log(` ${tone("blocker", "BLOCKED")} ${truncate(entry.task.blocker, terminalWidth() - 12)}`);
|
|
280
|
+
rule("RELATED THREADS", String(threadList.length));
|
|
281
|
+
if (!threadList.length) console.log(` ${muted("No thread links directly to this task.")}`);
|
|
282
|
+
for (const thread of threadList) console.log(` ${thread.record.id} ${thread.record.subject}${thread.pending.length ? ` ${tone(`${thread.pending.length} waiting`, "WAITING")}` : ""}`);
|
|
283
|
+
const { behavioralReviewRequired, readBehavioralReviews } = await import("./reviews");
|
|
284
|
+
const reviewRequired = await behavioralReviewRequired(root, entry.task);
|
|
285
|
+
const reviewHistory = await readBehavioralReviews(root, projectName, taskId);
|
|
286
|
+
const latestReview = reviewHistory?.attempts.at(-1);
|
|
287
|
+
rule("FINAL BEHAVIORAL REVIEW", latestReview?.result?.decision ?? latestReview?.status?.toUpperCase() ?? (reviewRequired ? "REQUIRED" : "OPTIONAL"));
|
|
288
|
+
if (!latestReview) console.log(` ${muted(reviewRequired ? "No final behavioral review has been launched." : "No final behavioral review is required by current Workflow governance.")}`);
|
|
289
|
+
if (latestReview) {
|
|
290
|
+
console.log(` ${latestReview.id} ${latestReview.routing.classification} ${latestReview.reviewer.model} ${latestReview.status}`);
|
|
291
|
+
if (latestReview.result) console.log(` ${latestReview.result.decision} ${truncate(latestReview.result.rationale, terminalWidth() - 12)}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
rule("NEXT");
|
|
295
|
+
let next: string;
|
|
296
|
+
if (entry.task.blocker) next = "Resolve the recorded blocker before changing state.";
|
|
297
|
+
else if (entry.waiting.length) next = "Complete the unmet dependencies first.";
|
|
298
|
+
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>.\``;
|
|
299
|
+
else if (entry.task.status === "review" && latestReview?.status === "pending") next = `Await reviewer ${latestReview.reviewer.address}; inspect with \`atdd-flow behavioral-review open ${projectName} ${taskId}.\``;
|
|
300
|
+
else if (entry.task.status === "review" && latestReview?.result?.decision === "RETURN") next = `Coordinator: return the task with \`atdd-flow task return ${projectName} ${taskId} --by ${entry.task.coordinator}.\``;
|
|
301
|
+
else if (entry.task.status === "review" && latestReview?.result?.decision === "ESCALATE") next = "Coordinator: resolve authoritative intent; keep the task in review or block it explicitly.";
|
|
302
|
+
else if (entry.task.status === "review") next = `Coordinator: complete with \`atdd-flow task done ${projectName} ${taskId} --by ${entry.task.coordinator}.\``;
|
|
303
|
+
else if (entry.task.assignee) next = `Driver: start with \`atdd-flow task start ${projectName} ${taskId} --by ${entry.task.assignee}.\``;
|
|
304
|
+
else next = "Coordinator: assign a driver before this task can start.";
|
|
305
|
+
console.log(` ${next}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function seatDashboard(root: string, address: string) {
|
|
309
|
+
const record = await desk(root);
|
|
310
|
+
const resolved = await canonicalAddress(root, address);
|
|
311
|
+
const projectList = await projectNames(root);
|
|
312
|
+
const allSeats = (await Promise.all(projectList.map((project) => seats(root, project)))).flat();
|
|
313
|
+
const entry = allSeats.find((item) => item.record.address === resolved);
|
|
314
|
+
if (!entry) throw new Error(`Seat does not exist in this Desk: ${address}`);
|
|
315
|
+
const owned = (await tasks(root, entry.project)).filter((item) => item.task.assignee === resolved || item.task.coordinator === resolved);
|
|
316
|
+
const threadList = (await threads(root)).filter((item) => item.record.participants.includes(resolved));
|
|
317
|
+
const checkpoint = entry.checkpoint;
|
|
318
|
+
header(`SEAT / ${resolved}`, record.desk, `${entry.record.role}${dot}${entry.record.runtime?.model ?? entry.record.agent ?? "dynamic"}${dot}${checkpoint?.status ?? "unverified"}`, record.application);
|
|
319
|
+
console.log(`\n ${entry.record.purpose ?? muted("No responsibility statement recorded.")}`);
|
|
320
|
+
rule("CHECKPOINT", checkpoint?.status ?? "UNVERIFIED");
|
|
321
|
+
if (!checkpoint) console.log(` ${muted("No checkpoint has been written for this seat.")}`);
|
|
322
|
+
if (checkpoint) {
|
|
323
|
+
console.log(` ${truncate(checkpoint.summary, terminalWidth() - 4)}`);
|
|
324
|
+
console.log(` ${muted(`next: ${truncate(checkpoint.next_action, terminalWidth() - 10)}`)}`);
|
|
325
|
+
}
|
|
326
|
+
rule("WORK");
|
|
327
|
+
console.log(` branch ${entry.record.branch}`);
|
|
328
|
+
console.log(` worktree ${entry.record.worktree}`);
|
|
329
|
+
if (entry.record.retired) console.log(` ${tone("retired", "DONE")} ${entry.record.retired.summary}`);
|
|
330
|
+
rule("RUNTIME");
|
|
331
|
+
const runtime = entry.record.runtime;
|
|
332
|
+
if (!runtime) console.log(` ${muted("No live application is currently attached.")}`);
|
|
333
|
+
for (const [application, nativeAddress] of Object.entries(runtime?.addresses ?? {})) console.log(` ${application}${application === runtime?.application ? " *" : " "} ${nativeAddress}`);
|
|
334
|
+
rule("RESPONSIBILITIES", String(owned.length));
|
|
335
|
+
if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
|
|
336
|
+
for (const task of owned.slice(0, 6)) console.log(` ${tone("●", taskPhase(task))} ${task.id} ${taskPhase(task)}${task.task.assignee === resolved ? " owner" : " coordinator"}`);
|
|
337
|
+
rule("THREADS", String(threadList.length));
|
|
338
|
+
if (!threadList.length) console.log(` ${muted("No thread includes this seat.")}`);
|
|
339
|
+
for (const thread of threadList.slice(0, 6)) console.log(` ${thread.record.id} ${truncate(thread.record.subject, terminalWidth() - 32)}${thread.pending.some((item) => item.endsWith(`@${resolved}`)) ? ` ${tone("REPLY", "WAITING")}` : ""}`);
|
|
340
|
+
rule("NEXT");
|
|
341
|
+
console.log(` ${checkpoint?.next_action ?? "Attach a host or record a checkpoint before assigning new work."}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function threadDashboard(root: string, threadId: string) {
|
|
345
|
+
const record = await desk(root);
|
|
346
|
+
const entry = (await threads(root)).find((item) => item.record.id === threadId);
|
|
347
|
+
if (!entry) throw new Error(`Thread does not exist in this Desk: ${threadId}`);
|
|
348
|
+
header(`THREAD / ${threadId}`, record.desk, `${entry.record.state}${dot}${entry.record.participants.length} participants${dot}${entry.pending.length} results waiting`, record.application);
|
|
349
|
+
console.log(`\n ${strong(entry.record.subject)}`);
|
|
350
|
+
rule("PARTICIPANTS", String(entry.record.participants.length));
|
|
351
|
+
for (const participant of entry.record.participants) console.log(` ${participant}`);
|
|
352
|
+
rule("LINK");
|
|
353
|
+
console.log(` task ${entry.record.task ?? muted("none")}`);
|
|
354
|
+
console.log(` summary ${entry.record.summary ?? muted("none")}`);
|
|
355
|
+
rule("CONVERSATION", String(entry.messages.length));
|
|
356
|
+
if (!entry.messages.length) console.log(` ${muted("No messages have been posted yet.")}`);
|
|
357
|
+
for (const message of entry.messages.slice(-8)) {
|
|
358
|
+
const recipient = message.to === "all" ? "all" : message.to.join(", ");
|
|
359
|
+
console.log(` ${tone(message.kind.toUpperCase(), message.kind === "result" ? "DONE" : message.kind === "receipt" ? "ACTIVE" : "WAITING")} ${message.from} → ${recipient} ${muted(message.created_at.slice(0, 16).replace("T", " "))}`);
|
|
360
|
+
console.log(` ${truncate(message.body.replace(/\s+/g, " "), terminalWidth() - 4)}`);
|
|
361
|
+
}
|
|
362
|
+
rule("WAITING", String(entry.pending.length));
|
|
363
|
+
if (!entry.pending.length) console.log(` ${tone("✓", "DONE")} ${muted("No requested result is outstanding.")}`);
|
|
364
|
+
for (const pending of entry.pending) console.log(` ${tone("○", "WAITING")} ${pending}`);
|
|
365
|
+
rule("NEXT");
|
|
366
|
+
console.log(` ${entry.pending.length ? "The named recipient(s) should post a result against the outstanding message." : "This thread has no outstanding result obligation."}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function printSeats(root: string, projectName: string) {
|
|
370
|
+
for (const entry of await seats(root, projectName)) {
|
|
371
|
+
const { record, checkpoint } = entry;
|
|
372
|
+
console.log(`${record.address} ${record.role} ${record.runtime?.model ?? record.agent ?? "dynamic"} ${checkpoint?.status ?? "unverified"}${record.purpose ? ` ${record.purpose}` : ""}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function printTasks(root: string, projectName: string) {
|
|
377
|
+
for (const { id, task, waiting } of await tasks(root, projectName)) {
|
|
378
|
+
const proof = `${task.done_when.filter((item) => item.proof).length}/${task.done_when.length}`;
|
|
379
|
+
console.log(`${projectName}/${id} ${task.status} proof:${proof} ${task.assignee ?? "unassigned"} ${task.title}${waiting.length ? ` waiting:${waiting.join(",")}` : ""}${task.blocker ? ` blocked:${task.blocker}` : ""}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function audit(root: string, requestedProject?: string) {
|
|
384
|
+
const projects = await selectedProjects(root, requestedProject);
|
|
385
|
+
console.log("SEATS");
|
|
386
|
+
for (const project of projects) await printSeats(root, project);
|
|
387
|
+
console.log("TASKS");
|
|
388
|
+
for (const project of projects) await printTasks(root, project);
|
|
389
|
+
console.log("THREADS");
|
|
390
|
+
for (const entry of (requestedProject ? (await threads(root)).filter((item) => threadMatchesProject(item, requestedProject)) : await threads(root))) console.log(`${entry.record.id} ${entry.record.state} ${entry.record.subject}${entry.pending.length ? ` waiting:${entry.pending.join(",")}` : ""}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function status(root: string, args: string[] = []) {
|
|
394
|
+
const all = args.includes("--all");
|
|
395
|
+
const [scope, ...values] = args.filter((argument) => argument !== "--all");
|
|
396
|
+
if (args.some((argument) => argument.startsWith("-") && argument !== "--all")) throw new Error("Use `atdd-flow status [project|task|seat|thread] ... [--all]`.");
|
|
397
|
+
if (!scope) return all ? audit(root) : dashboard(root);
|
|
398
|
+
if (scope === "project" && values.length === 1) return all ? audit(root, values[0]) : dashboard(root, values[0]);
|
|
399
|
+
if (scope === "task" && values.length === 2) return taskDashboard(root, values[0], values[1]);
|
|
400
|
+
if (scope === "seat" && values.length === 1) return seatDashboard(root, values[0]);
|
|
401
|
+
if (scope === "thread" && values.length === 1) return threadDashboard(root, values[0]);
|
|
402
|
+
throw new Error("Use `atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]`.");
|
|
403
|
+
}
|