@afokapu/atdd-flow 0.3.2 → 0.3.4

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. 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. 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.2",
3
+ "version": "0.3.4",
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";
@@ -27,6 +28,7 @@ Usage:
27
28
  atdd-flow describe <address> --purpose <one-line responsibility>
28
29
  atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
29
30
  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> ...]
31
+ atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
30
32
  atdd-flow task amend <project> <task-id> [--title <text>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>]
31
33
  atdd-flow task import <project> <task-id> --proof <reference> [--proof <reference> ...] [--done-when <text> ...] [--source <reference>]
32
34
  atdd-flow task start|review|return <project> <task-id> --by <address>
@@ -48,6 +50,7 @@ Usage:
48
50
  atdd-flow behavioral-review record <project> <task-id> --by <reviewer-address> --file <result-yaml>
49
51
  atdd-flow behavioral-review open <project> <task-id>
50
52
  atdd-flow open <address>
53
+ atdd-flow address-book
51
54
 
52
55
  Global:
53
56
  atdd-flow --root <desk-directory> <command>
@@ -88,6 +91,7 @@ async function main() {
88
91
  task: async () => {
89
92
  const [subcommand, projectName, taskId, ...tail] = rest;
90
93
  if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
94
+ if (subcommand === "assign") return tasks.assign(root, required(projectName, "project"), required(taskId, "task id"), tail);
91
95
  if (subcommand === "amend") return tasks.amend(root, required(projectName, "project"), required(taskId, "task id"), tail);
92
96
  if (subcommand === "import") return tasks.importCompleted(root, required(projectName, "project"), required(taskId, "task id"), tail);
93
97
  if (subcommand === "start") return tasks.start(root, required(projectName, "project"), required(taskId, "task id"), tail);
@@ -98,7 +102,7 @@ async function main() {
98
102
  if (subcommand === "block") return tasks.block(root, required(projectName, "project"), required(taskId, "task id"), tail);
99
103
  if (subcommand === "list") return tasks.list(root, required(projectName, "project"), rest.slice(2));
100
104
  if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
101
- throw new Error("Use `atdd-flow task add|amend|import|start|prove|review|return|done|block|list|open`.");
105
+ throw new Error("Use `atdd-flow task add|assign|amend|import|start|prove|review|return|done|block|list|open`.");
102
106
  },
103
107
  thread: async () => {
104
108
  const [subcommand, ...tail] = rest;
@@ -130,6 +134,7 @@ async function main() {
130
134
  throw new Error("Use `atdd-flow behavioral-review launch|record|open <project> <task-id>`.");
131
135
  },
132
136
  open: () => openSeat(root, required(rest[0], "address")),
137
+ "address-book": () => addressBook(root),
133
138
  };
134
139
  const action = commands[command];
135
140
  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
@@ -116,6 +116,19 @@ export async function add(root: string, projectName: string, id: string, args: s
116
116
  console.log(id);
117
117
  }
118
118
 
119
+ export async function assign(root: string, projectName: string, id: string, args: string[]) {
120
+ const task = await readTask(root, projectName, taskId(id));
121
+ const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
122
+ if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may assign task ${id}.`);
123
+ if (task.status !== "todo") throw new Error(`Task ${id} can only be assigned while todo.`);
124
+ if (task.assignee) throw new Error(`Task ${id} is already assigned to ${task.assignee}.`);
125
+ const assignee = await canonicalAddress(root, required(words(args, "--assignee"), "--assignee"));
126
+ await seat(root, assignee);
127
+ task.assignee = assignee;
128
+ await writeTask(root, projectName, id, task);
129
+ console.log(`${id} assigned ${assignee}`);
130
+ }
131
+
119
132
  export async function amend(root: string, projectName: string, id: string, args: string[]) {
120
133
  const task = await readTask(root, projectName, taskId(id));
121
134
  const title = words(args, "--title");
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) {