@nanobpm/nano-workforce 0.50.0 → 0.52.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.
@@ -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,30 @@
1
+ -- Agentic visibility plane — presence & registry (ADR 0056, H1 / #144).
2
+ --
3
+ -- The durable supply mirror behind the agentic channel's presence family. A worker that opens the
4
+ -- channel and sends `register` lands one row here (keyed by its instance id) carrying its declared
5
+ -- enrolment capability (cognition/weight/family/host — an ENROLMENT attribute, NEVER a routing
6
+ -- token), the connection it registered on, and its own heartbeat-refreshed `last_seen` liveness.
7
+ -- Heartbeats refresh `last_seen`; `deregister`, an observed disconnect, or the presence-TTL sweep
8
+ -- remove the row. This is the read-only supply feed the enrolment epic (#152) reads — it is advisory
9
+ -- and NEVER gates a BPMN sequence flow.
10
+ --
11
+ -- The very same DDL is the single source of truth the runtime's `PresenceStore` applies through
12
+ -- `ensureSchema()` (@nanobpm/agentic/presence, `PRESENCE_SCHEMA_SQL`). Keeping the two application
13
+ -- paths (this boot migration and the store's guard) statement-for-statement identical is what stops
14
+ -- a production/boot schema drift. Forward-only and additive: `CREATE ... IF NOT EXISTS` only.
15
+ --
16
+ -- This is the reserved prefix H0 pre-allocated for H1 (023) so parallel wave-1 siblings never
17
+ -- independently grab "the next" migration number (H3 → 024_agentic_transcript, H4 → 025_agentic_blackboard).
18
+ CREATE TABLE IF NOT EXISTS agentic_presence (
19
+ instance TEXT PRIMARY KEY,
20
+ connection_id TEXT NOT NULL,
21
+ identity TEXT NOT NULL,
22
+ cognition TEXT,
23
+ weight REAL,
24
+ family TEXT,
25
+ host TEXT,
26
+ registered_at TEXT NOT NULL,
27
+ last_seen INTEGER NOT NULL
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_agentic_presence_last_seen ON agentic_presence (last_seen);
30
+ CREATE INDEX IF NOT EXISTS idx_agentic_presence_connection ON agentic_presence (connection_id);
@@ -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.