@afokapu/atdd-flow 0.3.55 → 0.3.57

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.55",
3
+ "version": "0.3.57",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/handover.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // URN: component:flow:e005:handover:backend:integration
2
2
  // Tested-By:
3
3
  // - test:atdd-flow:repeat-handover-replacement-supersession
4
+ // - test:flow:dos-supersession-launch-eligibility
4
5
  // Runtime: bun
5
6
  // Purpose: Validate, preserve, publish, and reconcile governed context handover evidence.
6
7
 
@@ -232,7 +233,9 @@ export async function replacementForMainLaunch(root: string, record: Seat, addre
232
233
  const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
233
234
  (task) => failedAdoptionTaskEligible(root, record, address, task));
234
235
  const canonicalLaunch = replacementMatchesRuntime(record.runtime, request)
235
- && await mainLaunchEligible(root, record, address, task);
236
+ && await (request.supersession
237
+ ? failedAdoptionTaskEligible(root, record, address, task)
238
+ : mainLaunchEligible(root, record, address, task));
236
239
  const failedAdoptionRecovery = Boolean(failedAdoption && !failedAdoption.runtimeState);
237
240
  if (!canonicalLaunch && !failedAdoptionRecovery) {
238
241
  throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
@@ -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
  }