@nanobpm/nano-workforce 0.50.0 → 0.51.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.51.0](https://github.com/nanobpm/nano-workforce/compare/v0.50.0...v0.51.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * mount agentic channel hub + family-registration seam (H0) ([#154](https://github.com/nanobpm/nano-workforce/issues/154)) ([96a679e](https://github.com/nanobpm/nano-workforce/commit/96a679e0843c76bf93940f09eef1c6c642a4ebed)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#143](https://github.com/nanobpm/nano-workforce/issues/143) [#isMounted](https://github.com/nanobpm/nano-workforce/issues/isMounted)
7
+
1
8
  # [0.50.0](https://github.com/nanobpm/nano-workforce/compare/v0.49.0...v0.50.0) (2026-08-12)
2
9
 
3
10
 
@@ -0,0 +1,57 @@
1
+ # `app/agentic/` — the app-tier agentic channel (ADR 0056)
2
+
3
+ This directory hosts the **agentic visibility plane** for nano-workforce (epic #142). H0 (#143)
4
+ owns the keystone wiring; siblings H1/H3/H4 extend it **without touching the boot script**.
5
+
6
+ ## What H0 lands
7
+
8
+ - **`channel.ts`** — `mountAgenticChannel(...)`. Stands up the `@nanobpm/agentic` WebSocket channel
9
+ + `AgenticHub` on the app's **own** HTTP server (`app.httpServer`, same port as the pages and
10
+ `/app/api/hooks/*` — no sidecar port), authenticates upgrades on `/agentic` (ADR 0028 identity
11
+ token + a required capability credential, mirroring the blackboard hook's `?token=…` pattern), and
12
+ mounts every discovered family. Returns a handle whose `teardown()` reverses everything.
13
+ - **`registry.ts`** — the `AgenticFamilyRegistry` seam + the `AgenticFamily` / `AgenticContext`
14
+ contracts. Mounts families on boot, tears them down in **reverse** order on shutdown.
15
+ - **`loader.ts`** — auto-discovers `*.family.ts` modules under `families/`. There is **no central
16
+ registration list** to append to, so siblings never collide on a shared file.
17
+ - **`families/`** — the discovery directory. Drop a family module here; `families/example.family.ts`
18
+ is the copyable no-op template.
19
+
20
+ `main.ts` calls `mountAgenticChannel(...)` once after `runFromEnv`, and its `teardown()` once inside
21
+ the existing `drainAndExit`. **That is the only edit to `main.ts` / `drainAndExit` for the whole
22
+ epic.**
23
+
24
+ ## How a sibling slice extends the channel (H1 / H3 / H4)
25
+
26
+ 1. Copy `families/example.family.ts` to `families/<slice>.family.ts` and implement `mount(ctx)`
27
+ (and optionally `teardown()`). `ctx` carries the reusable handles: `hub`, `registry`,
28
+ `transport`, `data` (the app SQLite `DataLayer`), and `log`.
29
+ 2. Own a message family via `ctx.hub.registerFamilyHandler("<family>", handler)` — the router
30
+ refuses a duplicate, so two slices can't both claim one family.
31
+ 3. **Do not** edit `main.ts`, `drainAndExit`, `channel.ts`, `registry.ts`, or `loader.ts`. You add
32
+ exactly one new file.
33
+
34
+ ## Reserved migration prefixes
35
+
36
+ Forward-only, additive (expand-only). The highest committed prefix when the epic began is `022`, so
37
+ H0 pre-allocates distinct prefixes to stop two siblings independently grabbing "the next" number:
38
+
39
+ | Slice | Reserved migration file |
40
+ | ---------------- | ------------------------------------------- |
41
+ | H1 presence #144 | `db/migrations/023_agentic_presence.sql` |
42
+ | H3 transcript #146 | `db/migrations/024_agentic_transcript.sql` |
43
+ | H4 blackboard #147 | `db/migrations/025_agentic_blackboard.sql` (only if a schema change is needed) |
44
+
45
+ H0 itself needs no migration.
46
+
47
+ ## Configuration
48
+
49
+ - `NANO_AGENTIC_SECRET` (falls back to `NANO_PR_WEBHOOK_SECRET`) — the shared identity secret peers
50
+ present as `?token=…`. When neither is set, the channel is **not mounted** (logged), so the app
51
+ never exposes an unauthenticated upgrade.
52
+
53
+ ## Invariants (ADR 0056)
54
+
55
+ - **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;
56
+ the agentic channel is the only new conversation.
57
+ - **Advisory** — a family never hard-locks or gates a BPMN sequence flow.
@@ -0,0 +1,244 @@
1
+ // Integration tests for the agentic channel mount (ADR 0056, H0 / #143).
2
+ //
3
+ // Exercises the acceptance surface against a locally-constructed `node:http` server: a valid WS
4
+ // client upgrades on `/agentic`, an invalid one is rejected, normal HTTP routes keep working, the
5
+ // hub is visible via `inspect()`, families mount/tear-down through the seam, and shutdown is clean.
6
+ import { type AddressInfo, createServer, type Server } from "node:http";
7
+ import { test } from "node:test";
8
+ import { WebSocket } from "ws";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import { noopLog } from "../../test/log.ts";
11
+ import { type AgenticChannelHandle, mountAgenticChannel } from "./channel.ts";
12
+ import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
13
+
14
+ const SECRET = "test-agentic-secret";
15
+
16
+ /** Start a bare HTTP server with one health route, on an ephemeral port. */
17
+ async function startHttp(): Promise<{ server: Server; port: number }> {
18
+ const server = createServer((req, res) => {
19
+ if (req.url === "/health") {
20
+ res.writeHead(200, { "content-type": "text/plain" });
21
+ res.end("ok");
22
+ return;
23
+ }
24
+ res.writeHead(404);
25
+ res.end();
26
+ });
27
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
28
+ const port = (server.address() as AddressInfo).port;
29
+ return { server, port };
30
+ }
31
+
32
+ /** Close an HTTP server and resolve only once it has stopped listening (server.close() is async). */
33
+ function closeServer(server: Server): Promise<void> {
34
+ return new Promise<void>((resolve, reject) => {
35
+ server.close((err) => (err ? reject(err) : resolve()));
36
+ });
37
+ }
38
+
39
+ /** Open a ws client and resolve on open. Rejects (with the close code) if it closes before opening. */
40
+ function connect(port: number, query: string): Promise<WebSocket> {
41
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/agentic${query}`);
42
+ return new Promise<WebSocket>((resolve, reject) => {
43
+ let opened = false;
44
+ ws.on("open", () => {
45
+ opened = true;
46
+ resolve(ws);
47
+ });
48
+ ws.on("error", () => {
49
+ /* the close frame carries the reason; swallow the paired error event */
50
+ });
51
+ ws.on("close", (code, reason) => {
52
+ if (!opened) reject(new Error(`closed ${code}: ${reason.toString()}`));
53
+ });
54
+ });
55
+ }
56
+
57
+ /**
58
+ * The application close code an authenticator rejection delivers. The hub authenticates AFTER the
59
+ * WebSocket upgrade completes, so a rejected peer momentarily opens and is then closed with the app
60
+ * code (4401/4403) — this waits for that close and returns the code.
61
+ */
62
+ /** How long rejectionCode waits for the close frame before failing (generous for slow CI). */
63
+ const REJECTION_TIMEOUT_MS = 2000;
64
+
65
+ function rejectionCode(port: number, query: string): Promise<number> {
66
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/agentic${query}`);
67
+ return new Promise<number>((resolve, reject) => {
68
+ const timer = setTimeout(
69
+ () => reject(new Error("connection neither closed nor timed out")),
70
+ REJECTION_TIMEOUT_MS,
71
+ );
72
+ timer.unref?.();
73
+ ws.on("error", () => {
74
+ /* swallow the paired error event; the close frame carries the code */
75
+ });
76
+ ws.on("close", (code) => {
77
+ clearTimeout(timer);
78
+ resolve(code);
79
+ });
80
+ });
81
+ }
82
+
83
+ async function mount(
84
+ _port: number,
85
+ server: Server,
86
+ families?: () => AgenticFamilyRegistry,
87
+ ): Promise<AgenticChannelHandle> {
88
+ return mountAgenticChannel({
89
+ server,
90
+ secret: SECRET,
91
+ data: undefined,
92
+ log: noopLog(),
93
+ families,
94
+ });
95
+ }
96
+
97
+ test("a valid identity token + capability credential upgrades on /agentic", async (t) => {
98
+ const { server, port } = await startHttp();
99
+ const channel = await mount(port, server);
100
+ t.after(async () => {
101
+ await channel.teardown();
102
+ await closeServer(server);
103
+ });
104
+
105
+ const ws = await connect(port, `?token=${SECRET}&capability=cap-1`);
106
+ assertEquals(ws.readyState, WebSocket.OPEN);
107
+ // The hub tracked exactly this one connection.
108
+ assertEquals(channel.hub.connectionCount, 1);
109
+ ws.close();
110
+ });
111
+
112
+ test("an invalid identity token is rejected (4401)", async (t) => {
113
+ const { server, port } = await startHttp();
114
+ const channel = await mount(port, server);
115
+ t.after(async () => {
116
+ await channel.teardown();
117
+ await closeServer(server);
118
+ });
119
+
120
+ const closedCode = await rejectionCode(port, "?token=wrong&capability=cap-1");
121
+ assertEquals(closedCode, 4401);
122
+ assertEquals(channel.hub.connectionCount, 0);
123
+ });
124
+
125
+ test("a missing capability credential is rejected (4403)", async (t) => {
126
+ const { server, port } = await startHttp();
127
+ const channel = await mount(port, server);
128
+ t.after(async () => {
129
+ await channel.teardown();
130
+ await closeServer(server);
131
+ });
132
+
133
+ const closedCode = await rejectionCode(port, `?token=${SECRET}`);
134
+ assertEquals(closedCode, 4403);
135
+ });
136
+
137
+ test("normal HTTP routes keep working alongside the channel", async (t) => {
138
+ const { server, port } = await startHttp();
139
+ const channel = await mount(port, server);
140
+ t.after(async () => {
141
+ await channel.teardown();
142
+ await closeServer(server);
143
+ });
144
+
145
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
146
+ assertEquals(res.status, 200);
147
+ assertEquals(await res.text(), "ok");
148
+ });
149
+
150
+ test("the hub is visible via inspect() and mounts registered families", async (t) => {
151
+ const { server, port } = await startHttp();
152
+ const trace: string[] = [];
153
+ const families = () => {
154
+ const reg = new AgenticFamilyRegistry();
155
+ reg.register({
156
+ name: "probe",
157
+ mount(ctx: AgenticContext) {
158
+ trace.push("mount");
159
+ // Prove the real hub handle is threaded through: registering a family handler must work.
160
+ ctx.hub.registerFamilyHandler("register", () => {});
161
+ },
162
+ teardown() {
163
+ trace.push("teardown");
164
+ },
165
+ });
166
+ return reg;
167
+ };
168
+ const channel = await mount(port, server, families);
169
+ t.after(() => closeServer(server));
170
+
171
+ const snap = channel.inspect();
172
+ assertEquals(snap.path, "/agentic");
173
+ assertEquals(snap.families, ["probe"]);
174
+ assertEquals(trace, ["mount"]);
175
+ assert(channel.hub.router.has("register"), "family handler should be registered");
176
+
177
+ await channel.teardown();
178
+ assertEquals(trace, ["mount", "teardown"]);
179
+ });
180
+
181
+ test("teardown closes live connections and is idempotent", async (t) => {
182
+ const { server, port } = await startHttp();
183
+ const channel = await mount(port, server);
184
+ t.after(() => closeServer(server));
185
+
186
+ const ws = await connect(port, `?token=${SECRET}&capability=cap-1`);
187
+ const closed = new Promise<void>((resolve) => ws.on("close", () => resolve()));
188
+ assertEquals(channel.hub.connectionCount, 1);
189
+
190
+ await channel.teardown();
191
+ await channel.teardown(); // second call is a no-op, must not throw
192
+ await closed;
193
+ assertEquals(ws.readyState, WebSocket.CLOSED);
194
+ });
195
+
196
+ test("a family mount failure tears down already-mounted families and closes the hub", async (t) => {
197
+ const { server, port } = await startHttp();
198
+ t.after(() => closeServer(server));
199
+ const trace: string[] = [];
200
+ const families = () => {
201
+ const reg = new AgenticFamilyRegistry();
202
+ reg.register({
203
+ name: "ok",
204
+ mount() {
205
+ trace.push("mount-ok");
206
+ },
207
+ teardown() {
208
+ trace.push("teardown-ok");
209
+ },
210
+ });
211
+ reg.register({
212
+ name: "boom",
213
+ mount() {
214
+ trace.push("mount-boom");
215
+ throw new Error("family boom failed to mount");
216
+ },
217
+ });
218
+ return reg;
219
+ };
220
+
221
+ let threw = false;
222
+ try {
223
+ await mount(port, server, families);
224
+ } catch {
225
+ threw = true;
226
+ }
227
+ assert(threw, "mountAgenticChannel must reject when a family mount throws");
228
+ // The already-mounted family was torn down (reverse order) — no family left half-mounted, and the
229
+ // hub was closed on the same path (see mountAgenticChannel's failure handler).
230
+ assertEquals(trace, ["mount-ok", "mount-boom", "teardown-ok"]);
231
+ });
232
+
233
+ test("a missing secret is refused (never mount an open channel)", async (t) => {
234
+ const { server, port } = await startHttp();
235
+ t.after(() => closeServer(server));
236
+ let threw = false;
237
+ try {
238
+ await mountAgenticChannel({ server, secret: "", data: undefined, log: noopLog() });
239
+ } catch {
240
+ threw = true;
241
+ }
242
+ assert(threw, "mountAgenticChannel must reject an empty secret");
243
+ assertEquals(port > 0, true);
244
+ });
@@ -0,0 +1,124 @@
1
+ // nano-workforce — mount the app-tier agentic channel hub (ADR 0056, H0 / #143).
2
+ //
3
+ // This is the keystone of the agentic-visibility epic (#142): it stands up the WebSocket channel +
4
+ // hub on the app's OWN HTTP server (same port as the pages and `/app/api/hooks/*` — no sidecar
5
+ // port), authenticates each upgrade (ADR 0028 identity token + a capability credential, mirroring
6
+ // the `?token=…` pattern the blackboard hook uses), and mounts every registered family module
7
+ // through the {@link AgenticFamilyRegistry} seam.
8
+ //
9
+ // `main.ts` calls {@link mountAgenticChannel} once after `runFromEnv` and calls the returned
10
+ // handle's `teardown()` inside its existing `drainAndExit`. That is the ONLY place `main.ts` /
11
+ // `drainAndExit` are edited for the whole epic — siblings extend the channel purely by dropping a
12
+ // family module under `app/agentic/families/`.
13
+ //
14
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
15
+ // is untouched; advisory semantics preserved (a family never gates a BPMN sequence flow).
16
+ import type { Server } from "node:http";
17
+ import {
18
+ AgenticHub,
19
+ sharedSecretAuthenticator,
20
+ WebSocketChannelTransport,
21
+ } from "@nanobpm/agentic/channel";
22
+ import type { DataLayer, Logger } from "@nanobpm/urban";
23
+ import { loadAgenticFamilies } from "./loader.ts";
24
+ import { AgenticFamilyRegistry } from "./registry.ts";
25
+
26
+ /** The path the agentic channel is served on, on the app's own port. */
27
+ export const AGENTIC_PATH = "/agentic";
28
+
29
+ export interface MountAgenticChannelOptions {
30
+ /** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
31
+ readonly server: Server;
32
+ /** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`. */
33
+ readonly secret: string;
34
+ /** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
35
+ readonly data: DataLayer | undefined;
36
+ /** A structured logger for lifecycle lines. */
37
+ readonly log: Logger;
38
+ /**
39
+ * Discover + register family modules from `app/agentic/families/`. Default: the real discovery
40
+ * loader. Tests inject a fixed set to keep the mount hermetic.
41
+ */
42
+ readonly families?: () => Promise<AgenticFamilyRegistry> | AgenticFamilyRegistry;
43
+ }
44
+
45
+ /** The live channel, returned to `main.ts` so it can inspect it and tear it down on shutdown. */
46
+ export interface AgenticChannelHandle {
47
+ readonly hub: AgenticHub;
48
+ readonly transport: WebSocketChannelTransport;
49
+ readonly registry: AgenticFamilyRegistry;
50
+ /** A structured snapshot for `inspect()`/logs. */
51
+ inspect(): Record<string, unknown>;
52
+ /** Tear the families (reverse order) then the hub + transport down. Idempotent. */
53
+ teardown(): Promise<void>;
54
+ }
55
+
56
+ /** Build a family registry from the on-disk discovery loader (the production default). */
57
+ async function discoverRegistry(log: Logger): Promise<AgenticFamilyRegistry> {
58
+ const registry = new AgenticFamilyRegistry();
59
+ registry.registerAll(await loadAgenticFamilies(undefined, log));
60
+ return registry;
61
+ }
62
+
63
+ /**
64
+ * Mount the agentic hub on `server`, authenticate upgrades with `secret`, and mount all discovered
65
+ * family modules. Returns a handle whose `teardown()` reverses everything.
66
+ */
67
+ export async function mountAgenticChannel(
68
+ opts: MountAgenticChannelOptions,
69
+ ): Promise<AgenticChannelHandle> {
70
+ const { server, secret, data, log } = opts;
71
+ if (!secret) throw new Error("mountAgenticChannel requires a non-empty identity secret");
72
+
73
+ const transport = new WebSocketChannelTransport({ server, path: AGENTIC_PATH });
74
+ const hub = new AgenticHub({
75
+ transport,
76
+ // A valid identity token PLUS a required capability credential upgrades; either missing/invalid
77
+ // is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an Authenticator.
78
+ authenticator: sharedSecretAuthenticator({ secret, requireCredential: true }),
79
+ onError: (err, connectionId) =>
80
+ log.warn("agentic hub error", { connectionId, err: String(err) }),
81
+ });
82
+ // Share the app's port: the transport rode the existing server, so it is already listening.
83
+ await transport.ready();
84
+
85
+ // If discovery or any family mount throws, the transport + hub are already live: tear down whatever
86
+ // mounted (in reverse) and close the hub before rethrowing, so a failed boot never strands upgrade
87
+ // handlers or half-open connections.
88
+ let registry: AgenticFamilyRegistry | undefined;
89
+ try {
90
+ registry = await (opts.families ? opts.families() : discoverRegistry(log));
91
+ await registry.mountAll({ hub, registry: hub.registry, transport, data, log });
92
+ } catch (err) {
93
+ await registry?.teardownAll(log);
94
+ await hub.close();
95
+ throw err;
96
+ }
97
+
98
+ log.info("agentic channel mounted", {
99
+ path: AGENTIC_PATH,
100
+ families: registry.names(),
101
+ });
102
+
103
+ let tornDown = false;
104
+ return {
105
+ hub,
106
+ transport,
107
+ registry,
108
+ inspect() {
109
+ return {
110
+ path: AGENTIC_PATH,
111
+ families: registry.names(),
112
+ connections: hub.connectionCount,
113
+ address: hub.address,
114
+ };
115
+ },
116
+ async teardown() {
117
+ if (tornDown) return;
118
+ tornDown = true;
119
+ await registry.teardownAll(log);
120
+ await hub.close();
121
+ log.info("agentic channel torn down");
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,35 @@
1
+ // nano-workforce — a no-op EXAMPLE agentic family module (ADR 0056, H0 / #143).
2
+ //
3
+ // This is the concrete, copyable pattern H1 (#144), H3 (#146) and H4 (#147) follow. It lives in the
4
+ // discovery directory (`app/agentic/families/`), so the loader ({@link ../loader.ts}) finds it by
5
+ // the `*.family.ts` suffix and the H0 seam mounts + tears it down — a genuine registered sample. It
6
+ // is a deliberate no-op (no message handler, no state), so mounting it in production is harmless.
7
+ //
8
+ // To add a real family slice, copy this file to `app/agentic/families/<slice>.family.ts`, rename it,
9
+ // and implement `mount` (and optionally `teardown`). That is the WHOLE integration: you add ONE NEW
10
+ // FILE and register NOTHING by hand — you never touch `main.ts` or `drainAndExit`.
11
+ //
12
+ // Reserved forward-only migration prefixes (do NOT compute "the next" number — use your reserved one):
13
+ // - H1 presence → `db/migrations/023_agentic_presence.sql`
14
+ // - H3 transcript → `db/migrations/024_agentic_transcript.sql`
15
+ // - H4 blackboard → `db/migrations/025_agentic_blackboard.sql` (only if a schema change is needed)
16
+ import type { AgenticContext, AgenticFamily } from "../registry.ts";
17
+
18
+ /**
19
+ * A minimal, no-op family. A real slice would, inside `mount`:
20
+ * - `ctx.hub.registerFamilyHandler("<family>", (frame, conn) => { … })` to own a message family,
21
+ * - persist via `ctx.data` (the app's SQLite DataLayer — the same store the blackboard uses),
22
+ * - attach presence via `ctx.registry.setPresence(conn.id, …)`,
23
+ * and release those in `teardown`.
24
+ */
25
+ export const family: AgenticFamily = {
26
+ name: "example",
27
+ mount(_ctx: AgenticContext): void {
28
+ // No-op template. Replace with the slice's real wiring against `_ctx`.
29
+ },
30
+ teardown(): void {
31
+ // No-op template. Release anything `mount` acquired here.
32
+ },
33
+ };
34
+
35
+ export default family;
@@ -0,0 +1,79 @@
1
+ // Unit tests for the agentic family DISCOVERY loader (ADR 0056, H0 / #143).
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import { noopLog } from "../../test/log.ts";
8
+ import { loadAgenticFamilies } from "./loader.ts";
9
+
10
+ /** A discovered family module's source: exports a named `family` with the given `name`. */
11
+ function familyModuleSource(name: string): string {
12
+ return `export const family = { name: ${JSON.stringify(name)}, mount() {}, teardown() {} };\n`;
13
+ }
14
+
15
+ async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> {
16
+ const dir = await mkdtemp(join(tmpdir(), "agentic-loader-"));
17
+ try {
18
+ await fn(dir);
19
+ } finally {
20
+ await rm(dir, { recursive: true, force: true });
21
+ }
22
+ }
23
+
24
+ test("discovers *.family.ts modules in sorted (deterministic) order", async () => {
25
+ await withTempDir(async (dir) => {
26
+ await writeFile(join(dir, "relay.family.ts"), familyModuleSource("relay"));
27
+ await writeFile(join(dir, "presence.family.ts"), familyModuleSource("presence"));
28
+ await writeFile(join(dir, "blackboard.family.ts"), familyModuleSource("blackboard"));
29
+ const families = await loadAgenticFamilies(dir, noopLog());
30
+ // Sorted by filename: blackboard.family.ts < presence.family.ts < relay.family.ts.
31
+ assertEquals(families.map((f) => f.name), ["blackboard", "presence", "relay"]);
32
+ });
33
+ });
34
+
35
+ test("ignores non-family files, test files, and READMEs", async () => {
36
+ await withTempDir(async (dir) => {
37
+ await writeFile(join(dir, "presence.family.ts"), familyModuleSource("presence"));
38
+ await writeFile(join(dir, "presence.family.test.ts"), "export const nope = 1;\n");
39
+ await writeFile(join(dir, "helper.ts"), "export const nope = 2;\n");
40
+ await writeFile(join(dir, "README.md"), "# families\n");
41
+ const families = await loadAgenticFamilies(dir, noopLog());
42
+ assertEquals(families.map((f) => f.name), ["presence"]);
43
+ });
44
+ });
45
+
46
+ test("a missing families directory yields no families (not an error)", async () => {
47
+ const families = await loadAgenticFamilies(join(tmpdir(), "does-not-exist-agentic-xyz"), noopLog());
48
+ assertEquals(families, []);
49
+ });
50
+
51
+ test("accepts a default export as well as a named `family` export", async () => {
52
+ await withTempDir(async (dir) => {
53
+ await writeFile(
54
+ join(dir, "def.family.ts"),
55
+ "export default { name: 'viaDefault', mount() {} };\n",
56
+ );
57
+ const families = await loadAgenticFamilies(dir, noopLog());
58
+ assertEquals(families.map((f) => f.name), ["viaDefault"]);
59
+ });
60
+ });
61
+
62
+ test("skips a module that exports no valid family, without crashing discovery", async () => {
63
+ await withTempDir(async (dir) => {
64
+ await writeFile(join(dir, "ok.family.ts"), familyModuleSource("ok"));
65
+ // No `family`/`default`; and a malformed one (missing mount).
66
+ await writeFile(join(dir, "empty.family.ts"), "export const something = 1;\n");
67
+ await writeFile(join(dir, "bad.family.ts"), "export const family = { name: 'bad' };\n");
68
+ const families = await loadAgenticFamilies(dir, noopLog());
69
+ assertEquals(families.map((f) => f.name), ["ok"]);
70
+ });
71
+ });
72
+
73
+ test("the real families/ directory discovers the copyable example no-op", async () => {
74
+ const families = await loadAgenticFamilies(undefined, noopLog());
75
+ assert(
76
+ families.some((f) => f.name === "example"),
77
+ "expected the shipped example family to be discovered",
78
+ );
79
+ });
@@ -0,0 +1,84 @@
1
+ // nano-workforce — the agentic family DISCOVERY loader (ADR 0056, H0 / #143).
2
+ //
3
+ // Siblings drop a `*.family.ts` module into `app/agentic/families/`; this loader finds it by
4
+ // convention and imports it. There is deliberately NO central registration array for siblings to
5
+ // append to (that would merely relocate the shared-file collision the plan review flagged): a family
6
+ // is discovered purely by living in the conventional directory with the conventional suffix.
7
+ //
8
+ // A discovered module contributes its family via a default export OR a named `family` export. Any
9
+ // `*.test.ts` file is ignored (test files never carry a family), and a module that exports no valid
10
+ // family is skipped with a warning rather than crashing boot.
11
+ import { readdir } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import type { Logger } from "@nanobpm/urban";
15
+ import type { AgenticFamily } from "./registry.ts";
16
+
17
+ /** The conventional directory holding sibling family modules, resolved next to this loader. */
18
+ export const FAMILIES_DIR = join(dirname(fileURLToPath(import.meta.url)), "families");
19
+
20
+ /** The filename suffix a family module must carry to be discovered. */
21
+ const FAMILY_SUFFIX = ".family.ts";
22
+
23
+ /** Read a property off an unknown object without an unsafe cast. */
24
+ function prop(obj: object, key: string): unknown {
25
+ return Object.hasOwn(obj, key) ? Object.getOwnPropertyDescriptor(obj, key)?.value : undefined;
26
+ }
27
+
28
+ /** A type guard proving an unknown value structurally satisfies {@link AgenticFamily}. */
29
+ function isAgenticFamily(candidate: unknown): candidate is AgenticFamily {
30
+ if (!candidate || typeof candidate !== "object") return false;
31
+ const name = prop(candidate, "name");
32
+ if (typeof name !== "string" || name.trim() === "") return false;
33
+ if (typeof prop(candidate, "mount") !== "function") return false;
34
+ const teardown = prop(candidate, "teardown");
35
+ return teardown === undefined || typeof teardown === "function";
36
+ }
37
+
38
+ /** Structurally validate a discovered module's contribution as an {@link AgenticFamily}. */
39
+ function asFamily(mod: unknown): AgenticFamily | undefined {
40
+ if (!mod || typeof mod !== "object") return undefined;
41
+ const candidate = prop(mod, "family") ?? prop(mod, "default");
42
+ return isAgenticFamily(candidate) ? candidate : undefined;
43
+ }
44
+
45
+ /**
46
+ * Discover every family module under `dir` (default {@link FAMILIES_DIR}), imported in a stable
47
+ * (sorted-by-filename) order so mount/teardown order is deterministic across hosts. A missing
48
+ * directory yields no families (the epic's first slice ships before any sibling exists). A module
49
+ * that fails to import or exports no valid family is logged and skipped, never fatal to boot.
50
+ */
51
+ export async function loadAgenticFamilies(
52
+ dir: string = FAMILIES_DIR,
53
+ log?: Logger,
54
+ ): Promise<AgenticFamily[]> {
55
+ let entries: string[];
56
+ try {
57
+ entries = await readdir(dir);
58
+ } catch (err) {
59
+ // A missing families directory is the expected steady state before any sibling lands.
60
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return [];
61
+ throw err;
62
+ }
63
+ const files = entries
64
+ .filter((name) => name.endsWith(FAMILY_SUFFIX))
65
+ .sort();
66
+ const families: AgenticFamily[] = [];
67
+ for (const name of files) {
68
+ const href = pathToFileURL(join(dir, name)).href;
69
+ let mod: unknown;
70
+ try {
71
+ mod = await import(href);
72
+ } catch (err) {
73
+ log?.error("agentic family module failed to import", { file: name, err: String(err) });
74
+ continue;
75
+ }
76
+ const family = asFamily(mod);
77
+ if (!family) {
78
+ log?.warn("agentic family module exported no valid family; skipping", { file: name });
79
+ continue;
80
+ }
81
+ families.push(family);
82
+ }
83
+ return families;
84
+ }
@@ -0,0 +1,160 @@
1
+ // Unit tests for the agentic family-registration seam (ADR 0056, H0 / #143).
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals, assertRejects, assertThrows } from "#test-assert";
4
+ import { noopLog } from "../../test/log.ts";
5
+ import { type AgenticContext, AgenticFamilyRegistry, type AgenticFamily } from "./registry.ts";
6
+
7
+ // A minimal context — the seam only threads it through to `mount`, so the tests don't need a real
8
+ // hub. `undefined`/no-op handles are fine here; the channel test exercises the real handles.
9
+ function fakeCtx(): AgenticContext {
10
+ // biome-ignore lint/suspicious/noExplicitAny: seam only forwards ctx opaquely in these tests
11
+ const stub: any = {};
12
+ return { hub: stub, registry: stub, transport: stub, data: undefined, log: noopLog() };
13
+ }
14
+
15
+ /** A family that records the order of mount/teardown calls into a shared trace. */
16
+ function tracer(name: string, trace: string[]): AgenticFamily {
17
+ return {
18
+ name,
19
+ mount() {
20
+ trace.push(`mount:${name}`);
21
+ },
22
+ teardown() {
23
+ trace.push(`teardown:${name}`);
24
+ },
25
+ };
26
+ }
27
+
28
+ test("mounts families in registration order, tears them down in reverse", async () => {
29
+ const trace: string[] = [];
30
+ const reg = new AgenticFamilyRegistry();
31
+ reg.registerAll([tracer("a", trace), tracer("b", trace), tracer("c", trace)]);
32
+ assertEquals(reg.names(), ["a", "b", "c"]);
33
+
34
+ await reg.mountAll(fakeCtx());
35
+ assertEquals(trace, ["mount:a", "mount:b", "mount:c"]);
36
+
37
+ await reg.teardownAll(noopLog());
38
+ assertEquals(trace, ["mount:a", "mount:b", "mount:c", "teardown:c", "teardown:b", "teardown:a"]);
39
+ });
40
+
41
+ test("mountAll is idempotent — a second call never re-mounts", async () => {
42
+ const trace: string[] = [];
43
+ const reg = new AgenticFamilyRegistry();
44
+ reg.register(tracer("a", trace));
45
+ const ctx = fakeCtx();
46
+ await reg.mountAll(ctx);
47
+ await reg.mountAll(ctx);
48
+ assertEquals(trace, ["mount:a"]);
49
+ });
50
+
51
+ test("teardownAll only reverses families that actually mounted, and is idempotent", async () => {
52
+ const trace: string[] = [];
53
+ const reg = new AgenticFamilyRegistry();
54
+ reg.register(tracer("a", trace));
55
+ await reg.mountAll(fakeCtx());
56
+ await reg.teardownAll();
57
+ await reg.teardownAll();
58
+ assertEquals(trace, ["mount:a", "teardown:a"]);
59
+ });
60
+
61
+ test("a rejected duplicate family name protects one-family-one-slot", () => {
62
+ const reg = new AgenticFamilyRegistry();
63
+ reg.register({ name: "dup", mount() {} });
64
+ assertThrows(() => reg.register({ name: "dup", mount() {} }), Error, "duplicate agentic family");
65
+ });
66
+
67
+ test("registering after mount is refused", async () => {
68
+ const reg = new AgenticFamilyRegistry();
69
+ reg.register({ name: "a", mount() {} });
70
+ await reg.mountAll(fakeCtx());
71
+ assertThrows(() => reg.register({ name: "b", mount() {} }), Error, "after mount");
72
+ });
73
+
74
+ test("a family with no teardown is skipped cleanly on shutdown", async () => {
75
+ const trace: string[] = [];
76
+ const reg = new AgenticFamilyRegistry();
77
+ reg.register({ name: "no-teardown", mount() {
78
+ trace.push("mount");
79
+ } });
80
+ await reg.mountAll(fakeCtx());
81
+ await reg.teardownAll();
82
+ assertEquals(trace, ["mount"]);
83
+ });
84
+
85
+ test("one family's teardown throw is isolated and does not strand siblings", async () => {
86
+ const trace: string[] = [];
87
+ const reg = new AgenticFamilyRegistry();
88
+ reg.register(tracer("a", trace));
89
+ reg.register({
90
+ name: "boom",
91
+ mount() {
92
+ trace.push("mount:boom");
93
+ },
94
+ teardown() {
95
+ throw new Error("teardown boom");
96
+ },
97
+ });
98
+ await reg.mountAll(fakeCtx());
99
+ // Should not throw despite "boom" failing; "a" must still tear down.
100
+ await reg.teardownAll(noopLog());
101
+ assertEquals(trace, ["mount:a", "mount:boom", "teardown:a"]);
102
+ });
103
+
104
+ test("a mount failure only tears down what actually mounted", async () => {
105
+ const trace: string[] = [];
106
+ const reg = new AgenticFamilyRegistry();
107
+ reg.register(tracer("a", trace));
108
+ reg.register({
109
+ name: "fails",
110
+ mount() {
111
+ throw new Error("mount fails");
112
+ },
113
+ teardown() {
114
+ trace.push("teardown:fails");
115
+ },
116
+ });
117
+ await assertRejects(() => reg.mountAll(fakeCtx()), Error, "mount fails");
118
+ await reg.teardownAll();
119
+ // "fails" never completed mount, so its teardown must not run; "a" did mount, so it tears down.
120
+ assertEquals(trace, ["mount:a", "teardown:a"]);
121
+ });
122
+
123
+ test("mountAll self-heals after a mid-mount failure — a retry re-mounts, never a stuck no-op", async () => {
124
+ const trace: string[] = [];
125
+ const reg = new AgenticFamilyRegistry();
126
+ reg.register(tracer("a", trace));
127
+ let shouldFail = true;
128
+ reg.register({
129
+ name: "flaky",
130
+ mount() {
131
+ if (shouldFail) {
132
+ shouldFail = false;
133
+ throw new Error("mount fails once");
134
+ }
135
+ trace.push("mount:flaky");
136
+ },
137
+ teardown() {
138
+ trace.push("teardown:flaky");
139
+ },
140
+ });
141
+
142
+ // First attempt fails mid-mount; mountAll must reverse the partial mount and reset its own state
143
+ // (without the caller having to call teardownAll)...
144
+ await assertRejects(() => reg.mountAll(fakeCtx()), Error, "mount fails once");
145
+ assertEquals(trace, ["mount:a", "teardown:a"]);
146
+
147
+ // ...so a retry actually re-mounts instead of being a silent no-op (the wedged-state regression).
148
+ await reg.mountAll(fakeCtx());
149
+ assertEquals(trace, ["mount:a", "teardown:a", "mount:a", "mount:flaky"]);
150
+
151
+ await reg.teardownAll();
152
+ assertEquals(trace, [
153
+ "mount:a",
154
+ "teardown:a",
155
+ "mount:a",
156
+ "mount:flaky",
157
+ "teardown:flaky",
158
+ "teardown:a",
159
+ ]);
160
+ });
@@ -0,0 +1,130 @@
1
+ // nano-workforce — the agentic-channel family-registration SEAM (ADR 0056, H0 / #143).
2
+ //
3
+ // This module is OWNED by H0 (the keystone slice). It is the single extension point every sibling
4
+ // slice of the agentic-visibility epic (#142) plugs into:
5
+ //
6
+ // - H1 presence (#144) → adds `app/agentic/families/presence.family.ts`
7
+ // - H3 relay (#146) → adds `app/agentic/families/relay.family.ts`
8
+ // - H4 blackboard(#147) → adds `app/agentic/families/blackboard.family.ts`
9
+ //
10
+ // A sibling adds ONE NEW FILE under `app/agentic/families/` exporting an {@link AgenticFamily} and
11
+ // NOTHING ELSE — it never edits `main.ts`, `drainAndExit`, or any shared boot line. The loader
12
+ // ({@link ./loader.ts}) discovers those files by convention (`*.family.ts`) and hands them to this
13
+ // registry, so there is no central registration list for siblings to collide on either — the
14
+ // shared-file collision the plan review flagged is designed out, not merely relocated.
15
+ //
16
+ // The registry mounts families on boot (in discovery order) and tears them down in REVERSE order on
17
+ // shutdown — the mirror-image lifecycle a stack of resources needs so a later family that depends on
18
+ // an earlier one is torn down first.
19
+ //
20
+ // RESERVED forward-only migration prefixes (H0 pre-allocates these so no two siblings independently
21
+ // grab "the next" number — current highest committed prefix is 022):
22
+ // - `db/migrations/023_agentic_presence.sql` → H1 (#144)
23
+ // - `db/migrations/024_agentic_transcript.sql` → H3 (#146)
24
+ // - `db/migrations/025_agentic_blackboard.sql` → H4 (#147), only if it needs a schema change
25
+ //
26
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
27
+ // is untouched — the agentic channel is the only new conversation; advisory semantics are preserved
28
+ // (a family NEVER hard-locks or gates a BPMN sequence flow).
29
+ import type { AgenticHub, ConnectionRegistry, WebSocketChannelTransport } from "@nanobpm/agentic/channel";
30
+ import type { DataLayer, Logger } from "@nanobpm/urban";
31
+
32
+ /**
33
+ * The reusable handle the seam threads to every family module at mount time. A sibling family uses
34
+ * these — and only these — so it never re-mounts the transport, re-authenticates, or reaches into
35
+ * the boot script.
36
+ */
37
+ export interface AgenticContext {
38
+ /** The app-tier hub: attach a family message handler via `hub.registerFamilyHandler(...)`. */
39
+ readonly hub: AgenticHub;
40
+ /** The shared connection registry with liveness (presence detail is attached here by H1). */
41
+ readonly registry: ConnectionRegistry;
42
+ /** The listening WebSocket transport bound to the app's OWN port. */
43
+ readonly transport: WebSocketChannelTransport;
44
+ /** The app's SQLite data layer — the same store the advisory blackboard uses (may be absent). */
45
+ readonly data: DataLayer | undefined;
46
+ /** A structured logger for boot/shutdown lifecycle lines. */
47
+ readonly log: Logger;
48
+ }
49
+
50
+ /**
51
+ * One pluggable family module. A sibling slice implements this and exports it (default export, or a
52
+ * named `family` export) from a `*.family.ts` file under `app/agentic/families/`.
53
+ */
54
+ export interface AgenticFamily {
55
+ /** A stable, unique name (used for ordering diagnostics, `inspect()`, and teardown logging). */
56
+ readonly name: string;
57
+ /** Attach the family's behaviour to the hub/channel. May be async. */
58
+ mount(ctx: AgenticContext): void | Promise<void>;
59
+ /** Release anything `mount` acquired. Called in REVERSE registration order on shutdown. */
60
+ teardown?(): void | Promise<void>;
61
+ }
62
+
63
+ /**
64
+ * The seam itself: collects registered families, mounts them all on boot (in registration order),
65
+ * and tears them down in reverse on shutdown. Mounting is idempotent-guarded (each family mounts at
66
+ * most once) so a double `mountAll` can never double-attach a handler.
67
+ */
68
+ export class AgenticFamilyRegistry {
69
+ readonly #families: AgenticFamily[] = [];
70
+ readonly #mounted: AgenticFamily[] = [];
71
+ #isMounted = false;
72
+
73
+ /** Register a family. Rejects a duplicate name so two slices cannot silently claim one slot. */
74
+ register(family: AgenticFamily): void {
75
+ if (this.#isMounted) {
76
+ throw new Error(`cannot register agentic family "${family.name}" after mount`);
77
+ }
78
+ if (this.#families.some((f) => f.name === family.name)) {
79
+ throw new Error(`duplicate agentic family name "${family.name}"`);
80
+ }
81
+ this.#families.push(family);
82
+ }
83
+
84
+ /** Register several families at once (the loader hands the discovered set here). */
85
+ registerAll(families: Iterable<AgenticFamily>): void {
86
+ for (const family of families) this.register(family);
87
+ }
88
+
89
+ /** The registered family names, in registration order. Surfaced in `inspect()`/logs. */
90
+ names(): string[] {
91
+ return this.#families.map((f) => f.name);
92
+ }
93
+
94
+ /** Mount every registered family, in registration order. A no-op if already mounted. */
95
+ async mountAll(ctx: AgenticContext): Promise<void> {
96
+ if (this.#isMounted) return;
97
+ this.#isMounted = true;
98
+ try {
99
+ for (const family of this.#families) {
100
+ await family.mount(ctx);
101
+ // Track post-mount so a failure mid-mount only tears down what actually mounted.
102
+ this.#mounted.push(family);
103
+ }
104
+ } catch (err) {
105
+ // A mid-mount failure must not wedge the registry at #isMounted=true (which would make every
106
+ // later mountAll a silent no-op). Reuse the canonical teardown to reverse the partial mount and
107
+ // reset the flag, leaving the registry clean and re-mountable, then rethrow to the caller.
108
+ await this.teardownAll(ctx.log);
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Tear every mounted family down in REVERSE mount order. Each teardown is isolated: a throw is
115
+ * logged (when a logger is supplied) and swallowed so one family's failure cannot strand another's
116
+ * cleanup. Safe to call more than once; the second call is a no-op.
117
+ */
118
+ async teardownAll(log?: Logger): Promise<void> {
119
+ while (this.#mounted.length > 0) {
120
+ const family = this.#mounted.pop();
121
+ if (!family?.teardown) continue;
122
+ try {
123
+ await family.teardown();
124
+ } catch (err) {
125
+ log?.error("agentic family teardown failed", { family: family.name, err: String(err) });
126
+ }
127
+ }
128
+ this.#isMounted = false;
129
+ }
130
+ }
@@ -0,0 +1,151 @@
1
+ # ADR 0001 — Cross-repo epics, release-ordered integration, and generic artifact wait-gates
2
+
3
+ Status: **Proposed.**
4
+ Date: 2026-08-13.
5
+
6
+ > **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app's* agent
7
+ > workforce decomposes and integrates epics. The platform-wide ADRs live in
8
+ > `Magikcraft/nano-bpm/docs/adr` (referenced here by their number + repo, e.g. "nano-bpm ADR 0056").
9
+ > nano-workforce's own decisions start their own series here at 0001.
10
+
11
+ Relates to:
12
+ nano-bpm **ADR 0051** (nano-workforce — the crew orchestrator this app implements),
13
+ nano-bpm **ADR 0056** (the Nano agentic protocol — the first consumer/producer *pair* that forced this
14
+ question: the hub epic https://github.com/nanobpm/nano-workforce/issues/142 and the worker epic
15
+ https://github.com/jwulf/c8ctl-plugin-nano/issues/38 live in **different repos** yet share one
16
+ published contract),
17
+ the **review-ready poller** in `main.ts` (a bespoke "wait for an external condition, then correlate a
18
+ message" loop — the seed this ADR generalizes into a first-class wait-gate),
19
+ and nano-bpm **ADR 0059** (the app-hosted OpenAPI hook surface these gates would be signalled through).
20
+
21
+ ## Context
22
+
23
+ nano-workforce executes an epic as a **single-repo** unit, and every load-bearing piece assumes it:
24
+
25
+ - **The repo is epic-level, not task-level.** `Plan.repo` carries the repository; `PlanTask`
26
+ (`app/plan.ts`) has **no** `repo` field. Fan-out clones *the epic's* repo for every slice.
27
+ - **Integration converges on one base branch in one repo.** The epic lands on a base branch; slices
28
+ PR into it; the merge-loop trial-merges the set and merges the epic (nano-bpm ADR 0051 machinery:
29
+ wave gates, base-branch guards, merge-loop reconciliation).
30
+ - **There is no "publish" step and no "wait for the outside world" primitive.** The only wait nwf does
31
+ is the hand-rolled review-ready poller in `main.ts`, which polls GitHub and correlates a
32
+ `review-ready` message. It is not reusable and knows only about PR reviews.
33
+
34
+ But real delivery in this ecosystem is **cross-repo by construction**. Shared libraries
35
+ (`@nanobpm/urban`, `@nanobpm/agentic`) are published from one repo and consumed downstream (this app,
36
+ c8ctl). The `urban → nano-workforce → c8ctl` chain is exactly a producer→consumer graph across repos
37
+ with an npm publish in the middle. Building the agentic visibility plane surfaced three concrete gaps:
38
+ (a) no per-task repo, (b) no release/publish step, (c) no wait-for-external-artifact primitive.
39
+
40
+ The temptation is to answer all three at once by making nwf a cross-repo, release-orchestrating
41
+ engine. That is a real redesign of the integration model and its highest-risk parts. Before paying for
42
+ it, note that **most cross-repo coupling in practice is a *versioned contract*, not a merge order** —
43
+ and a contract can be consumed *after* it is published, with no live cross-repo sequencing at all.
44
+
45
+ ## Decision
46
+
47
+ ### 1. Prefer contract-coupled per-repo epics over cross-repo epics (the default)
48
+
49
+ When two sides of a feature share a **versioned contract** — a published package plus a **conformance
50
+ corpus** both sides are held to — split the work into **one single-repo epic per repo**, each building
51
+ against the **already-published** contract. They coordinate through the contract, never through a merge
52
+ order. Neither epic waits on the other's code.
53
+
54
+ The agentic visibility plane is exactly this and ships this way, as **two** epics:
55
+
56
+ - **producer:** https://github.com/jwulf/c8ctl-plugin-nano/issues/38 (`nano work` → REGISTER/SERVE/relay);
57
+ - **hub + cockpit:** https://github.com/nanobpm/nano-workforce/issues/142.
58
+
59
+ Both consume the published `@nanobpm/agentic` and are held to `@nanobpm/agentic/protocol/conformance`.
60
+ This keeps nwf's proven single-repo integration model **entirely intact** and is the default posture
61
+ for any producer/consumer pair that can be expressed against a stable contract.
62
+
63
+ ### 2. A generic artifact-readiness wait-gate (not npm-specific)
64
+
65
+ Generalize the review-ready poller into a **first-class, durable wait-gate**: a service task that
66
+ **polls a declared readiness probe with backoff until it is satisfied or a timeout escalates**, then
67
+ lets the flow proceed (or correlates a message). It is modeled on the engine (timer + receive), so a
68
+ worker or hub restart **resumes** the wait rather than losing it.
69
+
70
+ The probe is **declared as data, not code** — a `ReadinessProbe` descriptor with a `kind` and pluggable
71
+ matchers, so authors add readiness sources without editing the BPMN or the worker:
72
+
73
+ ```jsonc
74
+ // ReadinessProbe — the gate is agnostic to what "ready" means.
75
+ {
76
+ "kind": "http", // http | command | npm | oci | git-ref | github-release | github-check | file
77
+ "target": "https://example/health", // URL | shell command | "pkg@version" | "image:tag" | "owner/repo@ref" | path
78
+ "match": { "status": 200 }, // per-kind predicate (status/body, exit code/stdout, version present, digest, …)
79
+ "poll": { "everyMs": 15000, "timeoutMs": 1800000, "backoff": "exponential" },
80
+ "onTimeout": "escalate" // escalate (default) | fail | continue
81
+ }
82
+ ```
83
+
84
+ Invariants:
85
+
86
+ - **Never npm-specific.** `npm` is *one* kind among many; `command` is the escape hatch that subsumes
87
+ almost anything (`gh`, `curl`, `docker manifest inspect`, a custom probe) for cases no built-in kind
88
+ covers. Adding a kind is a new matcher, not a schema change.
89
+ - **Bounded.** A probe that never goes green must **time out and escalate** (mirroring the per-task
90
+ escalation path) — a hanging probe can never wedge a plan.
91
+ - **Idempotent / resumable.** The gate only *reads* readiness; it holds no state a re-run could corrupt,
92
+ so a restarted worker simply re-probes.
93
+
94
+ This is immediately useful well beyond releases: waiting on CI, a downstream deploy, an external
95
+ system, a human approval, or a produced artifact.
96
+
97
+ ### 3. The shared-library bump stays a manual maintainer seam — for now
98
+
99
+ A downstream version bump (e.g. `@nanobpm/urban` → this app) after an upstream release is handled by a
100
+ maintainer **outside** the epic, until §4 lands. It is cheap, low-risk, and rare relative to the
101
+ intra-repo work of an epic. This is the deliberate, documented seam that lets §1 stay simple: the
102
+ *only* cross-repo dependency in the agentic plane (nano-ide `UrbanApp.httpServer`,
103
+ https://github.com/nanobpm/nano-ide/issues/221 → an `@nanobpm/urban` release → a bump here) is a
104
+ one-line human step, not a reason to build a cross-repo engine.
105
+
106
+ ### 4. Release-ordered cross-repo integration (the "release DAG") — deferred, sketched
107
+
108
+ Some future work genuinely cannot decouple: a consumer needs a producer's **new** release *mid-epic*.
109
+ For those cases only, model integration as a **DAG across independent per-repo merge trains**:
110
+
111
+ ```
112
+ producer PRs → merge → publish → [artifact wait-gate §2] → consumer PRs open/build → merge
113
+ ```
114
+
115
+ This replaces the single-epic-branch assumption **for those cases**, and requires, in order:
116
+
117
+ 1. **Per-task repo.** Add `repo` to `PlanTask`, derive it from each sub-issue (`parseIssue` already
118
+ yields `owner/repo`), and thread it into the `io.nanobpm.agentTask.repository` clone header. The
119
+ merge/review/finalize workers are *already* repo-parameterized (they take `repo` per PR and load the
120
+ merge protocol per repo), so this is mostly plan/task plumbing.
121
+ 2. **A release task type.** bump version → merge → **§2 wait-gate on artifact availability** → signal
122
+ downstream. Publish is at-least-once; the task must tolerate a re-run (mirror the idempotent
123
+ `scripts/publish.mjs` "skip already-published" discipline).
124
+ 3. **Cross-train ordering.** A meta-plan (or a first-class multi-repo epic) that sequences the per-repo
125
+ trains and their gates.
126
+
127
+ This is its **own follow-up epic with its own design**. Do **not** build it speculatively — §1 removes
128
+ the need for the foreseeable roadmap, and §2 is the reusable building block it will stand on.
129
+
130
+ ## Consequences
131
+
132
+ - The agentic visibility plane ships **now** as two single-repo epics; nwf's integration model
133
+ (base-branch, trial-merge, merge-loop, wave gates) is untouched and unrisked.
134
+ - nwf gains a durable **"wait for the world"** primitive it currently fakes with the bespoke review
135
+ poller; the poller can later be re-expressed as one `github-check`/`http` gate.
136
+ - New surface to own: the `ReadinessProbe` kinds. A malformed or hanging probe is bounded by the
137
+ mandatory timeout+escalation, so it cannot stall a plan.
138
+ - Shared-library bumps stay manual until §4 — an accepted cost given their frequency.
139
+ - When §4 is eventually built, §1 + §2 mean it is *additive* (a new integration topology + a release
140
+ task) rather than a rewrite.
141
+
142
+ ## Open questions
143
+
144
+ - **Probe extensibility model:** a curated registry of `kind`s vs leaning on the `command` escape hatch
145
+ for the long tail — and how a probe's credentials/secrets are supplied without leaking into logs.
146
+ - **Where cross-train ordering lives (§4):** a meta-plan across existing epics, or a genuine
147
+ first-class multi-repo epic with per-task repos.
148
+ - **Per-repo divergence when per-task repo lands:** merge protocol, required checks, Copilot-review
149
+ provisioning (not available on every repo), and push auth all differ per repo.
150
+ - **Gate signalling:** in-flow receive task vs an out-of-band message correlated by an app-side poller
151
+ (the review-ready shape) — likely both, chosen per use.
package/main.ts CHANGED
@@ -16,8 +16,11 @@
16
16
  // The reviewer agent (job type `senior:pr-review`) is deliberately NOT hosted here — it is an
17
17
  // EXTERNAL worker. Point a coding-agent harness at that job type (the same one that services
18
18
  // the code-first twin) so the automated review stays decoupled from the orchestration.
19
+ import { Server } from "node:http";
19
20
  import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
21
+ import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
20
22
  import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
23
+ import { envVar } from "./app/version.ts";
21
24
 
22
25
  const PORT = Number(process.env.PR_REVIEW_PORT ?? 3000);
23
26
  const POLL_MS = Number(process.env.NANO_PR_POLL_MS ?? 60_000);
@@ -40,6 +43,30 @@ const engine = await createNanoSdkEngineClient({
40
43
  // signal handler would only stop the HTTP server, leaving the poller keeping us alive).
41
44
  const app = await runFromEnv({ engine, host, port: PORT, handleSignals: false });
42
45
 
46
+ // Agentic visibility channel (ADR 0056, epic #142). Ride the app's OWN HTTP server so the channel
47
+ // shares the app port (no sidecar). This is the ONLY main.ts wiring for the whole epic — sibling
48
+ // slices (H1/H3/H4) extend it by dropping a family module under `app/agentic/families/`, never here.
49
+ // Mount only when a shared identity secret is configured, so the app never exposes an
50
+ // unauthenticated upgrade; `app.httpServer` is a `node:http` Server once started (undefined on hosts
51
+ // that don't surface one, e.g. Deno).
52
+ let agentic: AgenticChannelHandle | undefined;
53
+ const agenticSecret = envVar("NANO_AGENTIC_SECRET") ?? envVar("NANO_PR_WEBHOOK_SECRET");
54
+ const httpServer = app.httpServer;
55
+ if (httpServer instanceof Server) {
56
+ if (agenticSecret) {
57
+ agentic = await mountAgenticChannel({
58
+ server: httpServer,
59
+ secret: agenticSecret,
60
+ data: app.data,
61
+ log: app.log,
62
+ });
63
+ } else {
64
+ app.log.warn("agentic channel not mounted: set NANO_AGENTIC_SECRET (or NANO_PR_WEBHOOK_SECRET)");
65
+ }
66
+ } else if (agenticSecret) {
67
+ app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
68
+ }
69
+
43
70
  // Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
44
71
  // overlap two passes (which could double-signal `review-ready`); the next pass is scheduled
45
72
  // only after the previous one settles.
@@ -59,6 +86,13 @@ async function drainAndExit(): Promise<void> {
59
86
  if (shuttingDown) return;
60
87
  shuttingDown = true;
61
88
  if (pollTimer) clearTimeout(pollTimer);
89
+ // Tear the agentic families + hub down (releases the WS clients) before the app stops its HTTP
90
+ // server, which the channel shares.
91
+ if (agentic) {
92
+ try {
93
+ await agentic.teardown();
94
+ } catch { /* best-effort channel shutdown */ }
95
+ }
62
96
  try {
63
97
  await app.stop();
64
98
  } catch { /* already stopped */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -46,7 +46,8 @@
46
46
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
47
47
  },
48
48
  "dependencies": {
49
- "@nanobpm/urban": "^0.45.0"
49
+ "@nanobpm/agentic": "^0.1.0",
50
+ "@nanobpm/urban": "^0.46.0"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@biomejs/biome": "^2.4.11",
@@ -40,6 +40,19 @@ decomposition **from the issues**, then test the plan against it.
40
40
  `dependsOn` it. Reject a `dependsOn` edge added purely to **serialise the landing** of otherwise
41
41
  parallel work — that is not a fix, it just needlessly serialises implementation; name the pair,
42
42
  the shared surface, and which of (a)/(b) the planner should apply.
43
+ - **Package fragmentation (Conway artifact).** The plan gives a cohesive body of work its own
44
+ published unit **per task** — N tasks ⇒ N npm packages / crates / services — where one library
45
+ with the slices as **subpath exports / subdirectories** would serve the same consumers. This is
46
+ the task decomposition leaking into the artifact's module boundaries: separate packages are the
47
+ frictionless maximum of independence, so they get chosen by default, then have to be
48
+ unfragmented by hand (and each extra published unit is a publish/credentials bootstrap +
49
+ changelog + version cadence forever). Try to disprove that each **new** published-package
50
+ boundary is **consumer-driven**: is there a distinct external consumer of *it* alone, an
51
+ intentional independent release cadence, or a different runtime tier? If not for a given
52
+ package, flag it and demand the remedy: **coarsen the siblings into one package** exposing
53
+ subpaths, landing a **wave-0 scaffold task** (manifest with the full exports map
54
+ pre-declared + one empty subdirectory per slice) first if the shared manifest would otherwise be
55
+ a merge collision. Name the packages that lack a consumer-facing justification.
43
56
  - **Non-self-contained prompt.** A task's `prompt` can't be executed without reasoning the planner
44
57
  kept to itself.
45
58
  - **Sequencing intent violated.** If the issues state an ordering (e.g. "audit the foundation
package/prompts/plan.md CHANGED
@@ -157,6 +157,41 @@ Choose (1) when the surface *is* the task; choose (2) when the surface is shared
157
157
  infrastructure several distinct tasks sit on top of. Reserve plain parallel tasks
158
158
  (no shared surface) for genuinely disjoint work.
159
159
 
160
+ ### Packaging cohesion → one library, subpaths, not a package per task
161
+
162
+ The shared-surface rule above pushes toward independence, and independence has a
163
+ seductive failure mode: giving each task its **own published unit** (npm package,
164
+ crate, service) is the *frictionless maximum* of independence — a separate
165
+ manifest, separate exports, separate directory mean zero shared surface and zero
166
+ merge collision. So a plan that slices a single cohesive library into N tasks will,
167
+ left alone, tend to emit **N packages** — one per task. That is not a design; it is
168
+ your task decomposition leaking into the artifact's module boundaries (Conway's
169
+ Law). It has to be unfragmented by hand later, and each extra published unit is a
170
+ one-time publish/credentials bootstrap plus a changelog and version cadence forever.
171
+
172
+ So, before you slice: **a new published unit requires a consumer-facing
173
+ justification, not merely "this is an independent task."** A new package/crate/
174
+ service is warranted only when at least one is true:
175
+
176
+ - a **distinct external consumer** imports it on its own (something outside the
177
+ family depends on *it*, not on its siblings);
178
+ - it needs an **independent release cadence** (versioned and shipped separately on
179
+ purpose); or
180
+ - it is a **different runtime tier** (e.g. a browser bundle vs. a server library vs.
181
+ a worker client) that consumers install separately.
182
+
183
+ Absent one of those, the default is **one library, with the slices as subpath
184
+ exports / subdirectories inside it** (the shape of a package that exposes several
185
+ surfaces — e.g. `./runtime`, `./toolkit`, `./worker` — from a single manifest). The
186
+ slices stay independent to *write*: use the
187
+ **wave-0 scaffold task** (option 2 above) to land the library skeleton first — its
188
+ manifest with the **full exports map pre-declared** and an empty subdirectory per
189
+ slice — so every sibling only **adds files inside its own subdirectory** and never
190
+ touches the shared manifest or barrel. That buys parallel-merge independence **and**
191
+ a cohesive published artifact at the same time. Reserve genuinely separate packages
192
+ for the consumer-facing cases above, and say in the task prompt which consumer
193
+ justifies the split.
194
+
160
195
  ## Output contract
161
196
 
162
197
  Write a JSON object of **result variables** to the file named by the