@nanobpm/nano-workforce 0.135.0 → 0.137.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,19 @@
1
+ ## [0.137.0](https://github.com/nanobpm/nano-workforce/compare/v0.136.0...v0.137.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * single-source plan-family read models onto @nanobpm/urban defineRollup/defineReadModel ([#493](https://github.com/nanobpm/nano-workforce/issues/493)) ([#512](https://github.com/nanobpm/nano-workforce/issues/512)) ([2837636](https://github.com/nanobpm/nano-workforce/commit/283763689cc2d0780393efe7c51b09d7c2bef767)), closes [468/#469](https://github.com/468/nano-workforce/issues/469) [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#503](https://github.com/nanobpm/nano-workforce/issues/503)
6
+
7
+ ## [0.136.0](https://github.com/nanobpm/nano-workforce/compare/v0.135.0...v0.136.0) (2026-08-24)
8
+
9
+ ### Features
10
+
11
+ * **delivery:** agent-node classifier-emit contract so S7 guarded branches fire ([#506](https://github.com/nanobpm/nano-workforce/issues/506)) ([#509](https://github.com/nanobpm/nano-workforce/issues/509)) ([f3acd5b](https://github.com/nanobpm/nano-workforce/commit/f3acd5bd5977fe3b12797f7d1e3acb7d5e67eb8c))
12
+
13
+ ### Bug Fixes
14
+
15
+ * **deps:** update dependency @nanobpm/urban to ^0.82.0 ([#482](https://github.com/nanobpm/nano-workforce/issues/482)) ([81decc1](https://github.com/nanobpm/nano-workforce/commit/81decc1b8ebd53b2835aaecbb369c5347371ceb3))
16
+
1
17
  ## [0.135.0](https://github.com/nanobpm/nano-workforce/compare/v0.134.0...v0.135.0) (2026-08-24)
2
18
 
3
19
  ### Features
package/app/delivery.ts CHANGED
@@ -3,13 +3,38 @@
3
3
  // without importing `service.ts` — which imports `pollLineage` back from `lineage.ts` and would
4
4
  // otherwise form a `service.ts` ↔ `lineage.ts` module cycle (fragile in ESM). This is the single
5
5
  // source of truth for both; `service.ts` re-uses it and remains free to import `pollLineage`.
6
+ //
7
+ // ADR-0065 / issue #493. `deriveDelivery`/`deriveEpicBucket`/`epicIsAcknowledgeable` are no longer
8
+ // hand-authored oracles: they are now THIN ADAPTERS over the ONE `plan_read_model` declaration
9
+ // (app/planReadModel.ts) and the `plan_delivery_counts` rollup (app/planRollups.ts). Each routes
10
+ // through the framework's runtime backend — `planDeliveryCounts.reduce` (the TS group-reduce) for the
11
+ // slice-PR counts, and `planReadModel.evaluate` (the TS derivation) for the per-row `delivery` /
12
+ // `list_bucket` / `ack_open` signals — so these façades and the superseding SQLite VIEWs (migrations
13
+ // 082/083) compute byte-identical values by construction, guarded by `assertReadModelParity` /
14
+ // `assertRollupParity` (app/planReadModel.test.ts). Only the pre-formatted `label` display string is
15
+ // still assembled here (D3 — display formatting stays out of the framework AST).
6
16
 
7
- /** A PR is "done" in exactly these states; everything else (converging, waiting_review,
8
- * escalated, and the merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight. `converged`
17
+ import { TERMINAL_STATUSES } from "./deliveryStatuses.ts";
18
+ import {
19
+ DELIVERY_COUNTS_LOOKUP,
20
+ EFFECTIVE_STATUS_COLUMN,
21
+ planReadModel,
22
+ WAVE_PROGRESS_LOOKUP,
23
+ } from "./planReadModel.ts";
24
+ import { PR_TRACKING_RELATION, planDeliveryCounts } from "./planRollups.ts";
25
+
26
+ /** The synthetic correlation key threaded through the adapters: the base row's `plan_key` and each
27
+ * synthesised slice `plan_tasks`/`plan_delivery_counts` row share this value so the compiled rollup
28
+ * lookup / group-reduce correlate exactly as they do on real rows (mirrors app/stage.ts `SELF_KEY`). */
29
+ const SELF_KEY = "self";
30
+
31
+ /** The PR statuses that are TERMINAL for delivery — re-exported from the canonical leaf module
32
+ * (app/deliveryStatuses.ts) that BOTH this façade's consumers and the `plan_delivery_counts` rollup
33
+ * (app/planRollups.ts) read, so the SQL VIEW counts and the TS adapters can never drift. `converged`
9
34
  * is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
10
- * transitions into the merge stage and lands as `merged`. The status endpoint and the cancel
11
- * guard both key off this set. */
12
- export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
35
+ * transitions into the merge stage and lands as `merged`. The status endpoint and the cancel guard
36
+ * both key off this set. */
37
+ export { TERMINAL_STATUSES };
13
38
 
14
39
  /** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
15
40
  * means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
@@ -41,38 +66,35 @@ export function deriveDelivery(
41
66
  planStatus: string,
42
67
  prStatuses: readonly string[],
43
68
  ): DeliveryRollup {
44
- const prsOpened = prStatuses.length;
45
- let prsMerged = 0;
46
- let prsInFlight = 0;
47
- for (const s of prStatuses) {
48
- if (s === "merged") prsMerged++;
49
- else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
50
- }
51
- // `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
52
- // least one slice PR exists; otherwise there is nothing to have landed yet.
53
- if (planStatus !== "done" || prsOpened === 0) {
54
- return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
55
- }
56
- if (prsInFlight > 0) {
57
- return {
58
- delivery: "converging",
59
- label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
60
- prsOpened,
61
- prsMerged,
62
- prsInFlight,
63
- };
64
- }
65
- if (prsMerged === prsOpened) {
66
- return {
67
- delivery: "landed",
68
- label: `${prsOpened}/${prsOpened} slices merged`,
69
- prsOpened,
70
- prsMerged,
71
- prsInFlight,
72
- };
73
- }
74
- // Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
75
- return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
69
+ // Lower the caller's per-slice PR statuses into the two LEAF relations the `plan_delivery_counts`
70
+ // rollup reduces over (`plan_tasks` LEFT JOIN `pull_requests__tracking`): one opened slice per PR
71
+ // status. Callers already resolve the PR's terminal-folded `derived_status` (service.ts reads it via
72
+ // `prsTracking`), so it is fed under the tracking relation's `derived_status` column — the SAME
73
+ // column the managed VIEW joins — and the framework group-reduce folds the SAME three counts
74
+ // (`prs_opened`/`prs_merged`/`prs_in_flight`) the VIEW does; a missing/dangling PR status counts as
75
+ // in-flight, never false-`landed`.
76
+ const taskRows = prStatuses.map((_, i) => ({ plan_key: SELF_KEY, pr_key: `pr${i}`, wave: null, status: "opened" }));
77
+ const prRows = prStatuses.map((s, i) => ({ pr_key: `pr${i}`, derived_status: s }));
78
+ const [counts] = planDeliveryCounts.reduce({ plan_tasks: taskRows, [PR_TRACKING_RELATION]: prRows });
79
+ const prsOpened = Number(counts?.prs_opened ?? 0);
80
+ const prsMerged = Number(counts?.prs_merged ?? 0);
81
+ const prsInFlight = Number(counts?.prs_in_flight ?? 0);
82
+
83
+ // Derive the `delivery` signal from the ONE `plan_read_model` declaration, feeding the folded counts
84
+ // as the `plan_delivery_counts` lookup's single candidate row (the TS twin of the VIEW's LEFT JOIN).
85
+ const raw = planReadModel.evaluate(
86
+ { plan_key: SELF_KEY, status: planStatus, [EFFECTIVE_STATUS_COLUMN]: planStatus, acknowledged_at: null },
87
+ undefined,
88
+ { [DELIVERY_COUNTS_LOOKUP]: counts ? [counts] : [], [WAVE_PROGRESS_LOOKUP]: [] },
89
+ ).delivery;
90
+ const delivery: Delivery | null = raw === "converging" || raw === "landed" ? raw : null;
91
+
92
+ // The pre-formatted human label stays hand-authored here (D3 — display formatting is out of the
93
+ // framework AST); it mirrors the `plan_read_model` VIEW's `delivery_label` display column (083).
94
+ let label: string | null = null;
95
+ if (delivery === "converging") label = `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`;
96
+ else if (delivery === "landed") label = `${prsOpened}/${prsOpened} slices merged`;
97
+ return { delivery, label, prsOpened, prsMerged, prsInFlight };
76
98
  }
77
99
 
78
100
  /** The `plan.status` values that mean the epic's fan-out lifecycle is still LIVE — the planner is
@@ -108,16 +130,8 @@ export function deriveEpicBucket(
108
130
  delivery: string | null | undefined,
109
131
  acknowledgedAt: string | null | undefined,
110
132
  ): "active" | "history" {
111
- if (EPIC_LIVE_STATUSES.some((s) => s === status)) return "active";
112
- if (status === "done") {
113
- // A still-`converging` epic is Active regardless of any (stray) acknowledged_at — it is genuinely
114
- // working and is not acknowledgeable, so it can never be ticked off mid-flight (fail-closed).
115
- if (delivery === "converging") return "active";
116
- // Otherwise `done` — landed or resolved-not-landed/poller-pending (`delivery = null`): stay Active
117
- // until the operator dismisses it, so a just-`done` epic never flickers into History.
118
- return (acknowledgedAt ?? null) === null ? "active" : "history";
119
- }
120
- return "history";
133
+ const raw = evalEpicRow(status, delivery, acknowledgedAt).list_bucket;
134
+ return raw === "active" ? "active" : "history";
121
135
  }
122
136
 
123
137
  /** True iff an epic carries the operator "Dismiss" (acknowledge) affordance — a `done` epic whose
@@ -133,5 +147,43 @@ export function epicIsAcknowledgeable(
133
147
  status: string,
134
148
  delivery: string | null | undefined,
135
149
  ): boolean {
136
- return status === "done" && delivery !== "converging";
150
+ // The `ack_open` derivation folds in the `acknowledged_at IS NULL` gate; evaluate it with a null
151
+ // acknowledgement to isolate the "acknowledgeABLE" predicate (`done` ∧ resolved) from "ack OPEN".
152
+ return evalEpicRow(status, delivery, null).ack_open === 1;
153
+ }
154
+
155
+ /** Evaluate the `plan_read_model` per-row derivations (`list_bucket`/`ack_open`) for an epic whose
156
+ * effective status, already-computed `delivery`, and acknowledgement the caller supplies. The model
157
+ * recomputes `delivery` internally from its `plan_delivery_counts` lookup + base status, so — the twin
158
+ * of app/stage.ts's `openTaskRows` synthesis — we SYNTHESISE the lookup row + base status that make the
159
+ * model's internal `delivery` equal the passed value: `converging`/`landed` need a `done` base status
160
+ * with an in-flight / all-merged count row; a null/other `delivery` needs an empty count (`prs_opened =
161
+ * 0` ⇒ the model's first CASE arm ⇒ null). The status-classifying arms read the effective status under
162
+ * `derived_status`, so the caller's `status` is fed there verbatim. */
163
+ function evalEpicRow(
164
+ status: string,
165
+ delivery: string | null | undefined,
166
+ acknowledgedAt: string | null | undefined,
167
+ ): Record<string, unknown> {
168
+ const merged = delivery === "landed";
169
+ const inFlight = delivery === "converging";
170
+ const opened = merged || inFlight;
171
+ const dcRow = {
172
+ plan_key: SELF_KEY,
173
+ prs_opened: opened ? 1 : 0,
174
+ prs_merged: merged ? 1 : 0,
175
+ prs_in_flight: inFlight ? 1 : 0,
176
+ };
177
+ return planReadModel.evaluate(
178
+ {
179
+ plan_key: SELF_KEY,
180
+ // Force the model's internal `delivery` to the passed value: a `done` base status enables the
181
+ // non-null arms for converging/landed; any status with a zero-opened count folds to null.
182
+ status: opened ? "done" : status,
183
+ [EFFECTIVE_STATUS_COLUMN]: status,
184
+ acknowledged_at: acknowledgedAt ?? null,
185
+ },
186
+ undefined,
187
+ { [DELIVERY_COUNTS_LOOKUP]: [dcRow], [WAVE_PROGRESS_LOOKUP]: [] },
188
+ );
137
189
  }
@@ -277,6 +277,110 @@ test("S7 deploy+route: the green default branch SKIPS `migrate` and rides the el
277
277
  assert(r.releaseRan, "the green outcome still reaches `release` via the else-flow (proof the exclusive merge fires on one token)");
278
278
  });
279
279
 
280
+ // ── #506: the REAL agentic-worker classifier-emit contract drives a guarded split ──────────────────
281
+ // The S7 stubs above (`() => ({ result: outcome })`) prove the ENGINE routes on a published fact, but a
282
+ // bare `{ result }` is NOT what a real `senior:*` fleet agent returns — it completes with the whole
283
+ // Output-contract envelope (`{ status, summary, pr, … }`) and never a bare fact. So the gap #506 closes
284
+ // is: (a) the node's declared `emits` must be threaded into the agent's `appendPrompt` so a real agent
285
+ // is TOLD to surface the fact, and (b) the fact rides that SAME envelope as an extra top-level field.
286
+ // This graph proves both against the real engine: the `adopt` node declares `emits: [result]` and is
287
+ // serviced by a worker that (1) ASSERTS the emit contract reached it via `appendPrompt` — proving the
288
+ // runner actually delivers the instruction, not a test stub — and (2) returns the full envelope with the
289
+ // fact folded in, exactly as a contract-following agent would. Both branches are driven end to end.
290
+ const GUARDED_ADOPT_REAL: DeliveryGraph = {
291
+ name: "adopt runbook (real agent)",
292
+ nodes: [
293
+ {
294
+ id: "adopt",
295
+ kind: "agent",
296
+ agent: { jobType: "senior:feature", prompt: "Adopt the published package into this consumer and open a PR." },
297
+ emits: [{ name: "result", type: "string", description: "breaking | compatible" }],
298
+ },
299
+ { id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
300
+ { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "rel-real-1" } },
301
+ ],
302
+ edges: [
303
+ { from: "adopt", to: "migrate", when: "adopt.result", equals: "breaking" },
304
+ { from: "adopt", to: "release", default: true },
305
+ { from: "migrate", to: "release" },
306
+ ],
307
+ };
308
+
309
+ /** Drive `GUARDED_ADOPT_REAL` with a worker that behaves like a REAL contract-following `senior:feature`
310
+ * agent: it reads the emit contract the runner threaded into its `appendPrompt`, then completes with the
311
+ * full Output-contract envelope carrying the classifier fact as a top-level field. Returns whether the
312
+ * contract actually reached the agent, plus which branches ran. */
313
+ async function driveGuardedRealAgent(outcome: "breaking" | "compatible"): Promise<{
314
+ state: string;
315
+ contractDelivered: boolean;
316
+ factSurfaced: boolean;
317
+ migrateRan: boolean;
318
+ releaseRan: boolean;
319
+ }> {
320
+ const engine = await createWasmEngineClient();
321
+ try {
322
+ let contractDelivered = false;
323
+ let factSurfaced = false;
324
+ let migrateRan = false;
325
+ let releaseRan = false;
326
+
327
+ await engine.registerWorker("senior:feature", async (job) => {
328
+ const appendPrompt = String((job.variables as Record<string, unknown> | undefined)?.appendPrompt ?? "");
329
+ // (a) The classifier emit contract MUST have reached the agent via its steering channel — this is
330
+ // the #506 fix (a plain `senior:feature` seed would carry no such instruction).
331
+ contractDelivered =
332
+ appendPrompt.includes("Classifier emit contract") &&
333
+ appendPrompt.includes("`result`") &&
334
+ appendPrompt.includes("AGENT_RESULT_FILE");
335
+ factSurfaced = appendPrompt.includes("`result`");
336
+ // (b) A real agent completes with the WHOLE Output-contract envelope, folding the declared fact in
337
+ // as an extra top-level field — NOT a bare `{ result }` stub.
338
+ return { status: "opened", summary: `adopt done (${outcome})`, pr: "owner/repo#900", result: outcome };
339
+ });
340
+ await engine.registerWorker("senior:migrate", async () => {
341
+ migrateRan = true;
342
+ return { status: "opened", summary: "migrated", pr: "owner/repo#901" };
343
+ });
344
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
345
+ releaseRan = true;
346
+ return {};
347
+ });
348
+
349
+ const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL);
350
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
351
+ const key = run.handle.processInstanceKey;
352
+
353
+ let state = "?";
354
+ for (let round = 0; round < MAX_ROUNDS; round++) {
355
+ await engine.drain();
356
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
357
+ assert(pi, `no process instance snapshot for ${key}`);
358
+ state = pi.state ?? "?";
359
+ if (state === "COMPLETED" || state === "TERMINATED") break;
360
+ }
361
+ return { state, contractDelivered, factSurfaced, migrateRan, releaseRan };
362
+ } finally {
363
+ await engine.close();
364
+ }
365
+ }
366
+
367
+ test("#506 deploy+route: a REAL contract-following agent's envelope carries the classifier fact and routes the BREAKING branch through `migrate`", async () => {
368
+ const r = await driveGuardedRealAgent("breaking");
369
+ assert(r.contractDelivered, "the emit contract must reach the agent via its threaded appendPrompt (the #506 fix)");
370
+ assert(r.factSurfaced, "the declared fact must be named to the agent");
371
+ assertEquals(r.state, "COMPLETED", "the breaking branch must run to a COMPLETED instance");
372
+ assert(r.migrateRan, "the breaking outcome (returned inside the real Output-contract envelope) must route through `migrate`");
373
+ assert(r.releaseRan, "both branches must re-converge on `release`");
374
+ });
375
+
376
+ test("#506 deploy+route: the SAME real agent returning `compatible` in its envelope rides the default flow, SKIPPING `migrate`", async () => {
377
+ const r = await driveGuardedRealAgent("compatible");
378
+ assert(r.contractDelivered, "the emit contract must reach the agent via its threaded appendPrompt (the #506 fix)");
379
+ assertEquals(r.state, "COMPLETED", "the compatible branch must run to a COMPLETED instance");
380
+ assert(!r.migrateRan, "the compatible outcome must NOT route through `migrate` — the envelope's `result` rides the default flow");
381
+ assert(r.releaseRan, "the compatible outcome still reaches `release` via the else-flow");
382
+ });
383
+
280
384
  test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge — the untaken leaf never blocks completion", async () => {
281
385
  // Mode D: `adopt` routes a missing surface to an escalate (human) leaf, else to a `done` connector
282
386
  // leaf. On the default path the escalate leaf never fires; an exclusive End merge must still let the
@@ -119,6 +119,39 @@ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt
119
119
  assertEquals(ack?.nodeId, "ack");
120
120
  });
121
121
 
122
+ test("agent node classifier-emit contract (#506): a declared `emits` threads the emit instruction into appendPrompt; a no-emit node leaves it untouched", async () => {
123
+ // #506: a guarded split (S7) routes on a producer's emitted scalar, published from the engine
124
+ // variable named exactly after the fact. A real `senior:*` agent completes with the Output-contract
125
+ // envelope and would never return that fact unless TOLD — so an agent node that declares `emits`
126
+ // must carry the emit contract in its `appendPrompt` (its only steering channel), while a plain
127
+ // implementation node (no emits) must be byte-for-byte unchanged.
128
+ const graph: DeliveryGraph = {
129
+ name: "classifier",
130
+ nodes: [
131
+ { id: "adopt", kind: "agent", agent: { jobType: "senior:feature", prompt: "adopt the package" }, emits: [{ name: "result", type: "string", description: "breaking | compatible" }] },
132
+ { id: "plain", kind: "agent", agent: { jobType: "senior:feature", prompt: "just implement it" } },
133
+ ],
134
+ edges: [{ from: "adopt.result", to: "plain", when: "adopt.result", equals: "breaking" }, { from: "adopt", to: "plain", default: true }],
135
+ };
136
+ const p = await prepareOk(graph);
137
+ const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
138
+ const adopt = agents.find((v) => String(v.appendPrompt).startsWith("adopt the package"));
139
+ const plain = agents.find((v) => String(v.appendPrompt).startsWith("just implement it"));
140
+
141
+ // The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact.
142
+ assert(adopt, "the emit-declaring agent node must be seeded");
143
+ const adoptPrompt = String(adopt?.appendPrompt);
144
+ assert(adoptPrompt.startsWith("adopt the package"), "the authored prompt is preserved as the prefix");
145
+ assert(adoptPrompt.includes("Classifier emit contract"), `the emit contract must be threaded in, got: ${adoptPrompt}`);
146
+ assert(adoptPrompt.includes("`result`") && adoptPrompt.includes("(string)"), "the declared fact name + type must be surfaced to the agent");
147
+ assert(adoptPrompt.includes("breaking | compatible"), "the fact's optional description rides the contract");
148
+ assert(adoptPrompt.includes("AGENT_RESULT_FILE"), "the contract names the completion channel the fact rides");
149
+
150
+ // A node that declares NO facts is untouched — appendPrompt is exactly the authored prompt.
151
+ assertEquals(plain?.appendPrompt, "just implement it");
152
+ });
153
+
154
+
122
155
  test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
123
156
  const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
124
157
  (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
@@ -170,6 +170,39 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
170
170
  .replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
171
171
  }
172
172
 
173
+ /** Render the classifier-emit contract appended to an `agent` node's `appendPrompt` (issue #506) — the
174
+ * instruction that turns a declared `emits[]` into completion variables a downstream guarded split (S7)
175
+ * can route on. A `senior:*` fleet agent completes with the Output-contract envelope (`status`,
176
+ * `summary`, `pr`, …); the delivery output ioMapping instead publishes the engine variable named exactly
177
+ * after each fact (`factSourceVar` → `fact.name`), so the agent must ALSO return each declared fact as a
178
+ * TOP-LEVEL field of that same result JSON. This block tells it so, deriving entirely from the node's
179
+ * declared `emits` (no second source of truth). Empty for a no-emit node → the prompt is unchanged, so a
180
+ * plain implementation node behaves exactly as before. Deterministic: fixed wording, facts in declared
181
+ * order, so identical graphs still compile+seed byte-identically. */
182
+ export function renderEmitContract(emits: readonly DeliveryFact[]): string {
183
+ if (emits.length === 0) return "";
184
+ const facts = emits.map((f) => `- \`${f.name}\` (${f.type})${f.description ? ` — ${f.description}` : ""}`);
185
+ return [
186
+ "",
187
+ "",
188
+ "---",
189
+ "",
190
+ "## Classifier emit contract (delivery graph)",
191
+ "",
192
+ "This node is a PRODUCER in a delivery graph: a downstream **guarded split** routes on the typed",
193
+ "fact(s) below. In ADDITION to your normal result fields (`status`, `summary`, `pr`, …), the",
194
+ "structured result you write to `AGENT_RESULT_FILE` MUST include these TOP-LEVEL fields, each a",
195
+ "bare scalar of the declared type:",
196
+ "",
197
+ ...facts,
198
+ "",
199
+ "The value you return for each fact IS the routing decision — a downstream edge fires only when the",
200
+ "fact equals a specific literal, otherwise the graph takes the `default` (else) branch. If you",
201
+ "genuinely cannot determine a fact, OMIT it (the default branch is taken) rather than guessing.",
202
+ ].join("\n");
203
+ }
204
+
205
+
173
206
  /** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
174
207
  * subProcess ioMapping pulls. Total over the closed kind set. */
175
208
  function buildNodeInput(
@@ -177,8 +210,21 @@ function buildNodeInput(
177
210
  ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
178
211
  ): NodeInput {
179
212
  switch (node.kind) {
180
- case "agent":
181
- return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
213
+ case "agent": {
214
+ // Classifier-emit contract (issue #506). A `senior:*` fleet agent's real completion is the
215
+ // Output-contract envelope (`{ status, summary, pr, … }`) — it does NOT return a bare fact, so a
216
+ // node's declared `emits` would never appear and a downstream GUARDED split (S7) could only ever
217
+ // take its `default` branch. Close the gap the same way `factSourceVar` already reads it: the
218
+ // output ioMapping publishes the engine variable named exactly after each fact, so the agent must
219
+ // return `{ <fact>: <value> }` AS A TOP-LEVEL field of its result JSON (the same channel that
220
+ // carries `status`/`summary`/`pr`). The agent only knows to do this if it is TOLD — so the
221
+ // declared emits are rendered into the node's `appendPrompt` (its sole steering channel; the
222
+ // delivery agent node carries no base-prompt resource), keeping `emits` the single source of
223
+ // truth. A no-emit node appends nothing, so a plain implementation node is unchanged.
224
+ const basePrompt = node.agent.prompt ?? "";
225
+ const emits = Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
226
+ return { jobType: node.agent.jobType, appendPrompt: basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
227
+ }
182
228
  case "wait": {
183
229
  const probe = parseProbe(node.wait);
184
230
  return {
@@ -0,0 +1,13 @@
1
+ // The PR statuses that are TERMINAL for epic delivery — a slice PR in any of these is resolved (not
2
+ // in flight). This is the ONE canonical set, factored into a dependency-neutral leaf module (imports
3
+ // nothing) so BOTH sides of the single-sourced delivery derivation read the SAME value and can never
4
+ // drift: the runtime adapters + consumers via `app/delivery.ts` (which re-exports it as
5
+ // `TERMINAL_STATUSES`), and the `plan_delivery_counts` / `plan_wave_counts` rollups' in-flight fold via
6
+ // `app/planRollups.ts`. Adding or removing a terminal state here changes both the SQL VIEW counts and
7
+ // the TS reduce at once.
8
+ //
9
+ // `converged` is terminal only in review-only mode (AUTO_MERGE off); with auto-merge on, a converged PR
10
+ // transitions into the merge stage and lands as `merged`. `merged` is the landed state; `abandoned` is
11
+ // the resolved-not-landed state. Everything else (converging, waiting_review, escalated, and the
12
+ // merge-stage waiting_deps/waiting_merge/waiting_lane/queued) is in flight.
13
+ export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];