@afokapu/atdd-flow 0.3.26 → 0.3.27

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,6 +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
9
 
9
10
  type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
10
11
  export type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown; created_at?: unknown };
@@ -316,6 +317,18 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
316
317
  throw new Error("Timed out waiting for verified Flow launch receipt and binding.");
317
318
  }
318
319
 
320
+ /** Injects the current task-derived action; failure to derive never blocks mail or startup. */
321
+ async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
322
+ try {
323
+ const directive = await nextDirective(root, seat);
324
+ if (!directive) return;
325
+ pi.sendMessage({
326
+ customType: "atdd-flow-next-action", content: directiveNotice(seat, directive), display: true,
327
+ details: { project: directive.project, task: directive.task, lifecycle: directive.lifecycle, role: directive.role, authority: "task" },
328
+ }, { triggerTurn: true, deliverAs: "followUp" });
329
+ } catch { /* The durable task remains authoritative and is re-derived at the next startup or wake. */ }
330
+ }
331
+
319
332
  /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
320
333
  export default function (pi: ExtensionAPI) {
321
334
  const root = process.env.ATDD_WORKFLOW_ROOT;
@@ -325,7 +338,9 @@ export default function (pi: ExtensionAPI) {
325
338
  pi.on("session_start", async (_event, ctx) => {
326
339
  runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
327
340
  if (!sendIfOwned) return false;
328
- return sendIfOwned(() => {
341
+ return sendIfOwned(async () => {
342
+ // Task state is re-read at every wake, so queued mail cannot carry a stale action.
343
+ await sendTaskDirective(pi, root, seat);
329
344
  pi.sendMessage({
330
345
  customType: "atdd-flow-mail",
331
346
  content: mailNotice(typeof thread.id === "string" ? thread.id : "unknown", { ...mail, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
@@ -344,6 +359,7 @@ export default function (pi: ExtensionAPI) {
344
359
  activate: async () => {
345
360
  if (!await current.activate()) return;
346
361
  pi.sendMessage({ customType: "atdd-flow-start", content: `SYSTEM: you are ${seat}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Convention: ${lifecycleConventionPath}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
362
+ await sendTaskDirective(pi, root, seat);
347
363
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
348
364
  },
349
365
  }).catch(() => undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.26",
3
+ "version": "0.3.27",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/tasks.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import {
4
- type Checkpoint, atomicYaml, canonicalAddress, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
4
+ type Checkpoint, type Seat, atomicYaml, canonicalAddress, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
5
5
  } from "./core";
6
6
 
7
7
  export type TaskStatus = "todo" | "in_progress" | "review" | "done";
@@ -498,3 +498,83 @@ export async function list(root: string, projectName: string, args: string[]) {
498
498
  export async function open(root: string, projectName: string, id: string) {
499
499
  console.log(Bun.YAML.stringify(await readTask(root, projectName, taskId(id))));
500
500
  }
501
+
502
+ export type TaskDirective = {
503
+ project: string;
504
+ task: string;
505
+ lifecycle: "TODO" | "PLAN" | "RED" | "GREEN" | "REFACTOR" | "REVIEW" | "DONE" | "BLOCKED";
506
+ role: "assignee" | "coordinator";
507
+ action: string;
508
+ command?: string;
509
+ };
510
+
511
+ const phaseActions: Record<TaskPhase, string> = {
512
+ plan: "Prepare and submit PLAN handoff",
513
+ red: "Commit focused test-only RED handoff",
514
+ green: "Implement the accepted GREEN scope",
515
+ refactor: "Refactor only after GREEN acceptance, then submit the REFACTOR handoff",
516
+ };
517
+
518
+ /** Pure projection of an existing task record; it creates no durable state. */
519
+ async function directiveFor(root: string, projectName: string, id: string, task: Task, address: string): Promise<TaskDirective | undefined> {
520
+ const base = { project: projectName, task: id };
521
+ if (task.assignee === address) {
522
+ const as = { ...base, role: "assignee" as const };
523
+ if (task.status === "done") return { ...as, lifecycle: "DONE", action: "Perform DONE housekeeping only; start no new implementation." };
524
+ if (task.blocker) return { ...as, lifecycle: "BLOCKED", action: `Remain blocked and await coordinator resolution (${task.blocker}). Continue only independent required work.` };
525
+ if (task.status === "review") return { ...as, lifecycle: "REVIEW", action: "Await coordinator review; do not continue implementation." };
526
+ if (task.status === "todo") {
527
+ const waiting = await ready(root, projectName, task);
528
+ return waiting.length
529
+ ? { ...as, lifecycle: "TODO", action: `Wait; dependencies are unfinished: ${waiting.join(", ")}.` }
530
+ : { ...as, lifecycle: "TODO", action: "Start assigned ready task.", command: `atdd-flow task start ${projectName} ${id} --by ${address}` };
531
+ }
532
+ const current = task.phase ?? "plan";
533
+ const lifecycle = current.toUpperCase() as TaskDirective["lifecycle"];
534
+ if (task.handoff?.state === "awaiting_coordinator") return { ...as, lifecycle, action: `Await coordinator response to the ${lifecycle} handoff; do not continue to the next phase.` };
535
+ return {
536
+ ...as, lifecycle, action: `${phaseActions[current]}.`,
537
+ command: `atdd-flow task handoff ${projectName} ${id} --by ${address} --phase ${current} --evidence <message-or-reference>`,
538
+ };
539
+ }
540
+ if (task.coordinator === address && task.status !== "done") {
541
+ const as = { ...base, role: "coordinator" as const };
542
+ 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}` };
543
+ 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}` };
544
+ if (task.status === "in_progress" && task.handoff?.state === "awaiting_coordinator") {
545
+ const current = task.phase ?? "plan";
546
+ 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>` };
547
+ }
548
+ }
549
+ return undefined;
550
+ }
551
+
552
+ const directiveRank = (directive: TaskDirective, task: Task) =>
553
+ directive.lifecycle === "DONE" ? 4 : directive.lifecycle === "BLOCKED" ? 3 : task.status === "todo" ? 2 : task.status === "review" ? 1 : 0;
554
+
555
+ /**
556
+ * Derives the one next required action for an address from authoritative task
557
+ * records. Mail is intentionally not an input, and nothing is persisted.
558
+ */
559
+ export async function nextDirective(root: string, address: string): Promise<TaskDirective | undefined> {
560
+ const owner = await readYaml<Seat>(paths(root).seatFile(address));
561
+ if (owner.retired) return undefined;
562
+ const entries = await seatTasks(root, owner.project, address);
563
+ const candidates: { directive: TaskDirective; task: Task }[] = [];
564
+ for (const entry of entries) {
565
+ const directive = await directiveFor(root, owner.project, entry.id, entry.task, address);
566
+ if (directive) candidates.push({ directive, task: entry.task });
567
+ }
568
+ const best = (role: "assignee" | "coordinator") => {
569
+ const pool = candidates.filter((entry) => entry.directive.role === role)
570
+ .sort((left, right) => directiveRank(left.directive, left.task) - directiveRank(right.directive, right.task));
571
+ // Completed housekeeping names the most recent completed task, not the oldest.
572
+ return pool[0] && directiveRank(pool[0].directive, pool[0].task) === 4 ? pool.at(-1) : pool[0];
573
+ };
574
+ return (best("assignee") ?? best("coordinator"))?.directive;
575
+ }
576
+
577
+ /** Compact wake text; task state is named as the authority over any mail. */
578
+ export function directiveNotice(address: string, directive: TaskDirective) {
579
+ return `SYSTEM: authoritative next action for ${address} on ${directive.project}/${directive.task} [${directive.lifecycle}] from task state: ${directive.action}${directive.command ? ` Command: ${directive.command}.` : ""} Task state outranks mail; mail is notification only.`;
580
+ }