@afokapu/atdd-flow 0.3.19 → 0.3.21

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
@@ -120,6 +120,8 @@ atdd-flow task done resolver-os runtime-rollout --by coordinator@resolver-os
120
120
 
121
121
  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.
122
122
 
123
+ 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.
124
+
123
125
  ## Communicate and hand over
124
126
 
125
127
  Threads are the durable inbox/outbox. Workflow persists a message before a best-effort host notification, so a closed pane, rate limit, or missed prompt cannot lose it.
@@ -77,6 +77,17 @@ content:
77
77
  coordinator lineage, mismatched base/worktree placement, and ambiguous placement rather than
78
78
  guessing. Legacy seat, task, and thread identities remain readable without rename or mutation.
79
79
 
80
+ Transfer active task accountability only through `atdd-flow task transfer`, never by rewriting
81
+ task YAML. The transition retains append-only from/to/reason/exact-head/effective-time provenance
82
+ and leaves prior task, phase, review, and message evidence attributable. The current coordinator
83
+ may transfer, while main requires one applicable immutable `operator@desk`
84
+ `task-transfer-authorization/v1` message. Its project, task, from, to, reason, and exact target
85
+ branch/head must match the requested transfer before mutation; only `operator@desk` may create
86
+ that typed authorization. Accept only main or a valid named integration coordinator; atomically
87
+ derive the new governed base from that accepted target head. Reject same-target, duplicate, nested, mismatched,
88
+ unauthorized, or manually rewritten transfers. A transfer never moves or changes any seat, branch,
89
+ runtime binding, worktree, dirty state, or evidence artifact.
90
+
80
91
  Drivers hand work back to their named coordinator for integration and validation. The named
81
92
  coordinator hands a reviewed integration candidate to main; only main completes normal
82
93
  integration-to-main gates and publication. No automatic merge executor, branch graph, daemon, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
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
@@ -11,6 +11,7 @@ import * as reviews from "./reviews";
11
11
  import { status } from "./overview";
12
12
  import { multiplexer } from "./multiplexer";
13
13
  import * as cleanup from "./ephemeral-resources";
14
+ import * as ownerAlerts from "./owner-escalations";
14
15
 
15
16
  const usage = `atdd-flow — filesystem-first agent seats and tasks
16
17
 
@@ -32,6 +33,7 @@ Usage:
32
33
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
33
34
  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> ...]
34
35
  atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
36
+ atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <actor> [--authorization <operator-message-id>]
35
37
  atdd-flow task amend <project> <task-id> [--title <text>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>]
36
38
  atdd-flow task import <project> <task-id> --proof <reference> [--proof <reference> ...] [--done-when <text> ...] [--source <reference>]
37
39
  atdd-flow task start|review|return <project> <task-id> --by <address>
@@ -45,11 +47,13 @@ Usage:
45
47
  atdd-flow task open <project> <task-id>
46
48
  atdd-flow cleanup status <project>
47
49
  atdd-flow cleanup checklist <project> <task-id>
50
+ atdd-flow owner-alert scan --by <coordinator-or-main> [--re-notify-after-ms <milliseconds>]
51
+ atdd-flow owner-alert deliver|acknowledge|execute|resolve <alert-id> --by <address> [--note <text>|--option <id>|--reason <text>]
48
52
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
49
53
  atdd-flow thread add <thread-id> <address>
50
54
  atdd-flow thread open <thread-id>
51
55
  atdd-flow message read <message-id>
52
- atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result]
56
+ atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result] [--task-transfer-authorization <json>]
53
57
  atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>] [--label <non-sensitive-text>]
54
58
  atdd-flow result <thread-id> <message-id> --from <address> --body <text> [--label <non-sensitive-text>]
55
59
  atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]
@@ -107,6 +111,7 @@ async function main() {
107
111
  const [subcommand, projectName, taskId, ...tail] = rest;
108
112
  if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
109
113
  if (subcommand === "assign") return tasks.assign(root, required(projectName, "project"), required(taskId, "task id"), tail);
114
+ if (subcommand === "transfer") return tasks.transfer(root, required(projectName, "project"), required(taskId, "task id"), tail);
110
115
  if (subcommand === "amend") return tasks.amend(root, required(projectName, "project"), required(taskId, "task id"), tail);
111
116
  if (subcommand === "import") return tasks.importCompleted(root, required(projectName, "project"), required(taskId, "task id"), tail);
112
117
  if (subcommand === "start") return tasks.start(root, required(projectName, "project"), required(taskId, "task id"), tail);
@@ -120,7 +125,7 @@ async function main() {
120
125
  if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
121
126
  if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
122
127
  if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
123
- throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
128
+ throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
124
129
  },
125
130
  cleanup: async () => {
126
131
  const [subcommand, projectName, taskId] = rest;
@@ -128,6 +133,20 @@ async function main() {
128
133
  if (subcommand === "checklist") return console.log(await cleanup.checklist(root, { project: required(projectName, "project"), task: required(taskId, "task id") }));
129
134
  throw new Error("Use `atdd-flow cleanup status <project>` or `atdd-flow cleanup checklist <project> <task-id>`.");
130
135
  },
136
+ "owner-alert": async () => {
137
+ const [subcommand, alertId, ...tail] = rest;
138
+ if (subcommand === "scan") {
139
+ const delay = words(rest.slice(1), "--re-notify-after-ms");
140
+ if (delay !== undefined && (!Number.isSafeInteger(Number(delay)) || Number(delay) < 0)) throw new Error("--re-notify-after-ms must be a non-negative integer.");
141
+ return console.log(Bun.YAML.stringify(await ownerAlerts.scanOwnerAlerts(root, { by: required(words(rest.slice(1), "--by"), "--by"), ...(delay === undefined ? {} : { re_notify_after_ms: Number(delay) }) })));
142
+ }
143
+ const by = required(words(tail, "--by"), "--by");
144
+ if (subcommand === "deliver") return ownerAlerts.deliverOwnerAlert(root, required(alertId, "alert id"), { by });
145
+ if (subcommand === "acknowledge") return ownerAlerts.acknowledgeOwnerAlert(root, required(alertId, "alert id"), { by, note: required(words(tail, "--note"), "--note") });
146
+ if (subcommand === "execute") return ownerAlerts.chooseOwnerAlertOption(root, required(alertId, "alert id"), { by, option: required(words(tail, "--option"), "--option") as "authorize-safe-fallback" | "provide-cross-project-decision" | "hold-or-resolve" });
147
+ if (subcommand === "resolve") return ownerAlerts.resolveOwnerAlert(root, required(alertId, "alert id"), { by, reason: required(words(tail, "--reason"), "--reason") });
148
+ throw new Error("Use `atdd-flow owner-alert scan|deliver|acknowledge|execute|resolve ...`.");
149
+ },
131
150
  thread: async () => {
132
151
  const [subcommand, ...tail] = rest;
133
152
  if (subcommand === "start") return startThread(root, tail);
@@ -0,0 +1,123 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { atomicYaml, exists, now as workflowNow, paths, readYaml, required, seat } from "./core";
5
+ import { type Task } from "./tasks";
6
+ import { post, startThread } from "./threads";
7
+
8
+ type Option = "authorize-safe-fallback" | "provide-cross-project-decision" | "hold-or-resolve";
9
+ type Alert = {
10
+ schema: "atdd-workflow/owner-alert/v1"; id: string; incident: string; fingerprint: string;
11
+ state: "blocked" | "waiting" | "resolved" | "superseded";
12
+ material_class: "cross-project" | "toolkit" | "authorization"; affected_tasks: string[]; evidence: string;
13
+ fallback: { state: "absent"; evidence: string; safe_automatic_action: "none" };
14
+ owner_options: { id: Option; text: string }[]; prohibited_actions: string[];
15
+ delivery: { state: "persisted" | "sent" | "delivered" | "acknowledged" | "executing" | "owner_unavailable"; persisted_at: string; sent_at?: string; delivered_at?: string; acknowledged_at?: string; executing_at?: string; unavailable_reason?: string };
16
+ notifications: { at: string; thread?: string; message?: string; kind: "initial" | "reminder" }[]; thread?: string;
17
+ owner_decision?: { option: Option; by: "operator@desk"; at: string }; resolution?: { by: string; reason: string; at: string }; superseded_by?: string;
18
+ };
19
+ type Candidate = Omit<Alert, "id" | "incident" | "fingerprint" | "delivery" | "notifications" | "thread">;
20
+ export type OwnerAlertScan = { created: string[]; sent: string[]; renotified: string[]; unavailable: string[]; ignored: string[] };
21
+
22
+ const directory = (root: string) => join(root, ".atdd-flow", "owner-alerts");
23
+ export const ownerAlertFile = (root: string, id: string) => join(directory(root), `${id}.yaml`);
24
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex");
25
+ const time = (value?: string) => value ?? workflowNow();
26
+ const options: { id: Option; text: string }[] = [
27
+ { id: "authorize-safe-fallback", text: "Authorize one named safe fallback; Flow will not infer that authorization." },
28
+ { id: "provide-cross-project-decision", text: "Provide the named cross-project/toolkit/authorization decision or owner." },
29
+ { id: "hold-or-resolve", text: "Hold the work explicitly or confirm the blocker is resolved." },
30
+ ];
31
+
32
+ /** Redaction occurs before blocker text enters an alert key, record, or durable mail. */
33
+ export function redactOwnerEvidence(value: string) {
34
+ return value.replace(/\b(token|password|secret|api[-_ ]?key|authorization)\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1$2[REDACTED]")
35
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
36
+ }
37
+ function classification(value: string) {
38
+ if (/\bcross[- ]project\b/i.test(value)) return "cross-project" as const;
39
+ if (/\btoolkit\b/i.test(value)) return "toolkit" as const;
40
+ if (/\bauthorization\b|\bauthori[sz](?:e|ation)\b/i.test(value)) return "authorization" as const;
41
+ }
42
+ async function projects(root: string) {
43
+ let names: string[]; try { names = await readdir(paths(root).work); } catch { return []; }
44
+ const found = await Promise.all(names.map(async (name) => ({ name, valid: await exists(paths(root).projectFile(name)) })));
45
+ return found.filter((entry) => entry.valid).map((entry) => entry.name);
46
+ }
47
+ async function tasks(root: string) {
48
+ const result: { project: string; id: string; task: Task }[] = [];
49
+ for (const project of await projects(root)) {
50
+ let files: string[]; try { files = await readdir(paths(root).tasks(project)); } catch { continue; }
51
+ for (const file of files.sort()) if (file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")) result.push({ project, id: file.slice(0, -5), task: await readYaml<Task>(join(paths(root).tasks(project), file)) });
52
+ }
53
+ return result;
54
+ }
55
+ async function alerts(root: string) {
56
+ let files: string[]; try { files = await readdir(directory(root)); } catch { return [] as Alert[]; }
57
+ return Promise.all(files.filter((file) => file.endsWith(".yaml")).sort().map((file) => readYaml<Alert>(join(directory(root), file))));
58
+ }
59
+ async function escalator(root: string, address: string) {
60
+ const actor = await seat(root, address);
61
+ if (actor.role !== "coordinator" && actor.role !== "main") throw new Error("Only a coordinator or main seat may scan or resolve owner alerts.");
62
+ return actor.address;
63
+ }
64
+ async function ownerAvailable(root: string) { try { return (await seat(root, "operator@desk")).role === "operator"; } catch { return false; } }
65
+ async function independent(root: string, entry: { project: string; id: string }, all: Awaited<ReturnType<typeof tasks>>) {
66
+ for (const other of all) {
67
+ if (other.project !== entry.project || other.id === entry.id || other.task.status !== "in_progress" || other.task.blocker || !other.task.assignee) continue;
68
+ try { if ((await readYaml<{ status?: string }>(paths(root).checkpointFile(other.task.assignee))).status === "active") return true; } catch { /* absent checkpoint is not visible execution */ }
69
+ }
70
+ return false;
71
+ }
72
+ function candidate(entry: { project: string; id: string; task: Task }): Candidate | undefined {
73
+ if (entry.task.status === "done" || !entry.task.blocker) return;
74
+ const material = classification(entry.task.blocker); if (!material) return;
75
+ return { schema: "atdd-workflow/owner-alert/v1", state: "blocked", material_class: material, affected_tasks: [`${entry.project}/${entry.id}`], evidence: redactOwnerEvidence(entry.task.blocker),
76
+ fallback: { state: "absent", evidence: "No independent checkpoint-active task is visibly executing in the affected project.", safe_automatic_action: "none" }, owner_options: options,
77
+ prohibited_actions: ["Do not infer authorization.", "Do not execute an external action.", "Do not expose secrets."] };
78
+ }
79
+ function incident(value: Candidate) { return hash(`${value.material_class}\0${value.affected_tasks.join("\0")}`).slice(0, 16); }
80
+ function fingerprint(value: Candidate) { return hash(JSON.stringify({ state: value.state, evidence: value.evidence, fallback: value.fallback })); }
81
+ async function coordinatorFor(root: string, record: Alert) {
82
+ return required((await tasks(root)).find((entry) => record.affected_tasks.includes(`${entry.project}/${entry.id}`))?.task.coordinator, "alert coordinator");
83
+ }
84
+ async function send(root: string, record: Alert, kind: "initial" | "reminder", timestamp: string) {
85
+ const coordinator = await coordinatorFor(root, record);
86
+ const thread = record.thread ?? await startThread(root, ["--with", `operator@desk,${coordinator}`, "--subject", `Owner alert ${record.id}`]);
87
+ if (!record.thread) { record.thread = thread; await atomicYaml(ownerAlertFile(root, record.id), record); }
88
+ const message = await post(root, thread, ["--from", coordinator, "--to", "operator@desk", "--label", "material stall owner alert", "--expects-result", "--body",
89
+ `OWNER ALERT ${record.id}\nstate: ${record.state}\nclass: ${record.material_class}\naffected: ${record.affected_tasks.join(", ")}\nevidence: ${record.evidence}\nfallback: ${record.fallback.evidence}\nsafe automatic action: ${record.fallback.safe_automatic_action}\noptions: ${record.owner_options.map((option) => `${option.id} (${option.text})`).join("; ")}\nprohibited: ${record.prohibited_actions.join(" ")}`]);
90
+ record.thread = thread; record.delivery.state = "sent"; record.delivery.sent_at = timestamp; record.notifications.push({ at: timestamp, thread, message, kind });
91
+ }
92
+
93
+ /** An explicit bounded observation, never a daemon, executor, or authorization proxy. */
94
+ export async function scanOwnerAlerts(root: string, input: { by: string; re_notify_after_ms?: number; now?: string }): Promise<OwnerAlertScan> {
95
+ await escalator(root, input.by); const timestamp = time(input.now); const result: OwnerAlertScan = { created: [], sent: [], renotified: [], unavailable: [], ignored: [] };
96
+ const all = await tasks(root); const stored = await alerts(root);
97
+ for (const entry of all) {
98
+ const next = candidate(entry);
99
+ if (!next || await independent(root, entry, all)) { result.ignored.push(`${entry.project}/${entry.id}`); continue; }
100
+ const key = incident(next); const digest = fingerprint(next); const active = stored.find((record) => record.incident === key && record.state !== "resolved" && record.state !== "superseded");
101
+ if (active && active.fingerprint !== digest) { active.state = "superseded"; active.superseded_by = `O-${key}-${digest.slice(0, 8)}`; await atomicYaml(ownerAlertFile(root, active.id), active); }
102
+ const exact = stored.find((record) => record.incident === key && record.fingerprint === digest);
103
+ if (exact && exact.state !== "superseded") {
104
+ if (exact.state === "resolved") { result.ignored.push(`${entry.project}/${entry.id}`); continue; }
105
+ const last = exact.notifications.at(-1)?.at; const delay = input.re_notify_after_ms;
106
+ if (exact.delivery.state === "sent" && delay !== undefined && delay >= 0 && last && Date.parse(timestamp) - Date.parse(last) >= delay) { await send(root, exact, "reminder", timestamp); await atomicYaml(ownerAlertFile(root, exact.id), exact); result.renotified.push(exact.id); }
107
+ else result.ignored.push(`${entry.project}/${entry.id}`);
108
+ continue;
109
+ }
110
+ const id = `O-${key}-${digest.slice(0, 8)}`; const record: Alert = { ...next, id, incident: key, fingerprint: digest, delivery: { state: "persisted", persisted_at: timestamp }, notifications: [] };
111
+ await atomicYaml(ownerAlertFile(root, id), record); result.created.push(id);
112
+ if (!await ownerAvailable(root)) { record.delivery.state = "owner_unavailable"; record.delivery.unavailable_reason = "operator@desk is absent or is not an operator seat."; await atomicYaml(ownerAlertFile(root, id), record); result.unavailable.push(id); continue; }
113
+ try { await send(root, record, "initial", timestamp); await atomicYaml(ownerAlertFile(root, id), record); result.sent.push(id); }
114
+ catch (error) { record.delivery.state = "persisted"; record.delivery.unavailable_reason = `Owner delivery path failed closed: ${(error as Error).message}`; await atomicYaml(ownerAlertFile(root, id), record); result.unavailable.push(id); }
115
+ }
116
+ return result;
117
+ }
118
+ async function read(root: string, id: string) { return readYaml<Alert>(ownerAlertFile(root, id)); }
119
+ async function owner(root: string, address: string) { if (address !== "operator@desk" || !await ownerAvailable(root)) throw new Error("Only the available operator@desk seat may update owner delivery state."); }
120
+ export async function deliverOwnerAlert(root: string, id: string, input: { by: string; now?: string }) { await owner(root, input.by); const record = await read(root, id); if (record.delivery.state !== "sent") throw new Error(`Owner alert ${id} is not awaiting delivery.`); record.delivery.state = "delivered"; record.delivery.delivered_at = time(input.now); await atomicYaml(ownerAlertFile(root, id), record); }
121
+ export async function acknowledgeOwnerAlert(root: string, id: string, input: { by: string; note: string; now?: string }) { await owner(root, input.by); required(input.note, "owner acknowledgement note"); const record = await read(root, id); if (record.delivery.state !== "delivered") throw new Error(`Owner alert ${id} must be delivered before acknowledgement.`); record.delivery.state = "acknowledged"; record.delivery.acknowledged_at = time(input.now); await atomicYaml(ownerAlertFile(root, id), record); }
122
+ export async function chooseOwnerAlertOption(root: string, id: string, input: { by: string; option: Option; now?: string }) { await owner(root, input.by); if (!options.some((entry) => entry.id === input.option)) throw new Error("Unknown owner alert option."); const record = await read(root, id); if (record.delivery.state !== "acknowledged") throw new Error(`Owner alert ${id} must be acknowledged before selecting an option.`); record.delivery.state = "executing"; record.delivery.executing_at = time(input.now); record.owner_decision = { option: input.option, by: "operator@desk", at: record.delivery.executing_at }; await atomicYaml(ownerAlertFile(root, id), record); }
123
+ export async function resolveOwnerAlert(root: string, id: string, input: { by: string; reason: string; now?: string }) { const by = await escalator(root, input.by); const record = await read(root, id); if (record.state === "resolved" || record.state === "superseded") throw new Error(`Owner alert ${id} is already terminal.`); record.state = "resolved"; record.resolution = { by, reason: required(input.reason, "resolution reason"), at: time(input.now) }; await atomicYaml(ownerAlertFile(root, id), record); }
package/src/tasks.ts CHANGED
@@ -12,6 +12,16 @@ export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assi
12
12
  export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string };
13
13
  export type DoneWhen = { text: string; proof?: string };
14
14
  export type GovernedBase = { coordinator: string; branch: string; commit: string };
15
+ /** An append-only accountability decision; prior coordinator evidence remains untouched. */
16
+ export type CoordinatorTransfer = {
17
+ from: string;
18
+ to: string;
19
+ reason: string;
20
+ /** Required when main executes the transfer; references an operator@desk message. */
21
+ authorization?: string;
22
+ exact_head: { branch: string; commit: string };
23
+ effective_at: string;
24
+ };
15
25
  export type Task = {
16
26
  schema: "atdd-workflow/task/v1";
17
27
  title: string;
@@ -28,6 +38,8 @@ export type Task = {
28
38
  handoff?: TaskHandoff;
29
39
  /** Exact named-coordinator or main head from which an assigned driver is governed. */
30
40
  governed_base?: GovernedBase;
41
+ /** Absent on legacy tasks; later entries never rewrite earlier coordinator evidence. */
42
+ coordinator_transfers?: CoordinatorTransfer[];
31
43
  done_when: DoneWhen[];
32
44
  };
33
45
 
@@ -40,9 +52,29 @@ const transitions: Record<TaskStatus, TaskStatus[]> = {
40
52
  done: [],
41
53
  };
42
54
 
55
+ function assertCoordinatorIntegrity(task: Task, id: string) {
56
+ const transfers = task.coordinator_transfers;
57
+ if (transfers && !Array.isArray(transfers)) throw new Error(`Task ${id} has invalid immutable transfer provenance.`);
58
+ const latest = transfers?.at(-1);
59
+ if (latest) {
60
+ if (!latest.from || !latest.to || !latest.reason || !latest.effective_at || !latest.exact_head?.branch || !latest.exact_head.commit) {
61
+ throw new Error(`Task ${id} has invalid immutable transfer provenance.`);
62
+ }
63
+ if (task.coordinator !== latest.to || !task.governed_base
64
+ || task.governed_base.coordinator !== latest.to
65
+ || task.governed_base.branch !== latest.exact_head.branch
66
+ || task.governed_base.commit !== latest.exact_head.commit) {
67
+ throw new Error(`Task ${id} coordinator is not backed by immutable transfer provenance.`);
68
+ }
69
+ } else if (task.governed_base && task.governed_base.coordinator !== task.coordinator) {
70
+ throw new Error(`Task ${id} coordinator is not backed by immutable transfer provenance.`);
71
+ }
72
+ }
73
+
43
74
  async function readTask(root: string, projectName: string, id: string) {
44
75
  const record = await readYaml<Task>(paths(root).taskFile(projectName, id));
45
76
  if (record.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
77
+ assertCoordinatorIntegrity(record, id);
46
78
  return record;
47
79
  }
48
80
 
@@ -182,6 +214,92 @@ export async function assign(root: string, projectName: string, id: string, args
182
214
  console.log(`${id} assigned ${assignee}`);
183
215
  }
184
216
 
217
+ type TransferAuthorization = {
218
+ schema: "atdd-workflow/task-transfer-authorization/v1";
219
+ project: string;
220
+ task: string;
221
+ from: string;
222
+ to: string;
223
+ reason: string;
224
+ exact_head: { branch: string; commit: string };
225
+ };
226
+
227
+ async function operatorAuthorization(root: string, reference: string, main: string, expected: TransferAuthorization) {
228
+ type AuthorizationMessage = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: TransferAuthorization };
229
+ type AuthorizationThread = { schema?: string; participants?: string[] };
230
+ const folders = await readdir(paths(root).threads, { withFileTypes: true });
231
+ const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
232
+ .map(async (entry) => {
233
+ try {
234
+ const [thread, message] = await Promise.all([
235
+ readYaml<AuthorizationThread>(paths(root).threadFile(entry.name)),
236
+ readYaml<AuthorizationMessage>(paths(root).message(entry.name, reference)),
237
+ ]);
238
+ return { thread, message };
239
+ } catch { return undefined; }
240
+ }))).filter((entry): entry is NonNullable<typeof entry> =>
241
+ entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
242
+ && entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
243
+ if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
244
+ throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
245
+ }
246
+ if (matches[0].message.to !== "all" && !matches[0].message.to?.includes(main)) {
247
+ throw new Error(`Authorization ${reference} is not applicable to ${main}.`);
248
+ }
249
+ const authorization = matches[0].message.authorization;
250
+ if (!authorization || authorization.schema !== expected.schema
251
+ || authorization.project !== expected.project || authorization.task !== expected.task
252
+ || authorization.from !== expected.from || authorization.to !== expected.to
253
+ || authorization.reason !== expected.reason
254
+ || authorization.exact_head?.branch !== expected.exact_head.branch
255
+ || authorization.exact_head.commit !== expected.exact_head.commit) {
256
+ throw new Error(`Authorization ${reference} is not an exact transfer authorization.`);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Changes only the current task accountability projection. The record is
262
+ * append-only and validates the target's existing topology; it never creates,
263
+ * moves, cleans, or binds a seat/worktree.
264
+ */
265
+ export async function transfer(root: string, projectName: string, id: string, args: string[]) {
266
+ const task = await readTask(root, projectName, taskId(id));
267
+ const config = await project(root, projectName);
268
+ const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
269
+ const target = await canonicalAddress(root, required(words(args, "--to"), "--to"));
270
+ const main = `main@${config.project}`;
271
+ if (target === task.coordinator) throw new Error(`Task ${id} is already accountable to ${target}.`);
272
+ if (actor !== main && actor !== task.coordinator) throw new Error(`Only ${main} or the current coordinator may transfer task ${id}.`);
273
+
274
+ // governedBase confirms main/named-coordinator identity, exact branch head,
275
+ // and the driver's descendant relationship before accountability changes.
276
+ const base = await governedBase(root, projectName, target, task.assignee);
277
+ if (!base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
278
+ const authorization = actor === main ? required(words(args, "--authorization"), "an owner-authorized topology plan") : undefined;
279
+ if (authorization) await operatorAuthorization(root, authorization, main, {
280
+ schema: "atdd-workflow/task-transfer-authorization/v1",
281
+ project: config.project,
282
+ task: id,
283
+ from: task.coordinator,
284
+ to: target,
285
+ reason: required(words(args, "--reason"), "--reason"),
286
+ exact_head: { branch: base.branch, commit: base.commit },
287
+ });
288
+ const transfer: CoordinatorTransfer = {
289
+ from: task.coordinator,
290
+ to: target,
291
+ reason: required(words(args, "--reason"), "--reason"),
292
+ ...(authorization ? { authorization } : {}),
293
+ exact_head: { branch: base.branch, commit: base.commit },
294
+ effective_at: now(),
295
+ };
296
+ task.coordinator = target;
297
+ task.governed_base = base;
298
+ task.coordinator_transfers = [...(task.coordinator_transfers ?? []), transfer];
299
+ await writeTask(root, projectName, taskId(id), task);
300
+ console.log(`${id} coordinator transferred ${transfer.from} -> ${transfer.to}`);
301
+ }
302
+
185
303
  export async function amend(root: string, projectName: string, id: string, args: string[]) {
186
304
  const task = await readTask(root, projectName, taskId(id));
187
305
  const title = words(args, "--title");
package/src/threads.ts CHANGED
@@ -8,6 +8,15 @@ import {
8
8
 
9
9
  export type Thread = { schema: string; id: string; subject: string; participants: string[]; state: "open" | "closed"; summary?: string; task?: string };
10
10
  export type Kind = "message" | "receipt" | "result";
11
+ export type TransferAuthorization = {
12
+ schema: "atdd-workflow/task-transfer-authorization/v1";
13
+ project: string;
14
+ task: string;
15
+ from: string;
16
+ to: string;
17
+ reason: string;
18
+ exact_head: { branch: string; commit: string };
19
+ };
11
20
  export type Message = {
12
21
  schema: string;
13
22
  id: string;
@@ -18,6 +27,8 @@ export type Message = {
18
27
  expects_result?: boolean;
19
28
  created_at: string;
20
29
  body: string;
30
+ /** Immutable owner authorization for one exact coordinator-transfer tuple. */
31
+ authorization?: TransferAuthorization;
21
32
  };
22
33
 
23
34
  async function thread(root: string, threadId: string) {
@@ -212,6 +223,21 @@ async function inject(root: string, address: string, message: Message, threadId:
212
223
  catch (error) { console.warn(`Notification for ${address} was not delivered: ${(error as Error).message}`); }
213
224
  }
214
225
 
226
+ function transferAuthorization(args: string[], from: string) {
227
+ const encoded = words(args, "--task-transfer-authorization");
228
+ if (!encoded) return undefined;
229
+ if (from !== "operator@desk") throw new Error("Only operator@desk may create a task transfer authorization.");
230
+ let authorization: unknown;
231
+ try { authorization = JSON.parse(encoded); }
232
+ catch { throw new Error("--task-transfer-authorization must be valid JSON."); }
233
+ const value = authorization as Partial<TransferAuthorization>;
234
+ if (!value || value.schema !== "atdd-workflow/task-transfer-authorization/v1"
235
+ || ![value.project, value.task, value.from, value.to, value.reason, value.exact_head?.branch, value.exact_head?.commit].every((entry) => typeof entry === "string" && entry.length)) {
236
+ throw new Error("--task-transfer-authorization must contain one exact task-transfer-authorization/v1 tuple.");
237
+ }
238
+ return value as TransferAuthorization;
239
+ }
240
+
215
241
  async function post(root: string, threadId: string, args: string[], overrides: Partial<Message> = {}) {
216
242
  const record = await thread(root, threadId);
217
243
  const from = await canonicalAddress(root, required(overrides.from ?? words(args, "--from"), "--from"));
@@ -221,11 +247,13 @@ async function post(root: string, threadId: string, args: string[], overrides: P
221
247
  if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
222
248
  await assertRoute(root, from, recipients.filter((address) => address !== from));
223
249
  const kind = overrides.kind ?? "message";
250
+ const authorization = overrides.authorization ?? transferAuthorization(args, from);
224
251
  // Labels are an explicit, non-sensitive operator hint; message bodies never enter durable IDs.
225
252
  const label = words(args, "--label") ?? kind;
226
253
  const message: Message = {
227
254
  schema: "atdd-workflow/message/v1", id: id("M", label), from, to: toValue === "all" ? "all" : recipients,
228
255
  kind,
256
+ ...(authorization ? { authorization } : {}),
229
257
  ...(overrides.in_reply_to ? { in_reply_to: overrides.in_reply_to } : {}),
230
258
  ...(overrides.expects_result || has(args, "--expects-result") ? { expects_result: true } : {}),
231
259
  created_at: now(), body: required(overrides.body ?? words(args, "--body"), "--body"),
@@ -236,6 +264,7 @@ async function post(root: string, threadId: string, args: string[], overrides: P
236
264
  await Promise.all(prepared.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)).map((entry) => publishNativeMail(root, entry.address, entry.segment, message.id)));
237
265
  await Promise.all(recipients.filter((address) => address !== from).map((recipient) => inject(root, recipient, message, threadId, prepared.some((entry) => entry?.address === recipient))));
238
266
  console.log(message.id);
267
+ return message.id;
239
268
  }
240
269
 
241
270
  export async function startThread(root: string, args: string[]) {
@@ -249,6 +278,7 @@ export async function startThread(root: string, args: string[]) {
249
278
  };
250
279
  await atomicYaml(paths(root).threadFile(record.id), record);
251
280
  console.log(record.id);
281
+ return record.id;
252
282
  }
253
283
 
254
284
  export async function addParticipant(root: string, threadId: string, address: string) {