@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.
@@ -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 { directiveNotice, nextDirective } from "../../src/tasks";
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
- // Task state is re-read at every wake, so queued mail cannot carry a stale action.
404
- await sendTaskDirective(pi, root, seat);
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(pi, root, seat);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.56",
3
+ "version": "0.3.58",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
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 { parse as parseYaml, stringify as stringifyYaml } from "yaml";
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) => stringifyYaml(value, { collectionStyle: "flow", flowCollectionPadding: false }),
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
- await writeFile(temp, yaml.print(value), "utf8");
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
- export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string };
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" | "DONE" | "BLOCKED";
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
- /** Pure projection of an existing task record; it creates no durable state. */
679
- async function directiveFor(root: string, projectName: string, id: string, task: Task, address: string): Promise<TaskDirective | undefined> {
680
- const base = { project: projectName, task: id };
681
- if (task.assignee === address) {
682
- const as = { ...base, role: "assignee" as const };
683
- if (task.status === "done") return { ...as, lifecycle: "DONE", action: "Perform DONE housekeeping only; start no new implementation." };
684
- if (terminal(task)) return undefined;
685
- if (task.blocker) return { ...as, lifecycle: "BLOCKED", action: `Remain blocked and await coordinator resolution (${task.blocker}). Continue only independent required work.` };
686
- if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Await coordinator review; do not continue implementation." };
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
- ...as, lifecycle, action: `${phaseActions[current]}.`,
698
- command: `atdd-flow task handoff ${projectName} ${id} --by ${address} --phase ${current} --evidence <message-or-reference>`,
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
- if (task.coordinator === address && !terminal(task)) {
702
- const as = { ...base, role: "coordinator" as const };
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}` };
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}` };
705
- if (task.status === "in_progress" && task.handoff?.state === "awaiting_coordinator") {
706
- const current = task.phase ?? "plan";
707
- 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>` };
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 === "DONE" ? 4 : directive.lifecycle === "BLOCKED" ? 3 : task.status === "todo" ? 2 : task.status === "review" ? 1 : 0;
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
- const pool = candidates.filter((entry) => entry.directive.role === role)
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
  }