@afokapu/atdd-flow 0.3.5 → 0.3.7

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
@@ -178,7 +178,15 @@ runtime:
178
178
  wake: native
179
179
  ```
180
180
 
181
- `application` identifies the visible pane. `wake: native` tells Flow not to also send a host prompt; the Pi extension reads `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, observes final `M-*.yaml` files, filters recipients, and queues a follow-up turn that tells Pi which durable file to read. Other agents keep `wake: host` and use their normal host notification adapter.
181
+ `application` identifies the visible pane. `wake: native` tells Flow not to also send a host prompt; the Pi extension reads `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, observes final `M-*.yaml` files, filters recipients, and queues a compact follow-up containing the thread subject, sender, recipients, thread/message IDs, and `atdd-flow message read <message-id>`. It never embeds the message body. Other agents keep `wake: host` and receive the same compact notification through their normal host adapter.
182
+
183
+ Read one durable message without loading its complete thread:
184
+
185
+ ```sh
186
+ atdd-flow message read M-...
187
+ ```
188
+
189
+ The output includes only the message and its thread ID/subject; Flow rejects missing or ambiguous message IDs.
182
190
 
183
191
  Pi’s native wake-up is intentionally lightweight: no daemon, duplicate mailbox, or separate extension installation. The extension is shipped inside the Flow package. Its in-memory message-id guard tolerates duplicate filesystem events; the Desk thread files remain the source of truth.
184
192
 
@@ -4,8 +4,8 @@ import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
 
7
- type Thread = { participants?: unknown };
8
- type Mail = { id?: unknown; from?: unknown; to?: unknown };
7
+ type Thread = { participants?: unknown; subject?: unknown };
8
+ type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown };
9
9
 
10
10
  const finalMail = /^M-[^.]+\.yaml$/;
11
11
 
@@ -21,6 +21,15 @@ export function addressedTo(mail: Mail, thread: Thread, seat: string) {
21
21
  return mail.to === "all" && participants(thread).includes(seat);
22
22
  }
23
23
 
24
+ /** A compact wake notice; the durable message body is read only on demand. */
25
+ export function mailNotice(threadId: string, mail: Mail) {
26
+ const id = typeof mail.id === "string" ? mail.id : "unknown";
27
+ const subject = typeof mail.subject === "string" ? mail.subject : undefined;
28
+ const from = typeof mail.from === "string" ? mail.from : "unknown";
29
+ const recipients = mail.to === "all" ? "all" : Array.isArray(mail.to) ? mail.to.join(", ") : "unknown";
30
+ return `SYSTEM: Flow mail ${id} | thread ${threadId}${subject ? ` (${subject})` : ""} | ${from} → ${recipients}. Read: atdd-flow message read ${id}`;
31
+ }
32
+
24
33
  /**
25
34
  * A Pi-native wake adapter. The Desk remains authoritative: this extension
26
35
  * only observes final immutable mail files and tells Pi where to read them.
@@ -55,7 +64,7 @@ export default function (pi: ExtensionAPI) {
55
64
  delivered.add(key);
56
65
  pi.sendMessage({
57
66
  customType: "atdd-flow-mail",
58
- content: `SYSTEM: new Flow mail ${id}. Read ${file}`,
67
+ content: mailNotice(threadId, { ...mail, id, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
59
68
  display: true,
60
69
  details: { thread: threadId, message: id, path: file },
61
70
  }, { triggerTurn: true, deliverAs: "followUp" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "bun": ">=1.3.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@afokapu/atdd-bun": "^0.10.44"
43
+ "@afokapu/atdd-bun": "^0.10.50"
44
44
  },
45
45
  "dependencies": {
46
46
  "@typesafe-ai/sdk": "^0.6.0",
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { basename, resolve } from "node:path";
4
4
  import { init, initProject, spawn, bind, useApplication, attach, launch, describe, checkpoint, migrate, openSeat, piExtensionPath } from "./seats";
5
- import { addParticipant, openThread, post, receipt, result, startThread } from "./threads";
5
+ import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
6
6
  import * as tasks from "./tasks";
7
7
  import { required, values, words } from "./core";
8
8
  import { addressBook } from "./address-book";
@@ -41,6 +41,7 @@ Usage:
41
41
  atdd-flow thread start --with <address,...> --subject <text> [--task <project/task-id>]
42
42
  atdd-flow thread add <thread-id> <address>
43
43
  atdd-flow thread open <thread-id>
44
+ atdd-flow message read <message-id>
44
45
  atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--expects-result]
45
46
  atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>]
46
47
  atdd-flow result <thread-id> <message-id> --from <address> --body <text>
@@ -113,6 +114,10 @@ async function main() {
113
114
  if (subcommand === "open") return openThread(root, required(tail[0], "thread id"));
114
115
  throw new Error("Use `atdd-flow thread start|add|open`.");
115
116
  },
117
+ message: async () => {
118
+ if (rest[0] === "read") return readMessage(root, required(rest[1], "message id"));
119
+ throw new Error("Use `atdd-flow message read <message-id>`.");
120
+ },
116
121
  post: () => post(root, required(rest[0], "thread id"), rest.slice(1)),
117
122
  receipt: () => receipt(root, required(rest[0], "thread id"), required(rest[1], "message id"), rest.slice(2)),
118
123
  result: () => result(root, required(rest[0], "thread id"), required(rest[1], "message id"), rest.slice(2)),
package/src/threads.ts CHANGED
@@ -81,8 +81,10 @@ async function inject(root: string, address: string, message: Message, threadId:
81
81
  if (runtime.wake === "native") return;
82
82
  const nativeAddress = runtime.addresses[runtime.application];
83
83
  if (!nativeAddress) return;
84
- const file = paths(root).message(threadId, message.id);
85
- const notice = `SYSTEM: new thread mail ${message.id} from ${message.from}. Read ${file}`;
84
+ const record = await thread(root, threadId);
85
+ const recipients = message.to === "all" ? "all" : message.to.join(", ");
86
+ const subject = record.subject.replace(/\s+/g, " ").trim();
87
+ const notice = `SYSTEM: Flow mail ${message.id} | thread ${threadId} (${subject}) | ${message.from} → ${recipients}. Read: atdd-flow message read ${message.id}`;
86
88
  try { await notify(runtime.application, nativeAddress, notice, (await desk(root)).herdr_session); }
87
89
  catch (error) { console.warn(`Notification for ${address} was not delivered: ${(error as Error).message}`); }
88
90
  }
@@ -132,6 +134,28 @@ export async function openThread(root: string, threadId: string) {
132
134
  console.log(Bun.YAML.stringify({ ...record, messages: all }));
133
135
  }
134
136
 
137
+ /** Reads one durable message without loading or printing its whole thread. */
138
+ export async function readMessage(root: string, messageId: string) {
139
+ if (!/^M-[A-Za-z0-9_-]+$/.test(messageId)) throw new Error(`Invalid message id: ${messageId}.`);
140
+ const folders = await readdir(paths(root).threads, { withFileTypes: true });
141
+ const matches = await Promise.all(folders
142
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
143
+ .map(async (entry) => {
144
+ const file = paths(root).message(entry.name, messageId);
145
+ try { return { thread: await thread(root, entry.name), message: await readYaml<Message>(file) }; }
146
+ catch { return undefined; }
147
+ }));
148
+ const found = matches.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
149
+ if (!found.length) throw new Error(`Message ${messageId} does not exist in this Desk.`);
150
+ if (found.length > 1) throw new Error(`Message ${messageId} is ambiguous across threads: ${found.map((entry) => entry.thread.id).join(", ")}.`);
151
+ const entry = found[0];
152
+ console.log(Bun.YAML.stringify({
153
+ schema: "atdd-workflow/message-read/v1",
154
+ thread: { id: entry.thread.id, subject: entry.thread.subject },
155
+ message: entry.message,
156
+ }));
157
+ }
158
+
135
159
  export async function receipt(root: string, threadId: string, messageId: string, args: string[]) {
136
160
  const from = await canonicalAddress(root, required(words(args, "--from"), "--from"));
137
161
  const target = await replyTarget(root, threadId, messageId, from, false);