@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
|
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/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`),
|
package/src/handover.ts
ADDED
|
@@ -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
|
+
}
|