@nanobpm/nano-workforce 0.49.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.
@@ -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.49.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.44.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