@afokapu/atdd-flow 0.3.56 → 0.3.58
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/extensions/pi/index.ts +14 -16
- package/package.json +1 -1
- package/src/core.ts +50 -4
- package/src/next-action-notices.ts +102 -0
- package/src/tasks.ts +57 -36
package/extensions/pi/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parse, stringify } from "yaml";
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
7
7
|
import { paths, readYaml, type Seat } from "../../src/core";
|
|
8
|
-
import {
|
|
8
|
+
import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
9
9
|
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
10
10
|
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
11
11
|
import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
|
|
@@ -341,18 +341,6 @@ const unsetOrEqual = (recorded: string | undefined, live: string | undefined) =>
|
|
|
341
341
|
const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSessionPath?: string) =>
|
|
342
342
|
unsetOrEqual(runtime?.pi_session, piSession) && unsetOrEqual(runtime?.pi_session_path, piSessionPath);
|
|
343
343
|
|
|
344
|
-
/** Injects the current task-derived action; failure to derive never blocks mail or startup. */
|
|
345
|
-
async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
|
|
346
|
-
try {
|
|
347
|
-
const directive = await nextDirective(root, seat);
|
|
348
|
-
if (!directive) return;
|
|
349
|
-
pi.sendMessage({
|
|
350
|
-
customType: "atdd-flow-next-action", content: directiveNotice(seat, directive), display: true,
|
|
351
|
-
details: { project: directive.project, task: directive.task, lifecycle: directive.lifecycle, role: directive.role, authority: "task" },
|
|
352
|
-
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
353
|
-
} catch { /* The durable task remains authoritative and is re-derived at the next startup or wake. */ }
|
|
354
|
-
}
|
|
355
|
-
|
|
356
344
|
/** The recovery bundle is advisory; any failure falls back to the plain prompt. */
|
|
357
345
|
const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
|
|
358
346
|
|
|
@@ -365,6 +353,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
365
353
|
let handoverPrompted = false;
|
|
366
354
|
// Host-attach only: mail older than this activation is consumed without a prompt, so a long backlog cannot trigger one turn per message.
|
|
367
355
|
let hostActivatedAt: number | undefined;
|
|
356
|
+
const notices: NoticeState = createNoticeState();
|
|
357
|
+
const host = pi as unknown as Parameters<typeof sendTaskDirective>[0];
|
|
358
|
+
// Held follow-ups are re-derived when Pi delivers them, so a notice queued before a state change cannot reach the model stale.
|
|
359
|
+
pi.on("message_end", async (event) => {
|
|
360
|
+
const message = await refreshDelivered(root, seat, notices, event.message as Parameters<typeof refreshDelivered>[3]);
|
|
361
|
+
return message ? { message: message as typeof event.message } : undefined;
|
|
362
|
+
});
|
|
363
|
+
// Nothing queued survives a settled run, so a pending flag left by a cleared queue cannot suppress future notices.
|
|
364
|
+
pi.on("agent_settled", () => { notices.pending = false; });
|
|
368
365
|
pi.on("turn_end", async (_event, ctx) => {
|
|
369
366
|
if (handoverPrompted) return;
|
|
370
367
|
const percent = ctx.getContextUsage()?.percent;
|
|
@@ -399,9 +396,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
399
396
|
runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
400
397
|
if (!sendIfOwned) return false;
|
|
401
398
|
if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
|
|
399
|
+
const notify = typeof mail.id === "string" && typeof thread.id === "string" && await shouldNotifyMail(root, seat, notices, thread.id, mail.id);
|
|
402
400
|
return sendIfOwned(async () => {
|
|
403
|
-
|
|
404
|
-
await sendTaskDirective(
|
|
401
|
+
if (!notify) return;
|
|
402
|
+
await sendTaskDirective(host, root, seat, notices);
|
|
405
403
|
pi.sendMessage({
|
|
406
404
|
customType: "atdd-flow-mail",
|
|
407
405
|
content: mailNotice(typeof thread.id === "string" ? thread.id : "unknown", { ...mail, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
|
|
@@ -442,7 +440,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
440
|
display: true, details: { seat, handover: successor.handover.id, authority: "handover-replacement" },
|
|
443
441
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
444
442
|
}
|
|
445
|
-
await sendTaskDirective(
|
|
443
|
+
await sendTaskDirective(host, root, seat, notices);
|
|
446
444
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
447
445
|
},
|
|
448
446
|
}).catch(() => undefined);
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import {
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { Document, Scalar, parse as parseYaml, stringify as stringifyYaml, visit } from "yaml";
|
|
6
7
|
|
|
7
8
|
export type Role = { address: string; branch: string; agent?: string; purpose?: string; worktree?: string; base?: string };
|
|
8
9
|
export type Group = { role?: string; members?: string[] };
|
|
@@ -133,9 +134,48 @@ export const required = <T>(value: T | undefined, label: string) => {
|
|
|
133
134
|
if (value === undefined || value === "") throw new Error(`Missing ${label}.`);
|
|
134
135
|
return value;
|
|
135
136
|
};
|
|
137
|
+
const printOptions = { collectionStyle: "flow", flowCollectionPadding: false } as const;
|
|
138
|
+
|
|
139
|
+
// What a faithful YAML round trip must return: JSON semantics, but never silently lossy.
|
|
140
|
+
function expectedRoundTrip(value: unknown) {
|
|
141
|
+
return JSON.parse(JSON.stringify(value, (_key, entry) => {
|
|
142
|
+
if (typeof entry === "function" || typeof entry === "symbol" || typeof entry === "bigint") throw new Error("Value is not representable in YAML.");
|
|
143
|
+
if (typeof entry === "number" && !Number.isFinite(entry)) throw new Error("Value is not representable in YAML.");
|
|
144
|
+
return entry;
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Tier 2 keeps multi-line text readable as block literals; tier 3 quotes every string.
|
|
149
|
+
const printTiers: Array<(expected: unknown) => string> = [
|
|
150
|
+
(expected) => stringifyYaml(expected, printOptions),
|
|
151
|
+
(expected) => {
|
|
152
|
+
const document = new Document(expected, { ...printOptions, lineWidth: 0 });
|
|
153
|
+
visit(document, { Scalar: (_key, node) => { if (typeof node.value === "string" && node.value.includes("\n")) node.type = Scalar.BLOCK_LITERAL; } });
|
|
154
|
+
return document.toString();
|
|
155
|
+
},
|
|
156
|
+
(expected) => stringifyYaml(expected, { ...printOptions, lineWidth: 0, defaultStringType: "QUOTE_DOUBLE", defaultKeyType: "PLAIN" }),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
// A parse error and a value mismatch are equally unsafe to publish, so both count as "does not round-trip".
|
|
160
|
+
// Strict deep equality also catches silent coercions such as the string "null" becoming null.
|
|
161
|
+
function roundTrips(text: string, expected: unknown) {
|
|
162
|
+
try { return isDeepStrictEqual(parseYaml(text), expected); }
|
|
163
|
+
catch { return false; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Serializes and proves the text parses back to the same value; the first tier that does wins, otherwise fail closed. */
|
|
167
|
+
function printVerified(value: unknown) {
|
|
168
|
+
const expected = expectedRoundTrip(value);
|
|
169
|
+
for (const tier of printTiers) {
|
|
170
|
+
const text = tier(expected);
|
|
171
|
+
if (roundTrips(text, expected)) return text;
|
|
172
|
+
}
|
|
173
|
+
throw new Error("Refusing to write YAML that does not parse back to the original value.");
|
|
174
|
+
}
|
|
175
|
+
|
|
136
176
|
export const yaml = {
|
|
137
177
|
parse: <T>(text: string) => parseYaml(text) as T,
|
|
138
|
-
print: (value: unknown) =>
|
|
178
|
+
print: (value: unknown) => printVerified(value),
|
|
139
179
|
};
|
|
140
180
|
|
|
141
181
|
export function addressParts(address: string) {
|
|
@@ -176,7 +216,13 @@ export const paths = (root: string) => ({
|
|
|
176
216
|
export async function atomicYaml(file: string, value: unknown) {
|
|
177
217
|
await mkdir(dirname(file), { recursive: true });
|
|
178
218
|
const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
|
|
179
|
-
|
|
219
|
+
const text = yaml.print(value);
|
|
220
|
+
await writeFile(temp, text, "utf8");
|
|
221
|
+
// yaml.print already proved this text round-trips; compare the bytes on disk to it before the rename publishes them.
|
|
222
|
+
if ((await readFile(temp, "utf8")) !== text) {
|
|
223
|
+
await rm(temp, { force: true });
|
|
224
|
+
throw new Error(`Refusing to publish ${file}: written YAML does not round-trip.`);
|
|
225
|
+
}
|
|
180
226
|
await rename(temp, file);
|
|
181
227
|
}
|
|
182
228
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// URN: component:flow:e007:next-action-notices:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:next-action-notice-correctness
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Keep Pi next-action and mail notices current and non-repeating.
|
|
6
|
+
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { parse } from "yaml";
|
|
10
|
+
import { type TaskDirective, directiveNotice, nextDirective } from "./tasks";
|
|
11
|
+
|
|
12
|
+
/** Per-session notice memory. Nothing here is durable; task and thread records stay authoritative. */
|
|
13
|
+
export type NoticeState = {
|
|
14
|
+
/** A next-action follow-up has been queued and not yet delivered. */
|
|
15
|
+
pending: boolean;
|
|
16
|
+
lastDelivered?: string;
|
|
17
|
+
/** Blocker notices already delivered this session, keyed by task and blocker text. */
|
|
18
|
+
blockers: Set<string>;
|
|
19
|
+
/** Mail ids already notified this session. */
|
|
20
|
+
mail: Set<string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type NoticeHost = {
|
|
24
|
+
sendMessage: (message: { customType: string; content: string; display: boolean; details: Record<string, unknown> }, options: { triggerTurn: boolean; deliverAs: "followUp" }) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A delivered message as Pi hands it to `message_end`; only the fields used here. */
|
|
28
|
+
export type NoticeMessage = { role?: string; customType?: string; content?: unknown; details?: Record<string, unknown> };
|
|
29
|
+
|
|
30
|
+
export const createNoticeState = (): NoticeState => ({ pending: false, blockers: new Set(), mail: new Set() });
|
|
31
|
+
|
|
32
|
+
const directiveKey = (directive: TaskDirective) =>
|
|
33
|
+
[directive.project, directive.task, directive.lifecycle, directive.role, directive.action, directive.command ?? ""].join("|");
|
|
34
|
+
|
|
35
|
+
const directiveDetails = (directive: TaskDirective) =>
|
|
36
|
+
({ project: directive.project, task: directive.task, lifecycle: directive.lifecycle, role: directive.role, authority: "task" });
|
|
37
|
+
|
|
38
|
+
/** Queues the current task-derived action unless one is pending or the last delivered one is unchanged. */
|
|
39
|
+
export async function sendTaskDirective(host: NoticeHost, root: string, seat: string, state: NoticeState) {
|
|
40
|
+
if (state.pending) return;
|
|
41
|
+
try {
|
|
42
|
+
const directive = await nextDirective(root, seat);
|
|
43
|
+
if (!directive || state.pending) return;
|
|
44
|
+
const key = directiveKey(directive);
|
|
45
|
+
if (key === state.lastDelivered || state.blockers.has(key)) return;
|
|
46
|
+
state.pending = true;
|
|
47
|
+
host.sendMessage({ customType: "atdd-flow-next-action", content: directiveNotice(seat, directive), display: true, details: directiveDetails(directive) }, { triggerTurn: true, deliverAs: "followUp" });
|
|
48
|
+
} catch {
|
|
49
|
+
state.pending = false; // The durable task remains authoritative and is re-derived at the next startup or wake.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Re-derives a held next-action follow-up when Pi delivers it; a queued message cannot be dropped, so it is replaced by the shortest truthful text. */
|
|
54
|
+
async function currentDirective(root: string, seat: string, state: NoticeState, message: NoticeMessage): Promise<NoticeMessage> {
|
|
55
|
+
state.pending = false;
|
|
56
|
+
const directive = await nextDirective(root, seat).catch(() => undefined);
|
|
57
|
+
if (!directive) {
|
|
58
|
+
state.lastDelivered = undefined;
|
|
59
|
+
return { ...message, content: `SYSTEM: next-action notice for ${seat} withdrawn: task state has no actionable step. Task state outranks mail.`, details: { authority: "task", lifecycle: "NONE" } };
|
|
60
|
+
}
|
|
61
|
+
const key = directiveKey(directive);
|
|
62
|
+
if (key === state.lastDelivered) {
|
|
63
|
+
return { ...message, content: `SYSTEM: next action for ${seat} unchanged on ${directive.project}/${directive.task} [${directive.lifecycle}]; see the previous notice.`, details: directiveDetails(directive) };
|
|
64
|
+
}
|
|
65
|
+
state.lastDelivered = key;
|
|
66
|
+
if (directive.lifecycle === "BLOCKED") state.blockers.add(key);
|
|
67
|
+
return { ...message, content: directiveNotice(seat, directive), details: directiveDetails(directive) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isMailFile = (file: string, mailId: string) => file.startsWith("M-") && file.endsWith(".yaml") && file !== `${mailId}.yaml`;
|
|
71
|
+
|
|
72
|
+
const cites = (record: Record<string, unknown> | undefined, seat: string, mailId: string) =>
|
|
73
|
+
record?.from === seat && (record.in_reply_to === mailId || String(record.body ?? "").includes(mailId));
|
|
74
|
+
|
|
75
|
+
const readRecord = (path: string) => readFile(path, "utf8").then((raw) => parse(raw) as Record<string, unknown>).catch(() => undefined);
|
|
76
|
+
|
|
77
|
+
/** True when this seat already replied to, or cited, the mail in the same thread. */
|
|
78
|
+
export async function answeredBy(root: string, seat: string, threadId: string, mailId: string) {
|
|
79
|
+
const folder = join(root, "threads", threadId);
|
|
80
|
+
for (const file of (await readdir(folder).catch(() => [] as string[])).filter((entry) => isMailFile(entry, mailId))) {
|
|
81
|
+
if (cites(await readRecord(join(folder, file)), seat, mailId)) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** At most one notice per mail id per session, and none for mail this seat already answered. */
|
|
87
|
+
export async function shouldNotifyMail(root: string, seat: string, state: NoticeState, threadId: string, mailId: string) {
|
|
88
|
+
if (state.mail.has(mailId)) return false;
|
|
89
|
+
state.mail.add(mailId);
|
|
90
|
+
return !await answeredBy(root, seat, threadId, mailId).catch(() => false);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `message_end` hook: keeps held notices truthful at the moment they reach the model. */
|
|
94
|
+
export async function refreshDelivered(root: string, seat: string, state: NoticeState, message: NoticeMessage): Promise<NoticeMessage | undefined> {
|
|
95
|
+
if (message.role !== "custom") return undefined;
|
|
96
|
+
if (message.customType === "atdd-flow-next-action") return currentDirective(root, seat, state, message);
|
|
97
|
+
if (message.customType !== "atdd-flow-mail") return undefined;
|
|
98
|
+
const { thread, message: mailId } = message.details ?? {};
|
|
99
|
+
if (typeof thread !== "string" || typeof mailId !== "string") return undefined;
|
|
100
|
+
if (!await answeredBy(root, seat, thread, mailId).catch(() => false)) return undefined;
|
|
101
|
+
return { ...message, content: `SYSTEM: Flow mail ${mailId} on thread ${thread} was already answered by ${seat}; no action.` };
|
|
102
|
+
}
|
package/src/tasks.ts
CHANGED
|
@@ -9,7 +9,8 @@ export type TaskStatus = "todo" | "in_progress" | "review" | "done" | "supersede
|
|
|
9
9
|
export type TaskPhase = "plan" | "red" | "green" | "refactor";
|
|
10
10
|
export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assignee";
|
|
11
11
|
/** A mutable projection of an immutable evidence message/reference, not a second task lifecycle. */
|
|
12
|
-
|
|
12
|
+
/** `submitted_phase` is absent on legacy records; it lets a same-phase accept be told apart from a never-submitted phase. */
|
|
13
|
+
export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string; submitted_phase?: TaskPhase };
|
|
13
14
|
export type DoneWhen = { text: string; proof?: string };
|
|
14
15
|
export type GovernedBase = { coordinator: string; branch: string; commit: string };
|
|
15
16
|
/** An append-only accountability decision; prior coordinator evidence remains untouched. */
|
|
@@ -539,7 +540,7 @@ export async function submitHandoff(root: string, projectName: string, id: strin
|
|
|
539
540
|
if (task.handoff?.state === "awaiting_coordinator") throw new Error(`Task ${id} is already awaiting coordinator handoff response.`);
|
|
540
541
|
const evidence = required(words(args, "--evidence"), "--evidence");
|
|
541
542
|
task.phase = phase(words(args, "--phase"));
|
|
542
|
-
task.handoff = { state: "awaiting_coordinator", evidence, updated_at: now() };
|
|
543
|
+
task.handoff = { state: "awaiting_coordinator", evidence, updated_at: now(), submitted_phase: task.phase };
|
|
543
544
|
await writeTask(root, projectName, taskId(id), task);
|
|
544
545
|
console.log(`${id} handoff awaiting_coordinator`);
|
|
545
546
|
}
|
|
@@ -662,7 +663,7 @@ export async function open(root: string, projectName: string, id: string) {
|
|
|
662
663
|
export type TaskDirective = {
|
|
663
664
|
project: string;
|
|
664
665
|
task: string;
|
|
665
|
-
lifecycle: "TODO" | "PLAN" | "RED" | "GREEN" | "REFACTOR" | "REVIEW" | "
|
|
666
|
+
lifecycle: "TODO" | "PLAN" | "RED" | "GREEN" | "REFACTOR" | "REVIEW" | "BLOCKED";
|
|
666
667
|
role: "assignee" | "coordinator";
|
|
667
668
|
action: string;
|
|
668
669
|
command?: string;
|
|
@@ -675,43 +676,65 @@ const phaseActions: Record<TaskPhase, string> = {
|
|
|
675
676
|
refactor: "Refactor only after GREEN acceptance, then submit the REFACTOR handoff",
|
|
676
677
|
};
|
|
677
678
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (task.status === "todo") {
|
|
688
|
-
const waiting = await ready(root, projectName, task);
|
|
689
|
-
return waiting.length
|
|
690
|
-
? { ...as, lifecycle: "TODO", action: `Wait; dependencies are unfinished: ${waiting.join(", ")}.` }
|
|
691
|
-
: { ...as, lifecycle: "TODO", action: "Start assigned ready task.", command: `atdd-flow task start ${projectName} ${id} --by ${address}` };
|
|
692
|
-
}
|
|
693
|
-
const current = task.phase ?? "plan";
|
|
694
|
-
const lifecycle = current.toUpperCase() as TaskDirective["lifecycle"];
|
|
695
|
-
if (task.handoff?.state === "awaiting_coordinator") return { ...as, lifecycle, action: `Await coordinator response to the ${lifecycle} handoff; do not continue to the next phase.` };
|
|
679
|
+
const phaseOrder: TaskPhase[] = ["plan", "red", "green", "refactor"];
|
|
680
|
+
|
|
681
|
+
const phaseCommand = (projectName: string, id: string, address: string, current: TaskPhase) =>
|
|
682
|
+
`atdd-flow task handoff ${projectName} ${id} --by ${address} --phase ${current} --evidence <message-or-reference>`;
|
|
683
|
+
|
|
684
|
+
/** An assignee whose submitted phase was accepted without naming a later phase is directed forward, never to resubmit. */
|
|
685
|
+
function acceptedDirective(base: Pick<TaskDirective, "project" | "task" | "role">, id: string, address: string, current: TaskPhase): TaskDirective {
|
|
686
|
+
const next = phaseOrder[phaseOrder.indexOf(current) + 1];
|
|
687
|
+
if (!next) {
|
|
696
688
|
return {
|
|
697
|
-
...
|
|
698
|
-
command: `atdd-flow task
|
|
689
|
+
...base, lifecycle: "REVIEW", action: "REFACTOR accepted; record a proof for every done_when item, then submit the task for review.",
|
|
690
|
+
command: `atdd-flow task review ${base.project} ${id} --by ${address}`,
|
|
699
691
|
};
|
|
700
692
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
693
|
+
return {
|
|
694
|
+
...base, lifecycle: next.toUpperCase() as TaskDirective["lifecycle"], action: `${current.toUpperCase()} accepted; ${phaseActions[next]}.`,
|
|
695
|
+
command: phaseCommand(base.project, id, address, next),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function assigneeDirective(root: string, projectName: string, id: string, task: Task, address: string): Promise<TaskDirective> {
|
|
700
|
+
const as = { project: projectName, task: id, role: "assignee" as const };
|
|
701
|
+
if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Await coordinator review; do not continue implementation." };
|
|
702
|
+
if (task.status === "todo") {
|
|
703
|
+
const waiting = await ready(root, projectName, task);
|
|
704
|
+
return waiting.length
|
|
705
|
+
? { ...as, lifecycle: "TODO", action: `Wait; dependencies are unfinished: ${waiting.join(", ")}.` }
|
|
706
|
+
: { ...as, lifecycle: "TODO", action: "Start assigned ready task.", command: `atdd-flow task start ${projectName} ${id} --by ${address}` };
|
|
707
|
+
}
|
|
708
|
+
const current = task.phase ?? "plan";
|
|
709
|
+
const lifecycle = current.toUpperCase() as TaskDirective["lifecycle"];
|
|
710
|
+
if (task.handoff?.state === "awaiting_coordinator") return { ...as, lifecycle, action: `Await coordinator response to the ${lifecycle} handoff; do not continue to the next phase.` };
|
|
711
|
+
if (task.handoff?.state === "awaiting_assignee" && task.handoff.submitted_phase === current) return acceptedDirective(as, id, address, current);
|
|
712
|
+
return { ...as, lifecycle, action: `${phaseActions[current]}.`, command: phaseCommand(projectName, id, address, current) };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function coordinatorDirective(projectName: string, id: string, task: Task, address: string): TaskDirective | undefined {
|
|
716
|
+
const as = { project: projectName, task: id, role: "coordinator" as const };
|
|
717
|
+
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}` };
|
|
718
|
+
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}` };
|
|
719
|
+
if (task.status === "in_progress" && task.handoff?.state === "awaiting_coordinator") {
|
|
720
|
+
const current = task.phase ?? "plan";
|
|
721
|
+
return { ...as, lifecycle: current.toUpperCase() as TaskDirective["lifecycle"], action: `Accept or return the ${current.toUpperCase()} handoff.`, command: `atdd-flow task respond ${projectName} ${id} --by ${address} --outcome <accept|return> --phase <plan|red|green|refactor>` };
|
|
709
722
|
}
|
|
710
723
|
return undefined;
|
|
711
724
|
}
|
|
712
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Pure projection of an existing task record; it creates no durable state. Terminal and deferred tasks
|
|
728
|
+
* yield nothing for any role, a blocked assignee is told nothing, and only the coordinator hears of a blocker.
|
|
729
|
+
*/
|
|
730
|
+
async function directiveFor(root: string, projectName: string, id: string, task: Task, address: string): Promise<TaskDirective | undefined> {
|
|
731
|
+
if (terminal(task) || task.deferred) return undefined;
|
|
732
|
+
if (task.assignee === address && !task.blocker) return assigneeDirective(root, projectName, id, task, address);
|
|
733
|
+
return task.coordinator === address ? coordinatorDirective(projectName, id, task, address) : undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
713
736
|
const directiveRank = (directive: TaskDirective, task: Task) =>
|
|
714
|
-
directive.lifecycle === "
|
|
737
|
+
directive.lifecycle === "BLOCKED" ? 3 : task.status === "todo" ? 2 : task.status === "review" ? 1 : 0;
|
|
715
738
|
|
|
716
739
|
/**
|
|
717
740
|
* Derives the one next required action for an address from authoritative task
|
|
@@ -727,10 +750,8 @@ export async function nextDirective(root: string, address: string): Promise<Task
|
|
|
727
750
|
if (directive) candidates.push({ directive, task: entry.task });
|
|
728
751
|
}
|
|
729
752
|
const best = (role: "assignee" | "coordinator") => {
|
|
730
|
-
|
|
731
|
-
.sort((left, right) => directiveRank(left.directive, left.task) - directiveRank(right.directive, right.task));
|
|
732
|
-
// Completed housekeeping names the most recent completed task, not the oldest.
|
|
733
|
-
return pool[0] && directiveRank(pool[0].directive, pool[0].task) === 4 ? pool.at(-1) : pool[0];
|
|
753
|
+
return candidates.filter((entry) => entry.directive.role === role)
|
|
754
|
+
.sort((left, right) => directiveRank(left.directive, left.task) - directiveRank(right.directive, right.task))[0];
|
|
734
755
|
};
|
|
735
756
|
return (best("assignee") ?? best("coordinator"))?.directive;
|
|
736
757
|
}
|