@afokapu/atdd-flow 0.2.3 → 0.3.1
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 +33 -0
- package/extensions/pi/index.ts +111 -0
- package/package.json +4 -2
- package/src/cli.ts +8 -3
- package/src/core.ts +2 -1
- package/src/seats.ts +22 -5
- package/src/threads.ts +1 -0
package/README.md
CHANGED
|
@@ -163,6 +163,39 @@ atdd-flow launch driver.runtime@resolver-os --application tuios --placement reso
|
|
|
163
163
|
|
|
164
164
|
Launch selects a model from `models.yaml`, starts that model's executable in the seat's declared worktree, passes `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, records the selected model on the live runtime binding, and asks the agent to read its seat. Other hosts remain correct without an adapter; their operator supplies that prompt.
|
|
165
165
|
|
|
166
|
+
### Pi-native Desk mail
|
|
167
|
+
|
|
168
|
+
When Flow launches the `pi` executable, it automatically loads its bundled Pi extension. Pi remains a normal, interactive agent in its host pane, but the extension watches the immutable Desk mail files and wakes Pi internally with `pi.sendMessage()`—not terminal text injection.
|
|
169
|
+
|
|
170
|
+
The Pi seat records both concepts independently:
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
runtime:
|
|
174
|
+
application: herdr # or tuios
|
|
175
|
+
addresses:
|
|
176
|
+
herdr: w9:p1
|
|
177
|
+
model: pi
|
|
178
|
+
wake: native
|
|
179
|
+
```
|
|
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.
|
|
182
|
+
|
|
183
|
+
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
|
+
|
|
185
|
+
To host Pi in Herdr, create a pane with the durable Desk and seat identity, then start Pi with the extension supplied by the installed Flow package:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
PI_EXTENSION="$(atdd-flow pi extension-path)"
|
|
189
|
+
herdr pane split --current --direction right --cwd /path/to/worktree --no-focus \
|
|
190
|
+
--env ATDD_WORKFLOW_ROOT=/path/to/desk \
|
|
191
|
+
--env ATDD_WORKFLOW_SEAT=driver.runtime@resolver-os
|
|
192
|
+
# Use the pane id returned above.
|
|
193
|
+
herdr agent start pi-runtime --kind pi --pane <pane-id> -- --extension "$PI_EXTENSION"
|
|
194
|
+
atdd-flow --root /path/to/desk attach driver.runtime@resolver-os --application herdr --wake native
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Pi receives its identity and startup task from the extension itself. Its normal TUI remains visible and manually usable; incoming Desk mail wakes it through Pi's native message API.
|
|
198
|
+
|
|
166
199
|
With ATDD Bun, enable the Workflow profile:
|
|
167
200
|
|
|
168
201
|
```yaml
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
type Thread = { participants?: unknown };
|
|
8
|
+
type Mail = { id?: unknown; from?: unknown; to?: unknown };
|
|
9
|
+
|
|
10
|
+
const finalMail = /^M-[^.]+\.yaml$/;
|
|
11
|
+
|
|
12
|
+
function participants(thread: Thread) {
|
|
13
|
+
return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string")
|
|
14
|
+
? thread.participants as string[]
|
|
15
|
+
: [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function addressedTo(mail: Mail, thread: Thread, seat: string) {
|
|
19
|
+
if (mail.from === seat) return false;
|
|
20
|
+
if (Array.isArray(mail.to)) return mail.to.includes(seat);
|
|
21
|
+
return mail.to === "all" && participants(thread).includes(seat);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A Pi-native wake adapter. The Desk remains authoritative: this extension
|
|
26
|
+
* only observes final immutable mail files and tells Pi where to read them.
|
|
27
|
+
*/
|
|
28
|
+
export default function (pi: ExtensionAPI) {
|
|
29
|
+
const root = process.env.ATDD_WORKFLOW_ROOT;
|
|
30
|
+
const seat = process.env.ATDD_WORKFLOW_SEAT;
|
|
31
|
+
if (!root || !seat) return;
|
|
32
|
+
|
|
33
|
+
const threads = join(root, "threads");
|
|
34
|
+
const delivered = new Set<string>();
|
|
35
|
+
const pending = new Set<string>();
|
|
36
|
+
const threadWatchers = new Map<string, fs.FSWatcher>();
|
|
37
|
+
let threadsWatcher: fs.FSWatcher | undefined;
|
|
38
|
+
|
|
39
|
+
const deliver = (threadId: string, fileName: string) => {
|
|
40
|
+
const key = `${threadId}/${fileName}`;
|
|
41
|
+
if (delivered.has(key) || pending.has(key)) return;
|
|
42
|
+
pending.add(key);
|
|
43
|
+
setTimeout(async () => {
|
|
44
|
+
try {
|
|
45
|
+
const folder = join(threads, threadId);
|
|
46
|
+
const [rawThread, rawMail] = await Promise.all([
|
|
47
|
+
readFile(join(folder, "thread.yaml"), "utf8"),
|
|
48
|
+
readFile(join(folder, fileName), "utf8"),
|
|
49
|
+
]);
|
|
50
|
+
const thread = parse(rawThread) as Thread;
|
|
51
|
+
const mail = parse(rawMail) as Mail;
|
|
52
|
+
if (!addressedTo(mail, thread, seat)) return;
|
|
53
|
+
const id = typeof mail.id === "string" ? mail.id : fileName.slice(0, -5);
|
|
54
|
+
const file = join(folder, fileName);
|
|
55
|
+
delivered.add(key);
|
|
56
|
+
pi.sendMessage({
|
|
57
|
+
customType: "atdd-flow-mail",
|
|
58
|
+
content: `SYSTEM: new Flow mail ${id}. Read ${file}`,
|
|
59
|
+
display: true,
|
|
60
|
+
details: { thread: threadId, message: id, path: file },
|
|
61
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
62
|
+
} catch {
|
|
63
|
+
// A later filesystem event can retry an atomic-write race.
|
|
64
|
+
} finally {
|
|
65
|
+
pending.delete(key);
|
|
66
|
+
}
|
|
67
|
+
}, 25);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const watchThread = (threadId: string) => {
|
|
71
|
+
if (threadWatchers.has(threadId)) return;
|
|
72
|
+
try {
|
|
73
|
+
const watcher = fs.watch(join(threads, threadId), (_event, file) => {
|
|
74
|
+
const name = file?.toString();
|
|
75
|
+
if (name && finalMail.test(name)) deliver(threadId, name);
|
|
76
|
+
});
|
|
77
|
+
threadWatchers.set(threadId, watcher);
|
|
78
|
+
} catch {
|
|
79
|
+
// A concurrent thread deletion is harmless.
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const discoverThreads = async () => {
|
|
84
|
+
try {
|
|
85
|
+
for (const entry of await readdir(threads, { withFileTypes: true })) {
|
|
86
|
+
if (entry.isDirectory() && entry.name.startsWith("T-")) watchThread(entry.name);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// The Desk may be created after Pi starts.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
94
|
+
await discoverThreads();
|
|
95
|
+
try { threadsWatcher = fs.watch(threads, () => { void discoverThreads(); }); }
|
|
96
|
+
catch { /* The startup prompt still makes the missing Desk visible. */ }
|
|
97
|
+
pi.sendMessage({
|
|
98
|
+
customType: "atdd-flow-start",
|
|
99
|
+
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.`,
|
|
100
|
+
display: true,
|
|
101
|
+
details: { seat, root },
|
|
102
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
103
|
+
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.on("session_shutdown", () => {
|
|
107
|
+
threadsWatcher?.close();
|
|
108
|
+
for (const watcher of threadWatchers.values()) watcher.close();
|
|
109
|
+
threadWatchers.clear();
|
|
110
|
+
});
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afokapu/atdd-flow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"bin",
|
|
16
16
|
"conventions",
|
|
17
|
+
"extensions",
|
|
17
18
|
"src",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
"@afokapu/atdd-bun": "^0.10.44"
|
|
43
44
|
},
|
|
44
45
|
"dependencies": {
|
|
45
|
-
"@typesafe-ai/sdk": "^0.6.0"
|
|
46
|
+
"@typesafe-ai/sdk": "^0.6.0",
|
|
47
|
+
"yaml": "2.9.0"
|
|
46
48
|
}
|
|
47
49
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import { init, initProject, spawn, bind, useApplication, attach, launch, describe, checkpoint, migrate, openSeat } from "./seats";
|
|
4
|
+
import { init, initProject, spawn, bind, useApplication, attach, launch, describe, checkpoint, migrate, openSeat, piExtensionPath } from "./seats";
|
|
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";
|
|
@@ -19,8 +19,9 @@ Usage:
|
|
|
19
19
|
atdd-flow desk migrate
|
|
20
20
|
atdd-flow project init <project>
|
|
21
21
|
atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
|
|
22
|
-
atdd-flow bind <address> [--application <application>] --address <native-address>
|
|
23
|
-
atdd-flow attach <address> [--application <application>]
|
|
22
|
+
atdd-flow bind <address> [--application <application>] --address <native-address> [--agent <executable>] [--wake host|native]
|
|
23
|
+
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
24
|
+
atdd-flow pi extension-path
|
|
24
25
|
atdd-flow launch <address> --application <application> --placement <native-container-address>
|
|
25
26
|
atdd-flow application use <address> <application>
|
|
26
27
|
atdd-flow describe <address> --purpose <one-line responsibility>
|
|
@@ -73,6 +74,10 @@ async function main() {
|
|
|
73
74
|
spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
|
|
74
75
|
bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
|
|
75
76
|
attach: () => attach(root, required(rest[0], "address"), rest.slice(1)),
|
|
77
|
+
pi: async () => {
|
|
78
|
+
if (rest[0] === "extension-path") return console.log(piExtensionPath());
|
|
79
|
+
throw new Error("Use `atdd-flow pi extension-path`.");
|
|
80
|
+
},
|
|
76
81
|
launch: () => launch(root, required(rest[0], "address"), rest.slice(1)),
|
|
77
82
|
application: async () => {
|
|
78
83
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
package/src/core.ts
CHANGED
|
@@ -47,7 +47,8 @@ export type Project = {
|
|
|
47
47
|
* are opaque application-owned locators: Workflow records and returns them,
|
|
48
48
|
* while the relevant bridge is responsible for using their native format.
|
|
49
49
|
*/
|
|
50
|
-
|
|
50
|
+
/** `native` means the agent runtime wakes itself from the durable Desk. */
|
|
51
|
+
export type Runtime = { application: string; addresses: Record<string, string>; attached_at?: string; model?: string; wake?: "host" | "native" };
|
|
51
52
|
export type Seat = {
|
|
52
53
|
schema: string;
|
|
53
54
|
address: string;
|
package/src/seats.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { discoverAddress, launchedAddress, launchCommand, notify } from "./adapters";
|
|
5
5
|
import {
|
|
6
|
-
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Seat,
|
|
6
|
+
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
|
|
7
7
|
atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
8
8
|
required, run, runOutput, seat, words, yaml,
|
|
9
9
|
} from "./core";
|
|
@@ -93,11 +93,15 @@ export async function spawn(root: string, projectName: string, roleName: string,
|
|
|
93
93
|
console.log(address);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
export async function bind(root: string, address: string, args: string[], selectedModel?: string) {
|
|
96
|
+
export async function bind(root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"]) {
|
|
97
97
|
const resolved = await canonicalAddress(root, address);
|
|
98
98
|
const record = await seat(root, resolved);
|
|
99
99
|
const config = await desk(root);
|
|
100
100
|
const application = words(args, "--application") ?? config.application;
|
|
101
|
+
const requestedAgent = words(args, "--agent");
|
|
102
|
+
const requestedWake = words(args, "--wake");
|
|
103
|
+
if (requestedWake && requestedWake !== "host" && requestedWake !== "native") throw new Error("Wake must be host or native.");
|
|
104
|
+
const selectedWake = wake ?? requestedWake as Runtime["wake"] | undefined;
|
|
101
105
|
if (!/^[a-z][a-z0-9_-]*$/.test(application)) throw new Error(`Application must use lowercase letters, numbers, underscores, or hyphens: ${application}`);
|
|
102
106
|
const nativeAddress = required(words(args, "--address"), "--address");
|
|
103
107
|
const addresses = record.runtime?.addresses ?? {};
|
|
@@ -107,7 +111,11 @@ export async function bind(root: string, address: string, args: string[], select
|
|
|
107
111
|
addresses: { ...addresses, [application]: nativeAddress },
|
|
108
112
|
attached_at: now(),
|
|
109
113
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
114
|
+
...(selectedWake ? { wake: selectedWake } : {}),
|
|
110
115
|
};
|
|
116
|
+
// Legacy Desks use this field as the executable chosen by a later `launch`.
|
|
117
|
+
// Keep it aligned when an existing seat is deliberately re-homed to Pi.
|
|
118
|
+
if (requestedAgent) record.agent = requestedAgent;
|
|
111
119
|
await atomicYaml(paths(root).seatFile(resolved), record);
|
|
112
120
|
console.log(`Bound ${resolved} to ${application}:${nativeAddress}`);
|
|
113
121
|
}
|
|
@@ -125,7 +133,8 @@ export async function useApplication(root: string, address: string, application:
|
|
|
125
133
|
export async function attach(root: string, address: string, args: string[]) {
|
|
126
134
|
const application = words(args, "--application") ?? (await desk(root)).application;
|
|
127
135
|
const nativeAddress = discoverAddress(application);
|
|
128
|
-
|
|
136
|
+
const wake = words(args, "--wake");
|
|
137
|
+
await bind(root, address, ["--application", application, "--address", nativeAddress, ...(wake ? ["--wake", wake] : [])]);
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
export function availableModelCandidates(config: Desk, portfolio: ModelPortfolio) {
|
|
@@ -166,6 +175,11 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
166
175
|
export const launchNotice = (address: string) =>
|
|
167
176
|
`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.`;
|
|
168
177
|
|
|
178
|
+
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
179
|
+
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
180
|
+
export const isPiExecutable = (agent: string) => basename(agent) === "pi";
|
|
181
|
+
export const piLaunchArgs = (agent: string, args: string[]) => isPiExecutable(agent) ? [...args, "--extension", piExtensionPath()] : args;
|
|
182
|
+
|
|
169
183
|
export async function launch(root: string, address: string, args: string[]) {
|
|
170
184
|
const resolved = await canonicalAddress(root, address);
|
|
171
185
|
const record = await seat(root, resolved);
|
|
@@ -187,12 +201,15 @@ export async function launch(root: string, address: string, args: string[]) {
|
|
|
187
201
|
agent = resolveExecutable(config, legacyAgent);
|
|
188
202
|
selectedModel = legacyAgent;
|
|
189
203
|
}
|
|
204
|
+
const wake: Runtime["wake"] = isPiExecutable(agent) ? "native" : "host";
|
|
205
|
+
modelArgs = piLaunchArgs(agent, modelArgs);
|
|
190
206
|
const output = await runOutput(launchCommand({
|
|
191
207
|
application, placement, name: resolved, worktree: record.worktree,
|
|
192
208
|
agent, args: modelArgs, root, seat: resolved,
|
|
193
209
|
}));
|
|
194
210
|
const nativeAddress = launchedAddress(application, placement, output);
|
|
195
|
-
await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel);
|
|
211
|
+
await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel, wake);
|
|
212
|
+
if (wake === "native") return;
|
|
196
213
|
const notice = launchNotice(resolved);
|
|
197
214
|
try { await notify(application, nativeAddress, notice, config.herdr_session); }
|
|
198
215
|
catch (error) { console.warn(`Launch notification for ${resolved} was not delivered: ${(error as Error).message}`); }
|
package/src/threads.ts
CHANGED
|
@@ -65,6 +65,7 @@ async function inject(root: string, address: string, message: Message, threadId:
|
|
|
65
65
|
const target = await seat(root, address);
|
|
66
66
|
const runtime = target.runtime;
|
|
67
67
|
if (!runtime) return;
|
|
68
|
+
if (runtime.wake === "native") return;
|
|
68
69
|
const nativeAddress = runtime.addresses[runtime.application];
|
|
69
70
|
if (!nativeAddress) return;
|
|
70
71
|
const file = paths(root).message(threadId, message.id);
|