@afokapu/atdd-flow 0.3.16 → 0.3.18
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/conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml +13 -0
- package/extensions/pi/index.ts +2 -1
- package/package.json +1 -1
- package/src/cli.ts +5 -1
- package/src/multiplexer.ts +24 -1
- package/src/overview.ts +19 -2
- package/src/seats.ts +40 -4
- package/src/tasks.ts +109 -0
|
@@ -69,6 +69,19 @@ content:
|
|
|
69
69
|
messages to their coordinator; they do not directly message the operator or main seat. A receipt
|
|
70
70
|
or result addresses the seat that made the request, not every historical participant in the thread.
|
|
71
71
|
|
|
72
|
+
For new project topology, `main@project` is the sole primary-checkout and `main`-branch
|
|
73
|
+
coordinator. A named `coordinator.stream@project` works only from its own linked
|
|
74
|
+
`integration/stream` branch and worktree; it integrates bounded driver delivery rather than acting
|
|
75
|
+
as a hidden feature driver. A driver coordinated by that seat derives its governed base from that
|
|
76
|
+
exact integration head, while a main-coordinated driver derives from main. Reject nested
|
|
77
|
+
coordinator lineage, mismatched base/worktree placement, and ambiguous placement rather than
|
|
78
|
+
guessing. Legacy seat, task, and thread identities remain readable without rename or mutation.
|
|
79
|
+
|
|
80
|
+
Drivers hand work back to their named coordinator for integration and validation. The named
|
|
81
|
+
coordinator hands a reviewed integration candidate to main; only main completes normal
|
|
82
|
+
integration-to-main gates and publication. No automatic merge executor, branch graph, daemon, or
|
|
83
|
+
scheduler is introduced by this workflow.
|
|
84
|
+
|
|
72
85
|
A driver starts only its assigned ready task. Treat the task's done_when criteria as the required
|
|
73
86
|
delivery scope. Prefer existing repository patterns, components, dependencies, contracts, and
|
|
74
87
|
abstractions. When multiple implementations are correct, choose the smallest effective change that
|
package/extensions/pi/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ const defaultHeartbeatMs = 15_000;
|
|
|
62
62
|
const defaultStaleAfterMs = 60_000;
|
|
63
63
|
const defaultActivationIntervalMs = 25;
|
|
64
64
|
const defaultActivationTimeoutMs = 60_000;
|
|
65
|
+
const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
|
|
65
66
|
|
|
66
67
|
function participants(thread: Thread) {
|
|
67
68
|
return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string") ? thread.participants as string[] : [];
|
|
@@ -342,7 +343,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
342
343
|
root, seat, piSession, herdrSession, pane,
|
|
343
344
|
activate: async () => {
|
|
344
345
|
if (!await current.activate()) return;
|
|
345
|
-
pi.sendMessage({ customType: "atdd-flow-start", content: `SYSTEM: you are ${seat}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
346
|
+
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" });
|
|
346
347
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
347
348
|
},
|
|
348
349
|
}).catch(() => undefined);
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -37,6 +37,8 @@ Usage:
|
|
|
37
37
|
atdd-flow task start|review|return <project> <task-id> --by <address>
|
|
38
38
|
atdd-flow task done <project> <task-id> --by <address> [--retire-assignee]
|
|
39
39
|
atdd-flow task prove <project> <task-id> --by <address> --item <number> --proof <reference>
|
|
40
|
+
atdd-flow task handoff <project> <task-id> --by <assignee> --phase <plan|red|green|refactor> --evidence <message-or-reference>
|
|
41
|
+
atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
|
|
40
42
|
atdd-flow task block <project> <task-id> --by <address> --reason <text>
|
|
41
43
|
atdd-flow task unblock <project> <task-id> --by <coordinator-address>
|
|
42
44
|
atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
|
|
@@ -112,11 +114,13 @@ async function main() {
|
|
|
112
114
|
if (subcommand === "done") return tasks.done(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
113
115
|
if (subcommand === "return") return tasks.returnToWork(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
114
116
|
if (subcommand === "prove") return tasks.prove(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
117
|
+
if (subcommand === "handoff") return tasks.submitHandoff(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
118
|
+
if (subcommand === "respond") return tasks.respondToHandoff(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
115
119
|
if (subcommand === "block") return tasks.block(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
116
120
|
if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
|
117
121
|
if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
|
|
118
122
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
119
|
-
throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|unblock|list|open`.");
|
|
123
|
+
throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
|
|
120
124
|
},
|
|
121
125
|
cleanup: async () => {
|
|
122
126
|
const [subcommand, projectName, taskId] = rest;
|
package/src/multiplexer.ts
CHANGED
|
@@ -89,6 +89,28 @@ async function projectSeats(root: string, projectName: string) {
|
|
|
89
89
|
return seats.filter((entry): entry is Seat => Boolean(entry && entry.schema === "atdd-workflow/seat/v2" && !entry.retired));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function assertNewTopologyPlacement(config: Project, entry: Seat, primary: string) {
|
|
93
|
+
const coordinator = config.roles.coordinator;
|
|
94
|
+
const namedCoordinatorDefaults = coordinator?.address === "coordinator.{name}@{project}" && coordinator.branch === "integration/{name}";
|
|
95
|
+
const mainDefaults = config.roles.main?.address === "main@{project}" && config.roles.main.branch === "main";
|
|
96
|
+
if (entry.role === "main" && mainDefaults && (entry.address !== `main@${config.project}` || entry.branch !== "main" || resolve(entry.worktree) !== primary)) {
|
|
97
|
+
throw new Error(`Main seat ${entry.address} must use the primary main worktree and branch.`);
|
|
98
|
+
}
|
|
99
|
+
if (entry.role !== "coordinator" || !namedCoordinatorDefaults) return;
|
|
100
|
+
// coordinator@project remains the preserved historical primary identity;
|
|
101
|
+
// every newly named coordinator must be a single linked integration stream.
|
|
102
|
+
if (entry.address === `coordinator@${config.project}`) {
|
|
103
|
+
if (entry.branch !== "main" || resolve(entry.worktree) !== primary) throw new Error(`Legacy primary coordinator ${entry.address} has mismatched placement.`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const suffix = `@${config.project}`;
|
|
107
|
+
const local = entry.address.endsWith(suffix) ? entry.address.slice(0, -suffix.length) : "";
|
|
108
|
+
const match = local.match(/^coordinator\.([a-z0-9_-]+)$/);
|
|
109
|
+
if (!match || entry.branch !== `integration/${match[1]}` || resolve(entry.worktree) === primary) {
|
|
110
|
+
throw new Error(`Named coordinator ${entry.address} must use one linked integration/<stream> worktree and branch.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
async function targets(root: string): Promise<Target[]> {
|
|
93
115
|
let projects: string[];
|
|
94
116
|
try { projects = await readdir(paths(root).work); }
|
|
@@ -102,6 +124,7 @@ async function targets(root: string): Promise<Target[]> {
|
|
|
102
124
|
const primary = resolve(config.repository);
|
|
103
125
|
result.push({ project: name, worktree: primary, workspaceLabel: name });
|
|
104
126
|
for (const entry of await projectSeats(root, name)) {
|
|
127
|
+
assertNewTopologyPlacement(config, entry, primary);
|
|
105
128
|
if ((entry.role === "main" || entry.role === "coordinator") && resolve(entry.worktree) === primary) {
|
|
106
129
|
result.push({ project: name, worktree: primary, workspaceLabel: name, seat: entry });
|
|
107
130
|
continue;
|
|
@@ -148,7 +171,7 @@ async function ensureWorkspace(session: string, target: Target, workspaces: Work
|
|
|
148
171
|
if (!current) {
|
|
149
172
|
const args = target.workspaceLabel === target.project
|
|
150
173
|
? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]
|
|
151
|
-
: ["worktree", "open", "--workspace", primary?.id ?? "", "--
|
|
174
|
+
: ["worktree", "open", "--workspace", primary?.id ?? "", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"];
|
|
152
175
|
if (args.includes("")) throw new Error(`Project primary workspace is required to open linked worktree ${target.worktree}.`);
|
|
153
176
|
const created = json(await command(["herdr", "--session", session, ...args]));
|
|
154
177
|
current = { workspace_id: workspaceId(created), label: target.workspaceLabel, worktree: { checkout_path: target.worktree } };
|
package/src/overview.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
|
|
3
|
-
import { type Task, type TaskStatus } from "./tasks";
|
|
3
|
+
import { type Task, type TaskHandoff, type TaskStatus } from "./tasks";
|
|
4
4
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
5
5
|
import { type Message, type Thread } from "./threads";
|
|
6
6
|
|
|
@@ -108,6 +108,10 @@ function taskPhase(entry: ListedTask) {
|
|
|
108
108
|
return stateName(entry.task.status);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function handoffStateName(value: TaskHandoff["state"]) {
|
|
112
|
+
return value.replaceAll("_", "-").toUpperCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
function attentionForTask(entry: ListedTask, checkpointBySeat: Map<string, Checkpoint | undefined>): Attention | undefined {
|
|
112
116
|
const { task } = entry;
|
|
113
117
|
const inspect = `atdd-flow task open ${entry.project} ${entry.id}`;
|
|
@@ -261,11 +265,24 @@ async function taskDashboard(root: string, projectName: string, taskId: string)
|
|
|
261
265
|
const record = await desk(root);
|
|
262
266
|
const threadList = (await threads(root)).filter((item) => item.record.task === `${projectName}/${taskId}`);
|
|
263
267
|
const proof = `${entry.task.done_when.filter((item) => item.proof).length}/${entry.task.done_when.length} proof`;
|
|
264
|
-
|
|
268
|
+
const runtime = entry.task.assignee ? await readRuntimeState(root, entry.task.assignee) : undefined;
|
|
269
|
+
const runtimeState = runtime && !isRuntimeStateStale(runtime, 60_000) ? "ONLINE" : "OFFLINE";
|
|
270
|
+
const handoff = entry.task.handoff ? handoffStateName(entry.task.handoff.state) : undefined;
|
|
271
|
+
header(`TASK / ${projectName}/${taskId}`, record.desk, `${taskPhase(entry)}${entry.task.phase ? `${dot}${entry.task.phase.toUpperCase()}` : ""}${handoff ? `${dot}${handoff}` : ""}${dot}${proof}${dot}RUNTIME ${runtimeState}`, record.application);
|
|
265
272
|
console.log(`\n ${strong(entry.task.title)}`);
|
|
266
273
|
rule("OWNERSHIP");
|
|
267
274
|
console.log(` coordinator ${entry.task.coordinator}`);
|
|
268
275
|
console.log(` assignee ${entry.task.assignee ?? muted("unassigned")}`);
|
|
276
|
+
rule("PHASE / HANDOFF");
|
|
277
|
+
if (!entry.task.phase && !entry.task.handoff) console.log(` ${muted("No optional phase or handoff projection has been recorded.")}`);
|
|
278
|
+
if (entry.task.phase) console.log(` phase ${entry.task.phase.toUpperCase()}`);
|
|
279
|
+
if (entry.task.handoff) {
|
|
280
|
+
console.log(` handoff ${handoffStateName(entry.task.handoff.state)}`);
|
|
281
|
+
console.log(` evidence ${entry.task.handoff.evidence}`);
|
|
282
|
+
console.log(` updated ${entry.task.handoff.updated_at}`);
|
|
283
|
+
}
|
|
284
|
+
rule("RUNTIME OBSERVATION", runtimeState);
|
|
285
|
+
console.log(` assignee runtime ${runtimeState}${runtime ? ` · ${runtime.model} · heartbeat ${runtime.heartbeat_at}` : " · no current advisory observation"}`);
|
|
269
286
|
rule("DEPENDENCIES", String((entry.task.depends_on ?? []).length));
|
|
270
287
|
if (!entry.task.depends_on?.length) console.log(` ${muted("Independent; it may proceed in parallel.")}`);
|
|
271
288
|
for (const dependency of entry.task.depends_on ?? []) console.log(` ${entry.waiting.includes(dependency) ? tone("○", "WAITING") : tone("✓", "DONE")} ${dependency}${entry.waiting.includes(dependency) ? ` ${tone("WAITING", "WAITING")}` : ""}`);
|
package/src/seats.ts
CHANGED
|
@@ -10,10 +10,17 @@ import {
|
|
|
10
10
|
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
11
11
|
import { seatTasks } from "./tasks";
|
|
12
12
|
import { projectHerdrSeat } from "./multiplexer";
|
|
13
|
+
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
14
|
+
|
|
15
|
+
const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
|
|
13
16
|
|
|
14
17
|
const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
|
|
18
|
+
main: {
|
|
19
|
+
address: "main@{project}", branch: "main", worktree: "{repository}",
|
|
20
|
+
...(dynamicModels ? {} : { agent: "pi" }),
|
|
21
|
+
},
|
|
15
22
|
coordinator: {
|
|
16
|
-
address: "coordinator@{project}", branch: "main", worktree: "{
|
|
23
|
+
address: "coordinator.{name}@{project}", branch: "integration/{name}", base: "main", worktree: "{worktree_root}/{name}",
|
|
17
24
|
...(dynamicModels ? {} : { agent: "pi" }),
|
|
18
25
|
},
|
|
19
26
|
driver: {
|
|
@@ -75,10 +82,27 @@ export async function spawn(root: string, projectName: string, roleName: string,
|
|
|
75
82
|
const config = await project(root, projectName);
|
|
76
83
|
const role = required(config.roles[roleName], `role ${roleName}`);
|
|
77
84
|
const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
// Retain the historical primary coordinator command for existing Desks while
|
|
86
|
+
// new defaults make main@project the only new primary identity.
|
|
87
|
+
const legacyPrimaryCoordinator = roleName === "coordinator" && name === "main"
|
|
88
|
+
&& role.address === "coordinator.{name}@{project}" && role.branch === "integration/{name}";
|
|
89
|
+
const address = legacyPrimaryCoordinator ? `coordinator@${config.project}` : fill(role.address, entries);
|
|
90
|
+
const configuredPath = legacyPrimaryCoordinator ? config.repository : role.worktree ? fill(role.worktree, { ...entries, repository: config.repository ?? "" }) : undefined;
|
|
80
91
|
const worktree = resolve(required(words(args, "--worktree") ?? configuredPath, "--worktree or role worktree template"));
|
|
81
|
-
const branch = words(args, "--branch") ?? fill(role.branch, { project: config.project, name });
|
|
92
|
+
const branch = words(args, "--branch") ?? (legacyPrimaryCoordinator ? "main" : fill(role.branch, { project: config.project, name }));
|
|
93
|
+
const newMainDefaults = roleName === "main" && role.address === "main@{project}" && role.branch === "main";
|
|
94
|
+
const newNamedCoordinatorDefaults = roleName === "coordinator" && !legacyPrimaryCoordinator
|
|
95
|
+
&& role.address === "coordinator.{name}@{project}" && role.branch === "integration/{name}";
|
|
96
|
+
if (newMainDefaults && (branch !== "main" || (config.repository && worktree !== resolve(config.repository)))) {
|
|
97
|
+
throw new Error(`Main seat main@${config.project} must use the declared primary main worktree and branch.`);
|
|
98
|
+
}
|
|
99
|
+
if (newNamedCoordinatorDefaults) {
|
|
100
|
+
if (!/^[a-z0-9_-]+$/.test(name)) throw new Error("Named coordinator must use one single stream name.");
|
|
101
|
+
const expectedWorktree = resolve(fill(required(role.worktree, "named coordinator worktree template"), { ...entries, repository: config.repository ?? "" }));
|
|
102
|
+
if (branch !== `integration/${name}` || worktree !== expectedWorktree) {
|
|
103
|
+
throw new Error(`Named coordinator ${address} must use its declared integration/${name} branch and linked worktree.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
82
106
|
await ensureWorktree(config, role, worktree, branch);
|
|
83
107
|
const purpose = words(args, "--purpose") ?? (role.purpose ? fill(role.purpose, entries) : undefined);
|
|
84
108
|
const portfolio = await modelPortfolio(root);
|
|
@@ -360,6 +384,17 @@ export async function openSeat(root: string, address: string) {
|
|
|
360
384
|
// legacy scalar form while the durable record now carries its session too.
|
|
361
385
|
const herdr = record.runtime?.addresses.herdr;
|
|
362
386
|
if (herdr && typeof herdr !== "string") console.log(`herdr: ${herdr.pane} (session: ${herdr.session})`);
|
|
387
|
+
if (typeof herdr === "string") console.log(`herdr: ${herdr} (unscoped; unverified)`);
|
|
388
|
+
if (record.runtime?.application === "herdr") {
|
|
389
|
+
const advisory = await readRuntimeState(root, resolved);
|
|
390
|
+
const missing = [
|
|
391
|
+
typeof herdr === "string" || !herdr ? "Herdr session" : undefined,
|
|
392
|
+
!record.runtime.pi_session || !record.runtime.pi_session_path ? "Pi session/path" : undefined,
|
|
393
|
+
!record.runtime.launch_receipt || !await exists(record.runtime.launch_receipt) ? "launch receipt" : undefined,
|
|
394
|
+
!advisory || isRuntimeStateStale(advisory, 60_000) ? "advisory heartbeat" : undefined,
|
|
395
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
396
|
+
console.log(`runtime verification: ${missing.length ? `unverified (${missing.join(", ")})` : "bounded metadata and fresh advisory heartbeat"}`);
|
|
397
|
+
}
|
|
363
398
|
const checkpointFile = paths(root).checkpointFile(resolved);
|
|
364
399
|
if (await exists(checkpointFile)) console.log(yaml.print(await readYaml<Checkpoint>(checkpointFile)));
|
|
365
400
|
const threadIds = await readdir(paths(root).threads);
|
|
@@ -369,4 +404,5 @@ export async function openSeat(root: string, address: string) {
|
|
|
369
404
|
const entry = await readYaml<{ participants: string[]; state: string; subject: string }>(file);
|
|
370
405
|
if (entry.participants.includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
|
|
371
406
|
}
|
|
407
|
+
console.log(`Convention: ${lifecycleConventionPath}`);
|
|
372
408
|
}
|
package/src/tasks.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
type Checkpoint, atomicYaml, canonicalAddress, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
|
|
4
5
|
} from "./core";
|
|
5
6
|
|
|
6
7
|
export type TaskStatus = "todo" | "in_progress" | "review" | "done";
|
|
8
|
+
/** An optional, descriptive implementation phase; task status remains the lifecycle authority. */
|
|
9
|
+
export type TaskPhase = "plan" | "red" | "green" | "refactor";
|
|
10
|
+
export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assignee";
|
|
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 };
|
|
7
13
|
export type DoneWhen = { text: string; proof?: string };
|
|
14
|
+
export type GovernedBase = { coordinator: string; branch: string; commit: string };
|
|
8
15
|
export type Task = {
|
|
9
16
|
schema: "atdd-workflow/task/v1";
|
|
10
17
|
title: string;
|
|
@@ -15,6 +22,12 @@ export type Task = {
|
|
|
15
22
|
source?: string;
|
|
16
23
|
depends_on?: string[];
|
|
17
24
|
blocker?: string;
|
|
25
|
+
/** Absent on legacy tasks. */
|
|
26
|
+
phase?: TaskPhase;
|
|
27
|
+
/** Absent on legacy tasks; evidence remains an opaque immutable message/reference. */
|
|
28
|
+
handoff?: TaskHandoff;
|
|
29
|
+
/** Exact named-coordinator or main head from which an assigned driver is governed. */
|
|
30
|
+
governed_base?: GovernedBase;
|
|
18
31
|
done_when: DoneWhen[];
|
|
19
32
|
};
|
|
20
33
|
|
|
@@ -60,6 +73,42 @@ async function writeTask(root: string, projectName: string, id: string, task: Ta
|
|
|
60
73
|
await atomicYaml(paths(root).taskFile(projectName, id), task);
|
|
61
74
|
}
|
|
62
75
|
|
|
76
|
+
/** Records the exact parent head for new bounded topology without rewriting legacy tasks. */
|
|
77
|
+
async function governedBase(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<GovernedBase | undefined> {
|
|
78
|
+
if (!assignee) return undefined;
|
|
79
|
+
const config = await project(root, projectName);
|
|
80
|
+
if (!config.repository) return undefined;
|
|
81
|
+
const coordinator = await seat(root, coordinatorAddress);
|
|
82
|
+
const driver = await seat(root, assignee);
|
|
83
|
+
if (driver.role !== "driver") throw new Error(`${assignee} is not a driver seat.`);
|
|
84
|
+
const primary = resolve(config.repository);
|
|
85
|
+
let branch: string;
|
|
86
|
+
if (coordinator.role === "main") {
|
|
87
|
+
if (coordinator.address !== `main@${config.project}` || coordinator.branch !== "main" || resolve(coordinator.worktree) !== primary) {
|
|
88
|
+
throw new Error(`Main coordinator ${coordinator.address} has mismatched primary lineage.`);
|
|
89
|
+
}
|
|
90
|
+
branch = "main";
|
|
91
|
+
} else if (coordinator.role === "coordinator") {
|
|
92
|
+
const configured = config.roles.coordinator;
|
|
93
|
+
// Existing coordinator role templates and coordinator@project records
|
|
94
|
+
// predate bounded integration lineage and remain readable unchanged.
|
|
95
|
+
if (configured?.address !== "coordinator.{name}@{project}" || configured.branch !== "integration/{name}" || coordinator.address === `coordinator@${config.project}`) return undefined;
|
|
96
|
+
const suffix = `@${config.project}`;
|
|
97
|
+
const local = coordinator.address.endsWith(suffix) ? coordinator.address.slice(0, -suffix.length) : "";
|
|
98
|
+
const match = local.match(/^coordinator\.([a-z0-9_-]+)$/);
|
|
99
|
+
if (!match || coordinator.branch !== `integration/${match[1]}` || resolve(coordinator.worktree) === primary) {
|
|
100
|
+
throw new Error(`Coordinator ${coordinator.address} has mismatched or nested integration lineage.`);
|
|
101
|
+
}
|
|
102
|
+
branch = coordinator.branch;
|
|
103
|
+
} else {
|
|
104
|
+
throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
|
|
105
|
+
}
|
|
106
|
+
const commit = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", branch]);
|
|
107
|
+
const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, driver.branch]);
|
|
108
|
+
if (common !== commit) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
|
|
109
|
+
return { coordinator: coordinator.address, branch, commit };
|
|
110
|
+
}
|
|
111
|
+
|
|
63
112
|
async function retireAssignee(root: string, projectName: string, id: string, task: Task) {
|
|
64
113
|
const assignee = required(task.assignee, `an assignee for task ${id}`);
|
|
65
114
|
const unfinished = (await allTasks(root, projectName))
|
|
@@ -98,6 +147,7 @@ export async function add(root: string, projectName: string, id: string, args: s
|
|
|
98
147
|
const assignee = assigneeValue ? await canonicalAddress(root, assigneeValue) : undefined;
|
|
99
148
|
await seat(root, coordinator);
|
|
100
149
|
if (assignee) await seat(root, assignee);
|
|
150
|
+
const base = await governedBase(root, projectName, coordinator, assignee);
|
|
101
151
|
const doneWhen = values(args, "--done-when").map((text) => ({ text }));
|
|
102
152
|
if (!doneWhen.length) throw new Error("A task needs at least one --done-when criterion.");
|
|
103
153
|
const dependsOn = words(args, "--depends-on")?.split(",").filter(Boolean).map(taskId);
|
|
@@ -107,6 +157,7 @@ export async function add(root: string, projectName: string, id: string, args: s
|
|
|
107
157
|
status: "todo",
|
|
108
158
|
coordinator,
|
|
109
159
|
...(assignee ? { assignee } : {}),
|
|
160
|
+
...(base ? { governed_base: base } : {}),
|
|
110
161
|
...(words(args, "--body") ? { body: words(args, "--body") } : {}),
|
|
111
162
|
...(words(args, "--source") ? { source: words(args, "--source") } : {}),
|
|
112
163
|
...(dependsOn?.length ? { depends_on: dependsOn } : {}),
|
|
@@ -124,7 +175,9 @@ export async function assign(root: string, projectName: string, id: string, args
|
|
|
124
175
|
if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
|
|
125
176
|
const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
|
|
126
177
|
await seat(root, assignee);
|
|
178
|
+
const base = await governedBase(root, projectName, task.coordinator, assignee);
|
|
127
179
|
task.assignee = assignee;
|
|
180
|
+
if (base) task.governed_base = base;
|
|
128
181
|
await writeTask(root, projectName, id, task);
|
|
129
182
|
console.log(`${id} assigned ${assignee}`);
|
|
130
183
|
}
|
|
@@ -220,6 +273,62 @@ export async function returnToWork(root: string, projectName: string, id: string
|
|
|
220
273
|
return transition(root, projectName, taskId(id), "in_progress", required(words(args, "--by"), "--by"));
|
|
221
274
|
}
|
|
222
275
|
|
|
276
|
+
const phases: TaskPhase[] = ["plan", "red", "green", "refactor"];
|
|
277
|
+
const handoffOutcomes = ["accept", "return"] as const;
|
|
278
|
+
|
|
279
|
+
type HandoffOutcome = typeof handoffOutcomes[number];
|
|
280
|
+
|
|
281
|
+
function phase(value: string | undefined): TaskPhase {
|
|
282
|
+
const selected = required(value, "--phase");
|
|
283
|
+
if (!phases.includes(selected as TaskPhase)) throw new Error(`--phase must be one of: ${phases.join(", ")}.`);
|
|
284
|
+
return selected as TaskPhase;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function handoffOutcome(value: string | undefined): HandoffOutcome {
|
|
288
|
+
const selected = required(value, "--outcome");
|
|
289
|
+
if (!(handoffOutcomes as readonly string[]).includes(selected)) throw new Error("--outcome must be accept or return.");
|
|
290
|
+
return selected as HandoffOutcome;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The assignee records work as ready for a coordinator decision. The evidence
|
|
295
|
+
* value is deliberately opaque: it links an immutable message/reference but
|
|
296
|
+
* never parses message prose or changes the task lifecycle.
|
|
297
|
+
*/
|
|
298
|
+
export async function submitHandoff(root: string, projectName: string, id: string, args: string[]) {
|
|
299
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
300
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
301
|
+
if (!task.assignee || actor !== task.assignee) throw new Error(`Only ${task.assignee ?? "the assignee"} may submit a handoff for task ${id}.`);
|
|
302
|
+
if (task.status !== "in_progress") throw new Error(`Task ${id} must be in_progress before a handoff is submitted.`);
|
|
303
|
+
if (task.handoff?.state === "awaiting_coordinator") throw new Error(`Task ${id} is already awaiting coordinator handoff response.`);
|
|
304
|
+
const evidence = required(words(args, "--evidence"), "--evidence");
|
|
305
|
+
task.phase = phase(words(args, "--phase"));
|
|
306
|
+
task.handoff = { state: "awaiting_coordinator", evidence, updated_at: now() };
|
|
307
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
308
|
+
console.log(`${id} handoff awaiting_coordinator`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The coordinator accepts a handoff for the assignee's next phase or returns
|
|
313
|
+
* it to active execution. This only updates the optional projection.
|
|
314
|
+
*/
|
|
315
|
+
export async function respondToHandoff(root: string, projectName: string, id: string, args: string[]) {
|
|
316
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
317
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
318
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may respond to task ${id} handoff.`);
|
|
319
|
+
if (task.status !== "in_progress") throw new Error(`Task ${id} must be in_progress before a handoff is answered.`);
|
|
320
|
+
if (task.handoff?.state !== "awaiting_coordinator") throw new Error(`Task ${id} is not awaiting coordinator handoff response.`);
|
|
321
|
+
const outcome = handoffOutcome(words(args, "--outcome"));
|
|
322
|
+
task.phase = phase(words(args, "--phase"));
|
|
323
|
+
task.handoff = {
|
|
324
|
+
...task.handoff,
|
|
325
|
+
state: outcome === "accept" ? "awaiting_assignee" : "executing",
|
|
326
|
+
updated_at: now(),
|
|
327
|
+
};
|
|
328
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
329
|
+
console.log(`${id} handoff ${task.handoff.state}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
223
332
|
export async function prove(root: string, projectName: string, id: string, args: string[]) {
|
|
224
333
|
const task = await readTask(root, projectName, taskId(id));
|
|
225
334
|
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|