@afokapu/atdd-flow 0.3.26 → 0.3.28

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/README.md CHANGED
@@ -130,6 +130,8 @@ atdd-flow task done example-app runtime-rollout --by coordinator@example-app
130
130
 
131
131
  Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun.
132
132
 
133
+ At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
134
+
133
135
  For topology normalization, transfer an active task through the narrow durable command, never by editing its YAML: `atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <current-coordinator>`; `main@project` must additionally pass `--authorization <operator-message-id>`. That message must be an immutable `task-transfer-authorization/v1` exact tuple for the project, task, current/target coordinator, reason, and target branch/head; only `operator@desk` creates it through `atdd-flow post --task-transfer-authorization <json>`. The command appends immutable source/target/reason/exact-head/effective-time provenance, keeps earlier phase/review/message evidence intact, derives the new governed base from the accepted target head, and never moves a seat, branch, runtime, or worktree. Same-target, duplicate, nested, mismatched, unauthorized, and manually rewritten transitions fail closed.
134
136
 
135
137
  ## Communicate and hand over
@@ -62,6 +62,64 @@ content:
62
62
  Start from the current seat, checkpoint, assigned tasks, and relevant thread records. A replacement
63
63
  agent resumes from those records; it does not depend on a conversation with its predecessor.
64
64
 
65
+ Context handover: every seat (main, coordinator, driver, operator) stops at 80% context usage.
66
+ It finishes or cleanly undoes the current atomic step (commit, post, receipt) and starts nothing
67
+ new. It writes a handover using the template below, validates it with
68
+ `atdd-flow handover <address> --check --file <path>`, fixes every reported problem, and records
69
+ it with `atdd-flow handover <address> --file <path>`. The seat's handover.yaml is the one durable
70
+ handover record; it sits beside the checkpoint and never replaces it. The old session then makes
71
+ no further tool calls, and a fresh session bound to the same seat continues. The new session reads
72
+ the instruction file, then `atdd-flow handover show <address>`, runs the handover's verification,
73
+ reconciles it with the live Desk (the Desk wins), reconciles its inbox, reports
74
+ `RESUMED; verified N/M; discrepancies: ...` through its normal reporting route, and continues.
75
+ A handover points to toolkit conventions and never restates or overrides them; read a convention
76
+ when its phase is acted on, not all on resume. 150 lines is a ceiling, not a target. List open
77
+ items only, use exact IDs, and mark state [verified|assumed]. Recording rejects a missing or
78
+ malformed header or Runtime line, a missing, duplicate, or out-of-order section, more than
79
+ 150 lines, a supersedes value other than the recorded handover id (or none when none is recorded),
80
+ and vague references: the words latest, newest, and most recent, and that/this/those/these/same/
81
+ previous/earlier/above/last/aforementioned followed by PR, pull request, task, thread, message,
82
+ commit, branch, handover, session, issue, or release without an exact identifier (one containing
83
+ a digit, @, or /). Template:
84
+
85
+ HANDOVER <seat-address> | <UTC ts> | handover-id <id> | supersedes <id|none>
86
+ Runtime: <application> <session>:<pane> | <agent> session <id> | model <provider/model> | context <NN>%
87
+
88
+ 1. SEAT, AUTHORITY, TOOLKIT AND HAZARDS
89
+ - Seat: <address> (<role>), project <project>; reports to <addr>; directs <addrs|none>
90
+ - Worktree: <path> @ <branch> <sha> (<clean | dirty: files>)
91
+ - Toolkit in force (this worktree): Flow <ver> / Bun <ver> | declared=locked=installed: <yes | NOT CURRENT: detail> | runtime binding: <Flow ver | unverified>
92
+ - Governing conventions for current phase: <convention ids> (pointers only)
93
+ - Profile/ratchet state: <profile> <reject-new|strict> | activated ratchets (never modify): <list|none>
94
+ - Authorizations in force: <scope> | granted by <addr> | ref <msg-id> | expires <ts|never>
95
+ - Seat-specific constraints (beyond the instruction file): <list|none>
96
+ - Hazards / known bugs / workarounds: <list|none>
97
+ - Dead ends (tried, failed, do not repeat): <what> | ref <id> (or none)
98
+
99
+ 2. OPEN WORK AND IN-FLIGHT OPERATIONS
100
+ - <project>/<task-id> | <status/phase> | assignee <addr> | [verified|assumed]
101
+ ref: <PR#/sha/pane>; last evidence <msg-id>; state: <one line>
102
+ - IN-FLIGHT: <operation> | started <ts> | check: <exact command> | retry-safe: <yes|no|why>
103
+ - Session-only knowledge: <facts learned, not recorded in Desk> (or none)
104
+
105
+ 3. DEPENDENCIES (both directions)
106
+ - WAITING ON: <what> | <addr> | since <ts> | ref <msg-id> | follow up at <ts>
107
+ - OWED BY ME: <what> | to <addr> | due <ts> | ref <msg-id>
108
+ - DECISION PENDING: <question> | owner <addr> | options A/B + recommendation | ref <id>
109
+ - RECEIVED, NOT YET ACTED ON: <msg-id> | from <addr> | needed action
110
+ (or: none, per subsection)
111
+
112
+ 4. RESUME PROCEDURE AND NEXT ACTIONS
113
+ Verify first:
114
+ - atdd-flow status seat <address>
115
+ - atdd-flow task open <project> <task-id> (each open task)
116
+ - git -C <worktree> status && git log -1
117
+ - installed toolkit versions in <worktree> match section 1 (mismatch = discrepancy; report, do not self-upgrade)
118
+ - <each IN-FLIGHT check command>
119
+ Then, in order:
120
+ 1. Read inbox; receipt pending messages lacking my receipt.
121
+ 2. <concrete action with exact IDs>
122
+
65
123
  `operator@desk` is the human authority, not an autonomous agent identity. Never post, approve,
66
124
  or make a decision while claiming that address unless the human operator actually supplied it.
67
125
  Main seats are the normal repository-main integration gateway to the operator. Coordinators may
@@ -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.28",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { status } from "./overview";
13
13
  import { multiplexer } from "./multiplexer";
14
14
  import * as cleanup from "./ephemeral-resources";
15
15
  import * as ownerAlerts from "./owner-escalations";
16
+ import { recordHandover, showHandover } from "./handover";
16
17
 
17
18
  const usage = `atdd-flow — filesystem-first agent seats and tasks
18
19
 
@@ -34,6 +35,8 @@ Usage:
34
35
  atdd-flow multiplexer status|apply herdr [--session <name>]
35
36
  atdd-flow describe <address> --purpose <one-line responsibility>
36
37
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
38
+ atdd-flow handover <address> --file <path> [--check]
39
+ atdd-flow handover show <address>
37
40
  atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
38
41
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
39
42
  atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <actor> [--authorization <operator-message-id>]
@@ -112,6 +115,7 @@ async function main() {
112
115
  multiplexer: () => multiplexer(root, rest),
113
116
  describe: () => describe(root, required(rest[0], "address"), rest.slice(1)),
114
117
  checkpoint: () => checkpoint(root, required(rest[0], "address"), rest.slice(1)),
118
+ handover: () => rest[0] === "show" ? showHandover(root, required(rest[1], "address")) : recordHandover(root, required(rest[0], "address"), rest.slice(1)),
115
119
  task: async () => {
116
120
  const [subcommand, projectName, taskId, ...tail] = rest;
117
121
  if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
package/src/core.ts CHANGED
@@ -159,6 +159,7 @@ export const paths = (root: string) => ({
159
159
  },
160
160
  seatFile: (address: string) => join(paths(root).seat(address), "seat.yaml"),
161
161
  checkpointFile: (address: string) => join(paths(root).seat(address), "checkpoint.yaml"),
162
+ handoverFile: (address: string) => join(paths(root).seat(address), "handover.yaml"),
162
163
  tasks: (project: string) => join(root, "work", project, "tasks"),
163
164
  taskFile: (project: string, task: string) => join(root, "work", project, "tasks", `${taskId(task)}.yaml`),
164
165
  behavioralReviewFile: (project: string, task: string) => join(root, "work", project, "tasks", `${taskId(task)}.reviews.yaml`),
@@ -0,0 +1,90 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { atomicYaml, canonicalAddress, exists, has, now, paths, readYaml, required, seat, words } from "./core";
3
+
4
+ export type Handover = {
5
+ schema: "atdd-workflow/handover/v1";
6
+ seat: string;
7
+ handover_id: string;
8
+ supersedes: string;
9
+ recorded_at: string;
10
+ text: string;
11
+ };
12
+
13
+ export const maxHandoverLines = 150;
14
+ export const handoverSections = [
15
+ "SEAT, AUTHORITY, TOOLKIT AND HAZARDS",
16
+ "OPEN WORK AND IN-FLIGHT OPERATIONS",
17
+ "DEPENDENCIES",
18
+ "RESUME PROCEDURE AND NEXT ACTIONS",
19
+ ] as const;
20
+
21
+ const header = /^HANDOVER (\S+) \| (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?Z) \| handover-id ([^\s|]+) \| supersedes ([^\s|]+)$/;
22
+ const headerForm = "`HANDOVER <seat-address> | <UTC ts> | handover-id <id> | supersedes <id|none>`";
23
+ /** Words that name a moving target instead of an exact record. */
24
+ const vagueWords = /\b(latest|newest|most recent)\b/gi;
25
+ /** A demonstrative reference to a record kind, vague unless an exact identifier (one containing a digit, `@`, or `/`) follows. */
26
+ const vagueDemonstrative = /\b(?:that|this|those|these|same|previous|earlier|above|last|aforementioned)\s+(PRs?|pull requests?|tasks?|threads?|messages?|commits?|branch(?:es)?|handovers?|sessions?|issues?|releases?)\b(?!\s+\S*[\d@/])/gi;
27
+
28
+ /** Every reason a handover text is not acceptable for `seat`, given the currently recorded handover id. */
29
+ export function handoverProblems(text: string, seatAddress: string, recorded?: string) {
30
+ const problems: string[] = [];
31
+ const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
32
+ if (lines.length > maxHandoverLines) problems.push(`handover has ${lines.length} lines; the maximum is ${maxHandoverLines}.`);
33
+ const match = header.exec(lines[0] ?? "");
34
+ if (!match) problems.push(`line 1: missing or malformed header; expected ${headerForm}.`);
35
+ else {
36
+ const [, writer, , id, supersedes] = match as unknown as [string, string, string, string, string];
37
+ if (writer !== seatAddress) problems.push(`line 1: header seat ${writer} does not match ${seatAddress}.`);
38
+ if (id === "none") problems.push("line 1: handover-id must be an exact identifier, not none.");
39
+ if (recorded === undefined && supersedes !== "none") problems.push(`line 1: supersedes ${supersedes}, but ${seatAddress} has no recorded handover; use supersedes none.`);
40
+ if (recorded !== undefined && supersedes !== recorded) problems.push(`line 1: supersedes ${supersedes} must name the recorded handover ${recorded}.`);
41
+ if (recorded !== undefined && id === recorded) problems.push(`line 1: handover-id ${id} is already recorded; use a new id.`);
42
+ }
43
+ if (!/^Runtime:\s*\S/.test(lines[1] ?? "")) problems.push("line 2: missing Runtime line; expected `Runtime: <runtime> | <session> | model <provider/model> | context <NN>%`.");
44
+ const found: number[] = [];
45
+ handoverSections.forEach((title, index) => {
46
+ const heading = `${index + 1}. ${title}`;
47
+ const at = lines.flatMap((line, position) => line.trim().startsWith(heading) ? [position] : []);
48
+ if (!at.length) problems.push(`missing section ${index + 1} (\`${heading}\`).`);
49
+ else if (at.length > 1) problems.push(`line ${at[1]! + 1}: duplicate section ${index + 1}.`);
50
+ found[index + 1] = at[0] ?? -1;
51
+ });
52
+ for (let section = 2; section <= handoverSections.length; section++) {
53
+ const earlier = found[section - 1]!, later = found[section]!;
54
+ if (earlier >= 0 && later >= 0 && later < earlier) problems.push(`line ${later + 1}: section ${section} appears before section ${section - 1}; sections are out of order (expected 1, 2, 3, 4).`);
55
+ }
56
+ lines.forEach((line, index) => {
57
+ for (const vague of [...line.matchAll(vagueWords), ...line.matchAll(vagueDemonstrative)]) {
58
+ problems.push(`line ${index + 1}: vague reference "${vague[0]}"; name the exact ID (task, message, PR number, commit sha, or address).`);
59
+ }
60
+ });
61
+ return problems;
62
+ }
63
+
64
+ async function recorded(root: string, address: string) {
65
+ const file = paths(root).handoverFile(address);
66
+ return await exists(file) ? await readYaml<Handover>(file) : undefined;
67
+ }
68
+
69
+ /** Validate a handover file and, unless `--check`, record it as the seat's durable handover. Checkpoints are never touched. */
70
+ export async function recordHandover(root: string, address: string, args: string[]) {
71
+ const resolved = await canonicalAddress(root, address);
72
+ await seat(root, resolved);
73
+ const text = await readFile(required(words(args, "--file"), "--file"), "utf8");
74
+ const prior = await recorded(root, resolved);
75
+ const problems = handoverProblems(text, resolved, prior?.handover_id);
76
+ if (problems.length) throw new Error(`Handover rejected for ${resolved}; nothing was recorded:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
77
+ const [, , , id, supersedes] = header.exec(text.split(/\r?\n/, 1)[0]!)! as unknown as [string, string, string, string, string];
78
+ if (has(args, "--check")) return console.log(`Handover valid: ${id} for ${resolved} (checked only; not recorded).`);
79
+ const record: Handover = { schema: "atdd-workflow/handover/v1", seat: resolved, handover_id: id, supersedes, recorded_at: now(), text };
80
+ await atomicYaml(paths(root).handoverFile(resolved), record);
81
+ console.log(`Recorded handover ${id} for ${resolved}.`);
82
+ }
83
+
84
+ export async function showHandover(root: string, address: string) {
85
+ const resolved = await canonicalAddress(root, address);
86
+ await seat(root, resolved);
87
+ const record = await recorded(root, resolved);
88
+ if (!record) throw new Error(`No handover is recorded for ${resolved}.`);
89
+ process.stdout.write(record.text.endsWith("\n") ? record.text : `${record.text}\n`);
90
+ }
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
+ }