@afokapu/atdd-flow 0.3.16 → 0.3.17
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 +5 -1
- package/src/overview.ts +19 -2
- package/src/tasks.ts +65 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -37,6 +37,8 @@ Usage:
|
|
|
37
37
|
atdd-flow task start|review|return <project> <task-id> --by <address>
|
|
38
38
|
atdd-flow task done <project> <task-id> --by <address> [--retire-assignee]
|
|
39
39
|
atdd-flow task prove <project> <task-id> --by <address> --item <number> --proof <reference>
|
|
40
|
+
atdd-flow task handoff <project> <task-id> --by <assignee> --phase <plan|red|green|refactor> --evidence <message-or-reference>
|
|
41
|
+
atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
|
|
40
42
|
atdd-flow task block <project> <task-id> --by <address> --reason <text>
|
|
41
43
|
atdd-flow task unblock <project> <task-id> --by <coordinator-address>
|
|
42
44
|
atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
|
|
@@ -112,11 +114,13 @@ async function main() {
|
|
|
112
114
|
if (subcommand === "done") return tasks.done(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
113
115
|
if (subcommand === "return") return tasks.returnToWork(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
114
116
|
if (subcommand === "prove") return tasks.prove(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
117
|
+
if (subcommand === "handoff") return tasks.submitHandoff(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
118
|
+
if (subcommand === "respond") return tasks.respondToHandoff(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
115
119
|
if (subcommand === "block") return tasks.block(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
116
120
|
if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
117
121
|
if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
|
|
118
122
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
119
|
-
throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|unblock|list|open`.");
|
|
123
|
+
throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
|
|
120
124
|
},
|
|
121
125
|
cleanup: async () => {
|
|
122
126
|
const [subcommand, projectName, taskId] = rest;
|
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 TaskStatus } from "./tasks";
|
|
3
|
+
import { type Task, type TaskHandoff, type TaskStatus } from "./tasks";
|
|
4
4
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
5
5
|
import { type Message, type Thread } from "./threads";
|
|
6
6
|
|
|
@@ -108,6 +108,10 @@ function taskPhase(entry: ListedTask) {
|
|
|
108
108
|
return stateName(entry.task.status);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function handoffStateName(value: TaskHandoff["state"]) {
|
|
112
|
+
return value.replaceAll("_", "-").toUpperCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
function attentionForTask(entry: ListedTask, checkpointBySeat: Map<string, Checkpoint | undefined>): Attention | undefined {
|
|
112
116
|
const { task } = entry;
|
|
113
117
|
const inspect = `atdd-flow task open ${entry.project} ${entry.id}`;
|
|
@@ -261,11 +265,24 @@ async function taskDashboard(root: string, projectName: string, taskId: string)
|
|
|
261
265
|
const record = await desk(root);
|
|
262
266
|
const threadList = (await threads(root)).filter((item) => item.record.task === `${projectName}/${taskId}`);
|
|
263
267
|
const proof = `${entry.task.done_when.filter((item) => item.proof).length}/${entry.task.done_when.length} proof`;
|
|
264
|
-
|
|
268
|
+
const runtime = entry.task.assignee ? await readRuntimeState(root, entry.task.assignee) : undefined;
|
|
269
|
+
const runtimeState = runtime && !isRuntimeStateStale(runtime, 60_000) ? "ONLINE" : "OFFLINE";
|
|
270
|
+
const handoff = entry.task.handoff ? handoffStateName(entry.task.handoff.state) : undefined;
|
|
271
|
+
header(`TASK / ${projectName}/${taskId}`, record.desk, `${taskPhase(entry)}${entry.task.phase ? `${dot}${entry.task.phase.toUpperCase()}` : ""}${handoff ? `${dot}${handoff}` : ""}${dot}${proof}${dot}RUNTIME ${runtimeState}`, record.application);
|
|
265
272
|
console.log(`\n ${strong(entry.task.title)}`);
|
|
266
273
|
rule("OWNERSHIP");
|
|
267
274
|
console.log(` coordinator ${entry.task.coordinator}`);
|
|
268
275
|
console.log(` assignee ${entry.task.assignee ?? muted("unassigned")}`);
|
|
276
|
+
rule("PHASE / HANDOFF");
|
|
277
|
+
if (!entry.task.phase && !entry.task.handoff) console.log(` ${muted("No optional phase or handoff projection has been recorded.")}`);
|
|
278
|
+
if (entry.task.phase) console.log(` phase ${entry.task.phase.toUpperCase()}`);
|
|
279
|
+
if (entry.task.handoff) {
|
|
280
|
+
console.log(` handoff ${handoffStateName(entry.task.handoff.state)}`);
|
|
281
|
+
console.log(` evidence ${entry.task.handoff.evidence}`);
|
|
282
|
+
console.log(` updated ${entry.task.handoff.updated_at}`);
|
|
283
|
+
}
|
|
284
|
+
rule("RUNTIME OBSERVATION", runtimeState);
|
|
285
|
+
console.log(` assignee runtime ${runtimeState}${runtime ? ` · ${runtime.model} · heartbeat ${runtime.heartbeat_at}` : " · no current advisory observation"}`);
|
|
269
286
|
rule("DEPENDENCIES", String((entry.task.depends_on ?? []).length));
|
|
270
287
|
if (!entry.task.depends_on?.length) console.log(` ${muted("Independent; it may proceed in parallel.")}`);
|
|
271
288
|
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")}` : ""}`);
|
package/src/tasks.ts
CHANGED
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
} from "./core";
|
|
5
5
|
|
|
6
6
|
export type TaskStatus = "todo" | "in_progress" | "review" | "done";
|
|
7
|
+
/** An optional, descriptive implementation phase; task status remains the lifecycle authority. */
|
|
8
|
+
export type TaskPhase = "plan" | "red" | "green" | "refactor";
|
|
9
|
+
export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assignee";
|
|
10
|
+
/** A mutable projection of an immutable evidence message/reference, not a second task lifecycle. */
|
|
11
|
+
export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string };
|
|
7
12
|
export type DoneWhen = { text: string; proof?: string };
|
|
8
13
|
export type Task = {
|
|
9
14
|
schema: "atdd-workflow/task/v1";
|
|
@@ -15,6 +20,10 @@ export type Task = {
|
|
|
15
20
|
source?: string;
|
|
16
21
|
depends_on?: string[];
|
|
17
22
|
blocker?: string;
|
|
23
|
+
/** Absent on legacy tasks. */
|
|
24
|
+
phase?: TaskPhase;
|
|
25
|
+
/** Absent on legacy tasks; evidence remains an opaque immutable message/reference. */
|
|
26
|
+
handoff?: TaskHandoff;
|
|
18
27
|
done_when: DoneWhen[];
|
|
19
28
|
};
|
|
20
29
|
|
|
@@ -220,6 +229,62 @@ export async function returnToWork(root: string, projectName: string, id: string
|
|
|
220
229
|
return transition(root, projectName, taskId(id), "in_progress", required(words(args, "--by"), "--by"));
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
const phases: TaskPhase[] = ["plan", "red", "green", "refactor"];
|
|
233
|
+
const handoffOutcomes = ["accept", "return"] as const;
|
|
234
|
+
|
|
235
|
+
type HandoffOutcome = typeof handoffOutcomes[number];
|
|
236
|
+
|
|
237
|
+
function phase(value: string | undefined): TaskPhase {
|
|
238
|
+
const selected = required(value, "--phase");
|
|
239
|
+
if (!phases.includes(selected as TaskPhase)) throw new Error(`--phase must be one of: ${phases.join(", ")}.`);
|
|
240
|
+
return selected as TaskPhase;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function handoffOutcome(value: string | undefined): HandoffOutcome {
|
|
244
|
+
const selected = required(value, "--outcome");
|
|
245
|
+
if (!(handoffOutcomes as readonly string[]).includes(selected)) throw new Error("--outcome must be accept or return.");
|
|
246
|
+
return selected as HandoffOutcome;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The assignee records work as ready for a coordinator decision. The evidence
|
|
251
|
+
* value is deliberately opaque: it links an immutable message/reference but
|
|
252
|
+
* never parses message prose or changes the task lifecycle.
|
|
253
|
+
*/
|
|
254
|
+
export async function submitHandoff(root: string, projectName: string, id: string, args: string[]) {
|
|
255
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
256
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
257
|
+
if (!task.assignee || actor !== task.assignee) throw new Error(`Only ${task.assignee ?? "the assignee"} may submit a handoff for task ${id}.`);
|
|
258
|
+
if (task.status !== "in_progress") throw new Error(`Task ${id} must be in_progress before a handoff is submitted.`);
|
|
259
|
+
if (task.handoff?.state === "awaiting_coordinator") throw new Error(`Task ${id} is already awaiting coordinator handoff response.`);
|
|
260
|
+
const evidence = required(words(args, "--evidence"), "--evidence");
|
|
261
|
+
task.phase = phase(words(args, "--phase"));
|
|
262
|
+
task.handoff = { state: "awaiting_coordinator", evidence, updated_at: now() };
|
|
263
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
264
|
+
console.log(`${id} handoff awaiting_coordinator`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The coordinator accepts a handoff for the assignee's next phase or returns
|
|
269
|
+
* it to active execution. This only updates the optional projection.
|
|
270
|
+
*/
|
|
271
|
+
export async function respondToHandoff(root: string, projectName: string, id: string, args: string[]) {
|
|
272
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
273
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
274
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may respond to task ${id} handoff.`);
|
|
275
|
+
if (task.status !== "in_progress") throw new Error(`Task ${id} must be in_progress before a handoff is answered.`);
|
|
276
|
+
if (task.handoff?.state !== "awaiting_coordinator") throw new Error(`Task ${id} is not awaiting coordinator handoff response.`);
|
|
277
|
+
const outcome = handoffOutcome(words(args, "--outcome"));
|
|
278
|
+
task.phase = phase(words(args, "--phase"));
|
|
279
|
+
task.handoff = {
|
|
280
|
+
...task.handoff,
|
|
281
|
+
state: outcome === "accept" ? "awaiting_assignee" : "executing",
|
|
282
|
+
updated_at: now(),
|
|
283
|
+
};
|
|
284
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
285
|
+
console.log(`${id} handoff ${task.handoff.state}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
223
288
|
export async function prove(root: string, projectName: string, id: string, args: string[]) {
|
|
224
289
|
const task = await readTask(root, projectName, taskId(id));
|
|
225
290
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|