@afokapu/atdd-flow 0.2.3 → 0.3.0
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 +18 -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.0",
|
|
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> [--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,14 @@ 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 requestedWake = words(args, "--wake");
|
|
102
|
+
if (requestedWake && requestedWake !== "host" && requestedWake !== "native") throw new Error("Wake must be host or native.");
|
|
103
|
+
const selectedWake = wake ?? requestedWake as Runtime["wake"] | undefined;
|
|
101
104
|
if (!/^[a-z][a-z0-9_-]*$/.test(application)) throw new Error(`Application must use lowercase letters, numbers, underscores, or hyphens: ${application}`);
|
|
102
105
|
const nativeAddress = required(words(args, "--address"), "--address");
|
|
103
106
|
const addresses = record.runtime?.addresses ?? {};
|
|
@@ -107,6 +110,7 @@ export async function bind(root: string, address: string, args: string[], select
|
|
|
107
110
|
addresses: { ...addresses, [application]: nativeAddress },
|
|
108
111
|
attached_at: now(),
|
|
109
112
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
113
|
+
...(selectedWake ? { wake: selectedWake } : {}),
|
|
110
114
|
};
|
|
111
115
|
await atomicYaml(paths(root).seatFile(resolved), record);
|
|
112
116
|
console.log(`Bound ${resolved} to ${application}:${nativeAddress}`);
|
|
@@ -125,7 +129,8 @@ export async function useApplication(root: string, address: string, application:
|
|
|
125
129
|
export async function attach(root: string, address: string, args: string[]) {
|
|
126
130
|
const application = words(args, "--application") ?? (await desk(root)).application;
|
|
127
131
|
const nativeAddress = discoverAddress(application);
|
|
128
|
-
|
|
132
|
+
const wake = words(args, "--wake");
|
|
133
|
+
await bind(root, address, ["--application", application, "--address", nativeAddress, ...(wake ? ["--wake", wake] : [])]);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
export function availableModelCandidates(config: Desk, portfolio: ModelPortfolio) {
|
|
@@ -166,6 +171,11 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
166
171
|
export const launchNotice = (address: string) =>
|
|
167
172
|
`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
173
|
|
|
174
|
+
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
175
|
+
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
176
|
+
export const isPiExecutable = (agent: string) => basename(agent) === "pi";
|
|
177
|
+
export const piLaunchArgs = (agent: string, args: string[]) => isPiExecutable(agent) ? [...args, "--extension", piExtensionPath()] : args;
|
|
178
|
+
|
|
169
179
|
export async function launch(root: string, address: string, args: string[]) {
|
|
170
180
|
const resolved = await canonicalAddress(root, address);
|
|
171
181
|
const record = await seat(root, resolved);
|
|
@@ -187,12 +197,15 @@ export async function launch(root: string, address: string, args: string[]) {
|
|
|
187
197
|
agent = resolveExecutable(config, legacyAgent);
|
|
188
198
|
selectedModel = legacyAgent;
|
|
189
199
|
}
|
|
200
|
+
const wake: Runtime["wake"] = isPiExecutable(agent) ? "native" : "host";
|
|
201
|
+
modelArgs = piLaunchArgs(agent, modelArgs);
|
|
190
202
|
const output = await runOutput(launchCommand({
|
|
191
203
|
application, placement, name: resolved, worktree: record.worktree,
|
|
192
204
|
agent, args: modelArgs, root, seat: resolved,
|
|
193
205
|
}));
|
|
194
206
|
const nativeAddress = launchedAddress(application, placement, output);
|
|
195
|
-
await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel);
|
|
207
|
+
await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel, wake);
|
|
208
|
+
if (wake === "native") return;
|
|
196
209
|
const notice = launchNotice(resolved);
|
|
197
210
|
try { await notify(application, nativeAddress, notice, config.herdr_session); }
|
|
198
211
|
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);
|