@afokapu/atdd-flow 0.3.3 → 0.3.5

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,7 +130,7 @@ atdd-flow behavioral-review launch resolver-os runtime-rollout --by coordinator@
130
130
  atdd-flow task done resolver-os runtime-rollout --by coordinator@resolver-os
131
131
  ```
132
132
 
133
- 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. 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.
133
+ 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.
134
134
 
135
135
  ## Communicate and hand over
136
136
 
@@ -62,6 +62,13 @@ 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
+ `operator@desk` is the human authority, not an autonomous agent identity. Never post, approve,
66
+ or make a decision while claiming that address unless the human operator actually supplied it.
67
+ Main seats are the normal repository-main integration gateway to the operator. Coordinators may
68
+ escalate a concrete decision or incident directly to the operator. Drivers send normal work
69
+ messages to their coordinator; they do not directly message the operator or main seat. A receipt
70
+ or result addresses the seat that made the request, not every historical participant in the thread.
71
+
65
72
  A driver starts only its assigned ready task. Treat the task's done_when criteria as the required
66
73
  delivery scope. Prefer existing repository patterns, components, dependencies, contracts, and
67
74
  abstractions. When multiple implementations are correct, choose the smallest effective change that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,36 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { paths, readYaml, type Seat } from "./core";
3
+
4
+ function identity(address: string) {
5
+ return address === "operator@desk" ? "human" : "agent";
6
+ }
7
+
8
+ function authority(record: Seat) {
9
+ if (record.address === "operator@desk") return "Human authority; no autonomous agent may post as this address.";
10
+ if (record.role === "main") return "Repository-main integration gateway; may escalate to operator@desk.";
11
+ if (record.role === "coordinator") return "Coordinates assigned work; may escalate a decision or incident to operator@desk.";
12
+ if (record.role === "driver") return "Delivers assigned work; routes normal communication through its coordinator.";
13
+ return "See the seat purpose and current assignment.";
14
+ }
15
+
16
+ export async function addressBook(root: string) {
17
+ const projects = (await readdir(paths(root).work)).sort();
18
+ const records: Seat[] = [];
19
+ for (const project of projects) {
20
+ const folder = paths(root).seats(project);
21
+ let locals: string[];
22
+ try { locals = await readdir(folder); }
23
+ catch { continue; }
24
+ for (const local of locals.sort()) {
25
+ try { records.push(await readYaml<Seat>(`${folder}/${local}/seat.yaml`)); }
26
+ catch { /* A partially removed legacy seat is not an address-book entry. */ }
27
+ }
28
+ }
29
+ console.log("# Desk address book\n");
30
+ console.log("`operator@desk` is the human authority. Drivers route normal work through coordinators; main seats are the normal technical gateway to the operator.\n");
31
+ console.log("| Address | Identity | Purpose / authority |\n| --- | --- | --- |");
32
+ for (const record of records.sort((left, right) => left.address.localeCompare(right.address))) {
33
+ const purpose = (record.purpose ?? authority(record)).replaceAll("|", "\\|");
34
+ console.log(`| ${record.address} | ${identity(record.address)} | ${purpose} |`);
35
+ }
36
+ }
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { init, initProject, spawn, bind, useApplication, attach, launch, describ
5
5
  import { addParticipant, openThread, post, receipt, result, startThread } from "./threads";
6
6
  import * as tasks from "./tasks";
7
7
  import { required, values, words } from "./core";
8
+ import { addressBook } from "./address-book";
8
9
  import * as judgment from "./judgment";
9
10
  import * as reviews from "./reviews";
10
11
  import { status } from "./overview";
@@ -34,6 +35,7 @@ Usage:
34
35
  atdd-flow task done <project> <task-id> --by <address> [--retire-assignee]
35
36
  atdd-flow task prove <project> <task-id> --by <address> --item <number> --proof <reference>
36
37
  atdd-flow task block <project> <task-id> --by <address> --reason <text>
38
+ atdd-flow task unblock <project> <task-id> --by <coordinator-address>
37
39
  atdd-flow task list <project> [--coordinator <address>] [--assignee <address>]
38
40
  atdd-flow task open <project> <task-id>
39
41
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
@@ -49,6 +51,7 @@ Usage:
49
51
  atdd-flow behavioral-review record <project> <task-id> --by <reviewer-address> --file <result-yaml>
50
52
  atdd-flow behavioral-review open <project> <task-id>
51
53
  atdd-flow open <address>
54
+ atdd-flow address-book
52
55
 
53
56
  Global:
54
57
  atdd-flow --root <desk-directory> <command>
@@ -98,9 +101,10 @@ async function main() {
98
101
  if (subcommand === "return") return tasks.returnToWork(root, required(projectName, "project"), required(taskId, "task id"), tail);
99
102
  if (subcommand === "prove") return tasks.prove(root, required(projectName, "project"), required(taskId, "task id"), tail);
100
103
  if (subcommand === "block") return tasks.block(root, required(projectName, "project"), required(taskId, "task id"), tail);
104
+ if (subcommand === "unblock") return tasks.unblock(root, required(projectName, "project"), required(taskId, "task id"), tail);
101
105
  if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
102
106
  if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
103
- throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|list|open`.");
107
+ throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|unblock|list|open`.");
104
108
  },
105
109
  thread: async () => {
106
110
  const [subcommand, ...tail] = rest;
@@ -132,6 +136,7 @@ async function main() {
132
136
  throw new Error("Use `atdd-flow behavioral-review launch|record|open <project> <task-id>`.");
133
137
  },
134
138
  open: () => openSeat(root, required(rest[0], "address")),
139
+ "address-book": () => addressBook(root),
135
140
  };
136
141
  const action = commands[command];
137
142
  if (!action) throw new Error(`Unknown command: ${command}`);
package/src/seats.ts CHANGED
@@ -179,7 +179,7 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
179
179
  }
180
180
 
181
181
  export const launchNotice = (address: string) =>
182
- `SYSTEM: you are ${address}. Read your durable seat and assigned task with: atdd-flow open ${address}. Use the installed atdd-flow command; never use bunx to replace it or run atdd-flow init against an existing Desk. If your native pane binding differs from the Desk record, report it to the operator or coordinator. Continue assigned in_progress work until it is review-ready or explicitly blocked.`;
182
+ `SYSTEM: you are ${address}. Read your durable seat and assigned task with: atdd-flow open ${address}. Use the installed atdd-flow command; never use bunx to replace it or run atdd-flow init against an existing Desk. Routing: operator@desk is the human authority; drivers report to coordinators, and main seats are the normal technical gateway to the operator. Continue assigned in_progress work until it is review-ready or explicitly blocked.`;
183
183
 
184
184
  /** Pi loads this extension inside its own process, so it can wake without host text injection. */
185
185
  export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
package/src/tasks.ts CHANGED
@@ -240,6 +240,17 @@ export async function block(root: string, projectName: string, id: string, args:
240
240
  console.log(`${id} blocked`);
241
241
  }
242
242
 
243
+ export async function unblock(root: string, projectName: string, id: string, args: string[]) {
244
+ const task = await readTask(root, projectName, taskId(id));
245
+ const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
246
+ if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may unblock task ${id}.`);
247
+ if (task.status !== "in_progress") throw new Error(`Task ${id} can only be unblocked while in_progress.`);
248
+ if (!task.blocker) throw new Error(`Task ${id} has no blocker to clear.`);
249
+ delete task.blocker;
250
+ await writeTask(root, projectName, id, task);
251
+ console.log(`${id} unblocked`);
252
+ }
253
+
243
254
  export async function list(root: string, projectName: string, args: string[]) {
244
255
  const coordinator = words(args, "--coordinator") ? await canonicalAddress(root, required(words(args, "--coordinator"), "--coordinator")) : undefined;
245
256
  const assignee = words(args, "--assignee") ? await canonicalAddress(root, required(words(args, "--assignee"), "--assignee")) : undefined;
package/src/threads.ts CHANGED
@@ -36,6 +36,7 @@ async function replyTarget(root: string, threadId: string, messageId: string, fr
36
36
  const recipients = target.to === "all" ? record.participants.filter((address) => address !== target.from) : target.to;
37
37
  if (!recipients.includes(from)) throw new Error(`${from} was not a recipient of message ${messageId}.`);
38
38
  if (needsResult && !target.expects_result) throw new Error(`Message ${messageId} does not expect a result.`);
39
+ return target;
39
40
  }
40
41
 
41
42
  async function seatsByRole(root: string, projectName: string, role: string) {
@@ -61,6 +62,18 @@ async function resolveRecipients(root: string, value: string, participants: stri
61
62
  return [...new Set(expanded.flat())];
62
63
  }
63
64
 
65
+ async function assertRoute(root: string, from: string, recipients: string[]) {
66
+ if (from === "operator@desk") return;
67
+ const sender = await seat(root, from);
68
+ const targets = await Promise.all(recipients.map((address) => seat(root, address)));
69
+ if (targets.some((target) => target.address === "operator@desk") && !["main", "coordinator"].includes(sender.role)) {
70
+ throw new Error(`${from} may not directly address operator@desk; route through the responsible coordinator and main seat.`);
71
+ }
72
+ if (sender.role === "driver" && targets.some((target) => target.role !== "coordinator")) {
73
+ throw new Error(`${from} may message only a coordinator; coordinators and main seats handle further escalation.`);
74
+ }
75
+ }
76
+
64
77
  async function inject(root: string, address: string, message: Message, threadId: string) {
65
78
  const target = await seat(root, address);
66
79
  const runtime = target.runtime;
@@ -81,6 +94,7 @@ async function post(root: string, threadId: string, args: string[], overrides: P
81
94
  const toValue = overrides.to ?? words(args, "--to") ?? "all";
82
95
  const recipients = Array.isArray(toValue) ? toValue : await resolveRecipients(root, toValue, record.participants);
83
96
  if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
97
+ await assertRoute(root, from, recipients.filter((address) => address !== from));
84
98
  const message: Message = {
85
99
  schema: "atdd-workflow/message/v1", id: id("M"), from, to: toValue === "all" ? "all" : recipients,
86
100
  kind: overrides.kind ?? "message",
@@ -120,14 +134,14 @@ export async function openThread(root: string, threadId: string) {
120
134
 
121
135
  export async function receipt(root: string, threadId: string, messageId: string, args: string[]) {
122
136
  const from = await canonicalAddress(root, required(words(args, "--from"), "--from"));
123
- await replyTarget(root, threadId, messageId, from, false);
124
- return post(root, threadId, args, { from, kind: "receipt", in_reply_to: messageId, body: words(args, "--body") ?? `Received ${messageId}.` });
137
+ const target = await replyTarget(root, threadId, messageId, from, false);
138
+ return post(root, threadId, args, { from, to: [target.from], kind: "receipt", in_reply_to: messageId, body: words(args, "--body") ?? `Received ${messageId}.` });
125
139
  }
126
140
 
127
141
  export async function result(root: string, threadId: string, messageId: string, args: string[]) {
128
142
  const from = await canonicalAddress(root, required(words(args, "--from"), "--from"));
129
- await replyTarget(root, threadId, messageId, from, true);
130
- return post(root, threadId, args, { from, kind: "result", in_reply_to: messageId, body: required(words(args, "--body"), "--body") });
143
+ const target = await replyTarget(root, threadId, messageId, from, true);
144
+ return post(root, threadId, args, { from, to: [target.from], kind: "result", in_reply_to: messageId, body: required(words(args, "--body"), "--body") });
131
145
  }
132
146
 
133
147
  export async function status(root: string) {