@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.
- package/extensions/pi/index.ts +17 -1
- package/package.json +1 -1
- package/src/tasks.ts +81 -1
package/extensions/pi/index.ts
CHANGED
|
@@ -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
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
|
+
}
|