@nanobpm/nano-workforce 0.180.0 → 0.181.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,9 @@
1
+ ## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
2
+
3
+ ### Features
4
+
5
+ * delivery-graph MCP DX — interleaved sequence gates, paginated guide sections, stage-supersede visibility ([#741](https://github.com/nanobpm/nano-workforce/issues/741)) ([6f10e8a](https://github.com/nanobpm/nano-workforce/commit/6f10e8a25721645af4e5f932f2cbaa9ad6025b70)), closes [#740](https://github.com/nanobpm/nano-workforce/issues/740) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#740](https://github.com/nanobpm/nano-workforce/issues/740)
6
+
1
7
  ## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
2
8
 
3
9
  ### Features
@@ -10,9 +10,11 @@ import { test } from "node:test";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
  import {
12
12
  GUIDE_SECTIONS,
13
+ GUIDE_SECTION_PAGE_DEFAULT,
13
14
  guideToc,
14
15
  renderAgentGuide,
15
16
  renderGuideSection,
17
+ renderGuideSectionChunk,
16
18
  splitGuideSections,
17
19
  } from "./agentGuide.ts";
18
20
 
@@ -105,6 +107,91 @@ test("an unknown section id resolves to undefined (the op turns that into a 400)
105
107
  assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
106
108
  });
107
109
 
110
+ // ── Paginated section retrieval (issue #740) ────────────────────────────────────────────────────
111
+
112
+ test("renderGuideSectionChunk: paging through a section reassembles it exactly, chunk by chunk", () => {
113
+ const base = "https://x/app/api";
114
+ const full = renderGuideSection("delivery-graphs", base)!;
115
+ const totalChars = Array.from(full).length;
116
+ const PAGE = 4000;
117
+ let start = 0;
118
+ let assembled = "";
119
+ let pages = 0;
120
+ let lastTotal = -1;
121
+ // Follow the nextStart cursor until it is null — the classic pagination loop.
122
+ for (;;) {
123
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, start, PAGE)!;
124
+ assert(chunk, "a known section must page");
125
+ assert(Array.from(chunk.instructions).length <= PAGE, "a page must be bounded by the window");
126
+ assertEquals(chunk.start, start);
127
+ assertEquals(chunk.totalLength, totalChars);
128
+ if (lastTotal !== -1) assertEquals(chunk.totalLength, lastTotal);
129
+ lastTotal = chunk.totalLength;
130
+ assembled += chunk.instructions;
131
+ pages++;
132
+ if (chunk.nextStart === null) break;
133
+ start = chunk.nextStart;
134
+ assert(pages < 100, "pagination must terminate");
135
+ }
136
+ assert(pages > 1, "a large section must span multiple pages at this window");
137
+ assertEquals(assembled, full, "the reassembled pages must equal the whole section");
138
+ });
139
+
140
+ test("renderGuideSectionChunk: a window past the end returns an empty last page with nextStart null", () => {
141
+ const base = "https://x/app/api";
142
+ const full = renderGuideSection("orient", base)!;
143
+ const total = Array.from(full).length;
144
+ const chunk = renderGuideSectionChunk("orient", base, total + 500, 100)!;
145
+ assertEquals(chunk.start, total);
146
+ assertEquals(chunk.length, 0);
147
+ assertEquals(chunk.instructions, "");
148
+ assertEquals(chunk.nextStart, null);
149
+ });
150
+
151
+ test("renderGuideSectionChunk: the first page of a fitting section equals the whole section (nextStart null)", () => {
152
+ const base = "https://x/app/api";
153
+ const full = renderGuideSection("orient", base)!;
154
+ const chunk = renderGuideSectionChunk("orient", base, 0, GUIDE_SECTION_PAGE_DEFAULT)!;
155
+ // `orient` fits well under the default page, so one page carries it whole with no continuation.
156
+ assertEquals(chunk.instructions, full);
157
+ assertEquals(chunk.nextStart, null);
158
+ });
159
+
160
+ test("renderGuideSectionChunk: a zero-length window on a non-empty section terminates (nextStart null, no loop)", () => {
161
+ const base = "https://x/app/api";
162
+ const full = renderGuideSection("delivery-graphs", base)!;
163
+ assert(Array.from(full).length > 0, "precondition: the section has content");
164
+ // A caller (or a direct invoker bypassing the op's `length >= 1` validation) that asks for a
165
+ // zero-length window gets an empty page — but `nextStart` MUST be null so a "page until nextStart
166
+ // is null" loop cannot spin forever on a cursor that never advances.
167
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, 0, 0)!;
168
+ assertEquals(chunk.start, 0);
169
+ assertEquals(chunk.length, 0);
170
+ assertEquals(chunk.instructions, "");
171
+ assertEquals(chunk.nextStart, null);
172
+ });
173
+
174
+ test("renderGuideSectionChunk: an unknown section id is undefined (the op turns that into a 400)", () => {
175
+ assertEquals(renderGuideSectionChunk("does-not-exist", "https://x/app/api", 0, 100), undefined);
176
+ });
177
+
178
+ test("renderGuideSectionChunk: chunk boundaries never split a multi-byte character", () => {
179
+ const base = "https://x/app/api";
180
+ const full = renderGuideSection("delivery-graphs", base)!;
181
+ // Page at every 1-char window across a stretch that contains the guide's arrows/emoji; each
182
+ // reassembled result must round-trip losslessly (no U+FFFD replacement from a split code point).
183
+ let assembled = "";
184
+ let start = 0;
185
+ for (let i = 0; i < 200 && start < Array.from(full).length; i++) {
186
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, start, 7)!;
187
+ assembled += chunk.instructions;
188
+ if (chunk.nextStart === null) break;
189
+ start = chunk.nextStart;
190
+ }
191
+ assert(!assembled.includes("\uFFFD"), "no replacement characters — code points were never split");
192
+ assertEquals(assembled, Array.from(full).slice(0, Array.from(assembled).length).join(""));
193
+ });
194
+
108
195
  test("no content regression: each section body is a verbatim slice of the raw guide", () => {
109
196
  // Rendering keys examples to an instance; the UN-substituted section bodies must be exact
110
197
  // substrings of the authored doc, so the addressable surface never rewrites guide content.
package/app/agentGuide.ts CHANGED
@@ -174,3 +174,64 @@ export function renderGuideSection(id: string, apiBase: string): string | undefi
174
174
  const base = apiBase.replace(/\/+$/, "");
175
175
  return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
176
176
  }
177
+
178
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
179
+ // Paginated section retrieval (issue #740).
180
+ //
181
+ // The addressable guide exists to avoid the ~43KB monolith, but a single section can ITSELF overflow
182
+ // a typical MCP tool-result limit — `delivery-graphs` alone renders to ~25KB, the one section an
183
+ // author most needs. So a section is additionally retrievable in BOUNDED CHUNKS via a `start`/`length`
184
+ // window with a `nextStart` continuation cursor. Offsets are UNICODE CHARACTER (code-point) offsets —
185
+ // NOT raw byte offsets — so a chunk boundary never splits a multi-byte character (the arrows/emoji in
186
+ // the guide) into mojibake. A plain `renderGuideSection` (no window) is untouched, so existing
187
+ // `section=<id>` calls that already fit stay byte-for-byte identical.
188
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
189
+
190
+ /** The default window size (characters) when a caller engages pagination without an explicit
191
+ * `length` — comfortably under a typical MCP tool-result limit so a single page never overflows. */
192
+ export const GUIDE_SECTION_PAGE_DEFAULT = 12_000;
193
+
194
+ /** One page of a section's markdown: the `instructions` slice plus the cursor state so a caller can
195
+ * page through with `nextStart` until it is `null`. Offsets/lengths are CHARACTER counts. */
196
+ export interface GuideSectionChunk {
197
+ readonly instructions: string;
198
+ /** The (clamped) character offset this page starts at. */
199
+ readonly start: number;
200
+ /** The number of characters actually returned in this page. */
201
+ readonly length: number;
202
+ /** The total number of characters in the fully-rendered section. */
203
+ readonly totalLength: number;
204
+ /** The character offset to pass as `start` for the next page, or `null` when this is the last page. */
205
+ readonly nextStart: number | null;
206
+ }
207
+
208
+ /** Render a bounded WINDOW of a single section's markdown for a given app control-API base, with
209
+ * `__BASE__`/`__ENGINE__` substituted exactly as {@link renderGuideSection} does. `start` and
210
+ * `length` are CHARACTER offsets (clamped to the section bounds; a non-positive `length` yields an
211
+ * empty page). Returns `undefined` for an unknown id (the caller turns that into a 400). */
212
+ export function renderGuideSectionChunk(
213
+ id: string,
214
+ apiBase: string,
215
+ start: number,
216
+ length: number,
217
+ ): GuideSectionChunk | undefined {
218
+ const full = renderGuideSection(id, apiBase);
219
+ if (full === undefined) return undefined;
220
+ const chars = Array.from(full);
221
+ const total = chars.length;
222
+ const from = Math.min(Math.max(0, Math.trunc(start)), total);
223
+ const take = Math.max(0, Math.trunc(length));
224
+ const slice = chars.slice(from, from + take);
225
+ const end = from + slice.length;
226
+ return {
227
+ instructions: slice.join(""),
228
+ start: from,
229
+ length: slice.length,
230
+ totalLength: total,
231
+ // Only advance when this page actually consumed characters. A zero-length window (`length <= 0`)
232
+ // returns an empty page that made NO progress, so it must terminate the cursor (`null`) rather
233
+ // than hand back a `nextStart` equal to `start` — a non-advancing cursor would loop a caller
234
+ // that pages until `nextStart` is null forever.
235
+ nextStart: slice.length > 0 && end < total ? end : null,
236
+ };
237
+ }
@@ -164,7 +164,7 @@ const GRAPH_NAME_MAX_LENGTH = 255;
164
164
  * like the library import/save doors' `graphJson`, where the schema never touches the parsed value)
165
165
  * would otherwise let an oversized-but-compilable graph reach the layout/compiler and be persisted —
166
166
  * both violating the declared contract and exposing the import path to avoidable CPU/memory growth. */
167
- const GRAPH_MAX_NODES = 256;
167
+ export const GRAPH_MAX_NODES = 256;
168
168
  const GRAPH_MAX_EDGES = 1024;
169
169
  const NODE_MAX_EMITS = 32;
170
170
 
@@ -178,6 +178,47 @@ test("stageProposal: proposals with DIFFERENT logical keys coexist — supersede
178
178
  });
179
179
  });
180
180
 
181
+ // ── StageOutcome visibility (issue #740) ────────────────────────────────────────────────────────
182
+ test("stageProposal outcome: a FIRST stage reports nothing superseded and no live siblings", async () => {
183
+ await withData(async (data) => {
184
+ const outcome = await stageProposal(data, row({ digest: "d1" }));
185
+ assertEquals(outcome.row.digest, "d1");
186
+ assertEquals(outcome.superseded, []);
187
+ assertEquals(outcome.siblingsStaged, 0);
188
+ });
189
+ });
190
+
191
+ test("stageProposal outcome: a same-logical-key re-stage reports the retired digest in `superseded`", async () => {
192
+ await withData(async (data) => {
193
+ await stageProposal(data, row({ digest: "d1" }));
194
+ const outcome = await stageProposal(data, row({ digest: "d2" })); // same logical_key "runbook"
195
+ assertEquals(outcome.row.digest, "d2");
196
+ assertEquals(outcome.superseded, ["d1"], "the prior same-key staged digest was retired");
197
+ assertEquals(outcome.siblingsStaged, 0, "no OTHER-logical-key live proposal exists");
198
+ });
199
+ });
200
+
201
+ test("stageProposal outcome: an idempotent re-stage of the SAME live digest supersedes nothing", async () => {
202
+ await withData(async (data) => {
203
+ await stageProposal(data, row({ digest: "d1", createdAt: "2999-01-01T00:00:00.000Z" }));
204
+ const outcome = await stageProposal(data, row({ digest: "d1", createdAt: "2999-01-01T00:00:00.000Z" }));
205
+ assertEquals(outcome.row.digest, "d1");
206
+ assertEquals(outcome.superseded, [], "re-staging the identical live digest retires nothing");
207
+ assertEquals(outcome.siblingsStaged, 0);
208
+ });
209
+ });
210
+
211
+ test("stageProposal outcome: an orphan sibling under a DIFFERENT logical key is counted in `siblingsStaged`", async () => {
212
+ await withData(async (data) => {
213
+ // The footgun: a renamed graph stages under a new logical key without superseding the old one.
214
+ await stageProposal(data, row({ digest: "d1", logicalKey: "runbook-old" }));
215
+ const outcome = await stageProposal(data, row({ digest: "d2", logicalKey: "runbook-new" }));
216
+ assertEquals(outcome.row.digest, "d2");
217
+ assertEquals(outcome.superseded, [], "a different logical key supersedes nothing");
218
+ assertEquals(outcome.siblingsStaged, 1, "the still-live orphan under the old key is visible");
219
+ });
220
+ });
221
+
181
222
  test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage whose supersede pass runs AFTER a newer stage neither clobbers it (zero) nor coexists with it (two)", async () => {
182
223
  await withData(async (data) => {
183
224
  const table = deliveryGraphProposals(data);
@@ -200,6 +241,25 @@ test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage
200
241
  });
201
242
  });
202
243
 
244
+ test("stageProposal outcome: `row` reflects the POST-reconcile status — a stage immediately superseded by a newer sibling reports its own row as `superseded`, not the pre-reconcile `staged`", async () => {
245
+ await withData(async (data) => {
246
+ const table = deliveryGraphProposals(data);
247
+ // A newer staged sibling (d2) is already committed for logical_key "runbook".
248
+ const newer = row({ digest: "d2" });
249
+ newer.updated_at = "2999-01-01T00:00:00.000Z";
250
+ await table.insert(newer);
251
+ // The older stage (d1) runs last; the reconcile immediately supersedes its own row.
252
+ const outcome = await stageProposal(data, row({ digest: "d1" }));
253
+ assertEquals(outcome.row.digest, "d1");
254
+ assertEquals(
255
+ outcome.row.status,
256
+ "superseded",
257
+ "the returned row is re-read post-reconcile, matching what is actually persisted",
258
+ );
259
+ assertEquals((await table.get("d1"))?.status, "superseded");
260
+ });
261
+ });
262
+
203
263
  test("getStagedProposal: an EXPIRED staged proposal is not live", async () => {
204
264
  await withData(async (data) => {
205
265
  await stageProposal(data, row());
@@ -153,6 +153,27 @@ export function buildProposalRow(input: {
153
153
  };
154
154
  }
155
155
 
156
+ /** The outcome of a {@link stageProposal} — the written row PLUS a supersede/sibling summary the stage
157
+ * doors surface so an agent can warn the operator precisely about what its stage did to the cockpit's
158
+ * Delivery Graphs list (issue #740). Supersede keys on the LOGICAL graph key (derived from the graph
159
+ * `name`), so re-staging the "same" runbook under a CHANGED `name` creates a sibling with a different
160
+ * logical key that is NOT superseded — a silent footgun. Making the collision VISIBLE here (rather than
161
+ * silent) lets the agent tell the operator exactly which digests it retired and how many other live
162
+ * proposals remain (potential orphaned siblings). */
163
+ export interface StageOutcome {
164
+ /** The row this stage wrote, RE-READ after the supersede reconcile so it reflects the actually-
165
+ * persisted state (a concurrent newer stage can leave this digest `superseded` immediately). */
166
+ row: DeliveryGraphProposal;
167
+ /** Digests of OTHER proposals sharing this row's logical key that this stage flipped to
168
+ * `superseded` — the same-logical-graph proposals it cleanly replaced. Empty on a first stage. */
169
+ superseded: string[];
170
+ /** How many OTHER live `staged` proposals remain after this stage (a DIFFERENT logical key from this
171
+ * row) — i.e. proposals this stage did NOT supersede. A non-zero count flags possible orphaned
172
+ * siblings (e.g. an earlier stage of the same runbook under a different `name`) cluttering the
173
+ * operator's list, so the agent can name them for cleanup. */
174
+ siblingsStaged: number;
175
+ }
176
+
156
177
  /** Persist a compiled graph as a `staged` proposal and SUPERSEDE any prior staged proposal for the
157
178
  * same logical graph. Idempotent on `digest` (a re-stage of an identical, still-live digest refreshes
158
179
  * `updated_at` but preserves `created_at`, so the TTL stays anchored to the first stage; a re-stage of
@@ -162,10 +183,20 @@ export function buildProposalRow(input: {
162
183
  * exactly one live proposal per logical graph (the latest digest the operator would dispatch). The
163
184
  * supersede RECONCILES to the globally-newest staged row (`updated_at`, `digest`-tie-broken) rather than
164
185
  * flipping only rows older than the just-written one, so concurrent stages of two different digests
165
- * converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order. */
166
- export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<DeliveryGraphProposal> {
186
+ * converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order.
187
+ *
188
+ * Returns a {@link StageOutcome} — the written row plus the supersede/sibling summary (issue #740) the
189
+ * stage doors surface so an agent can warn the operator precisely about siblings it did / did not
190
+ * retire. */
191
+ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
167
192
  const table = deliveryGraphProposals(data);
168
193
  const existing = await table.get(row.digest);
194
+ // Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
195
+ // digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
196
+ // after to report exactly which digests this stage retired.
197
+ const priorSameKey = (await table.find({ status: "staged" })).filter(
198
+ (r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
199
+ );
169
200
  const toWrite = existing
170
201
  ? buildProposalRow({
171
202
  digest: row.digest,
@@ -208,7 +239,34 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
208
239
  `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
209
240
  [now(), row.logical_key],
210
241
  );
211
- return toWrite;
242
+
243
+ // Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
244
+ // this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
245
+ // leaving ours superseded — is reported honestly). `siblingsStaged` = OTHER live staged proposals
246
+ // with a DIFFERENT logical key that remain (the orphaned-sibling footgun: a re-stage under a changed
247
+ // `name` never supersedes them).
248
+ // Re-check ONLY the pre-stage staged siblings captured above (typically zero or a handful of
249
+ // concurrently-live same-key rows), reporting exactly those this reconcile flipped to `superseded`.
250
+ // Re-reading each `priorSameKey` digest keeps staging O(live same-key siblings) — a global
251
+ // `find({ status: "superseded" })` scans EVERY superseded row ever written (proposals are never
252
+ // deleted) and grows without bound. Matching strictly on `status === "superseded"` (not merely
253
+ // `!== "staged"`) avoids misreporting a sibling that concurrently went `dispatched`/`dismissed`/
254
+ // `expired`.
255
+ const superseded: string[] = [];
256
+ for (const prev of priorSameKey) {
257
+ const after = await table.get(prev.digest);
258
+ if (after?.status === "superseded") superseded.push(prev.digest);
259
+ }
260
+ const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
261
+
262
+ // Re-read the just-written row AFTER the reconcile so `row` reports the ACTUALLY-persisted state, not
263
+ // the pre-reconcile `toWrite`: a concurrent newer stage of a different digest can flip THIS digest to
264
+ // `superseded` in the reconcile above, so returning `toWrite` (still `staged`) would report a status
265
+ // that doesn't match the DB. Fall back to `toWrite` only if the row somehow vanished (it shouldn't —
266
+ // we just wrote it), keeping the return non-null.
267
+ const persisted = (await table.get(toWrite.digest)) ?? toWrite;
268
+
269
+ return { row: persisted, superseded, siblingsStaged };
212
270
  }
213
271
 
214
272
  /** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
@@ -39,6 +39,10 @@ export interface StagedResult {
39
39
  nodeCount: number;
40
40
  humanNodeCount: number;
41
41
  sideEffectCount: number;
42
+ /** Digests of same-logical-graph proposals this stage superseded (issue #740). */
43
+ superseded: string[];
44
+ /** How many OTHER live staged proposals remain after this stage — potential orphaned siblings. */
45
+ siblingsStaged: number;
42
46
  }
43
47
 
44
48
  /** A rejected compile — the `CompileDeliveryGraphErrors` body, verbatim from the compiler. */
@@ -75,7 +79,7 @@ export async function compileAndStageDeliveryGraph(
75
79
  ? result.resolved.name.trim()
76
80
  : null;
77
81
  const preview = buildProposalPreview(result);
78
- await stageProposal(
82
+ const outcome = await stageProposal(
79
83
  data,
80
84
  buildProposalRow({
81
85
  digest,
@@ -99,10 +103,16 @@ export async function compileAndStageDeliveryGraph(
99
103
  digest,
100
104
  preview,
101
105
  reviewUrl: proposalReviewUrl(digest, origin),
106
+ // Supersede/sibling visibility (issue #740): the agent surfaces these so the operator learns
107
+ // precisely which prior proposals this stage retired and whether other live proposals remain.
108
+ superseded: outcome.superseded,
109
+ siblingsStaged: outcome.siblingsStaged,
102
110
  },
103
111
  digest,
104
112
  nodeCount: result.resolved.nodes.length,
105
113
  humanNodeCount: result.humanNodes.length,
106
114
  sideEffectCount: result.sideEffects.length,
115
+ superseded: outcome.superseded,
116
+ siblingsStaged: outcome.siblingsStaged,
107
117
  };
108
118
  }
@@ -184,3 +184,134 @@ test("sequenceIssues: more than the max issues → rejected", () => {
184
184
  const issues = rejects({ issues: many });
185
185
  assert(issues.some((i) => i.path === "issues"));
186
186
  });
187
+
188
+ // ── Interleaved gates (issue #740) ──────────────────────────────────────────────────────────────
189
+
190
+ test("sequenceIssues: the issue's example — an npm gate between two issues generates a wait[npm] node gating the agent on prior merge AND publish", () => {
191
+ const graph = ok({
192
+ issues: [
193
+ "nanobpm/nano-ide#557",
194
+ { gate: { kind: "npm", target: "@nanobpm/agentic@0.13.0" }, issue: "jwulf/c8ctl-plugin-nano#186" },
195
+ "nanobpm/nano-workforce#738",
196
+ ],
197
+ });
198
+ // 3 issues × 3 canonical nodes + 1 interleaved gate node = 10.
199
+ assertEquals(graph.nodes.length, 10);
200
+ const gate = graph.nodes.find((n) => n.id === "gate-2");
201
+ assert(gate, "the interleaved gate node must exist");
202
+ assertEquals(gate.kind, "wait");
203
+ assertEquals(gate.wait.kind, "npm");
204
+ assertEquals(gate.wait.target, "@nanobpm/agentic@0.13.0");
205
+ // The gate carries the bounded default budget (not the 30-min trap).
206
+ assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
207
+ assertEquals(gate.wait.onTimeout, "escalate");
208
+ // Gated on the prior merge: merged-1 → gate-2. Gated on the publish → the agent waits on the gate:
209
+ // gate-2 → open-2. Together the agent (open-2) starts only after issue-1 merged AND the npm publish.
210
+ assert(graph.edges.some((e) => e.from === "merged-1" && e.to === "gate-2"), "gate waits on the prior merge");
211
+ assert(graph.edges.some((e) => e.from === "gate-2" && e.to === "open-2"), "the agent waits on the gate");
212
+ // No direct merged-1 → open-2 edge — the gate is spliced in between.
213
+ assert(!graph.edges.some((e) => e.from === "merged-1" && e.to === "open-2"), "the gate replaces the direct sequence edge");
214
+ // The third (ungated) issue still sequences directly off the second's merge.
215
+ assert(graph.edges.some((e) => e.from === "merged-2" && e.to === "open-3"), "an ungated issue keeps the direct sequence edge");
216
+ });
217
+
218
+ test("sequenceIssues: a gate on the FIRST issue behind an epic gate chains gate-epic → gate-1 → open-1", () => {
219
+ const graph = ok({
220
+ behind: "acme/repo#100",
221
+ issues: [{ gate: { kind: "github-check", target: "acme/repo@main" }, issue: "acme/repo#1" }],
222
+ });
223
+ assert(graph.nodes.some((n) => n.id === "gate-epic"), "the leading epic gate exists");
224
+ assert(graph.nodes.some((n) => n.id === "gate-1"), "the interleaved first-issue gate exists");
225
+ assert(graph.edges.some((e) => e.from === "gate-epic" && e.to === "gate-1"), "epic gate → interleaved gate");
226
+ assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "interleaved gate → agent");
227
+ assert(!graph.edges.some((e) => e.from === "gate-epic" && e.to === "open-1"), "the interleaved gate is spliced before the agent");
228
+ });
229
+
230
+ test("sequenceIssues: a gated first issue with NO behind gate starts the gate immediately (no predecessor edge)", () => {
231
+ const graph = ok({ issues: [{ gate: { kind: "npm", target: "pkg@1.0.0" }, issue: "acme/repo#1" }] });
232
+ // No inbound edge to gate-1 (nothing precedes it); the agent still waits on the gate.
233
+ assert(!graph.edges.some((e) => e.to === "gate-1"), "an ungated-first gate has no predecessor edge");
234
+ assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "the agent waits on the gate");
235
+ });
236
+
237
+ test("sequenceIssues: a gate's `kind`/`target` are trimmed before validation AND persistence (no whitespace-poisoned probe)", () => {
238
+ // Surrounding whitespace must neither trip a confusing "unknown kind" rejection nor survive into
239
+ // the generated `wait` node, where a stray trailing space silently probes the wrong target.
240
+ const graph = ok({ issues: [{ gate: { kind: " npm ", target: " pkg@1.0.0 " }, issue: "acme/repo#1" }] });
241
+ const gate = graph.nodes.find((n) => n.id === "gate-1");
242
+ assert(gate, "the interleaved gate node must exist");
243
+ assertEquals(gate.wait.kind, "npm");
244
+ assertEquals(gate.wait.target, "pkg@1.0.0");
245
+ });
246
+
247
+ test("sequenceIssues: an object entry accepts optional gate fields (match, poll, onTimeout, credentialEnv)", () => {
248
+ const graph = ok({
249
+ issues: [
250
+ {
251
+ gate: {
252
+ kind: "http",
253
+ target: "https://example.test/ready",
254
+ match: { status: 200 },
255
+ poll: { everyMs: 1000, timeoutMs: 60000 },
256
+ onTimeout: "continue",
257
+ credentialEnv: "MY_TOKEN",
258
+ },
259
+ issue: "acme/repo#1",
260
+ },
261
+ ],
262
+ });
263
+ const gate = graph.nodes.find((n) => n.id === "gate-1");
264
+ assertEquals(gate.wait.match, { status: 200 });
265
+ assertEquals(gate.wait.poll, { everyMs: 1000, timeoutMs: 60000 });
266
+ assertEquals(gate.wait.onTimeout, "continue");
267
+ assertEquals(gate.wait.credentialEnv, "MY_TOKEN");
268
+ });
269
+
270
+ test("sequenceIssues: a bare-string entry is byte-for-byte identical to the object form without a gate", () => {
271
+ const bare = ok({ issues: ["acme/repo#1", "acme/repo#2"] });
272
+ const objs = ok({ issues: [{ issue: "acme/repo#1" }, { issue: "acme/repo#2" }] });
273
+ assertEquals(objs.nodes, bare.nodes);
274
+ assertEquals(objs.edges, bare.edges);
275
+ });
276
+
277
+ test("sequenceIssues: a graph with an interleaved gate passes validateDeliveryGraph AND compiles", async () => {
278
+ const graph = ok({
279
+ issues: [
280
+ "acme/repo#1",
281
+ { gate: { kind: "npm", target: "@scope/pkg@2.0.0" }, issue: "acme/repo#2" },
282
+ ],
283
+ });
284
+ assertEquals(validateDeliveryGraph(graph), []);
285
+ const compiled = await compileDeliveryGraph(graph);
286
+ assert(compiled.ok, `expected the gated graph to compile, got ${JSON.stringify(compiled)}`);
287
+ });
288
+
289
+ test("sequenceIssues: an unknown gate kind → rejected at issues[i].gate.kind", () => {
290
+ const issues = rejects({ issues: [{ gate: { kind: "no-such-probe", target: "x" }, issue: "acme/repo#1" }] });
291
+ assert(issues.some((i) => i.path === "issues[0].gate.kind"), `expected issues[0].gate.kind, got ${JSON.stringify(issues)}`);
292
+ });
293
+
294
+ test("sequenceIssues: a gate missing its target → rejected at issues[i].gate.target", () => {
295
+ const issues = rejects({ issues: [{ gate: { kind: "npm" }, issue: "acme/repo#1" }] });
296
+ assert(issues.some((i) => i.path === "issues[0].gate.target"), `expected issues[0].gate.target, got ${JSON.stringify(issues)}`);
297
+ });
298
+
299
+ test("sequenceIssues: a gate with onTimeout:fail → rejected at issues[i].gate.onTimeout", () => {
300
+ const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1", onTimeout: "fail" }, issue: "acme/repo#1" }] });
301
+ assert(issues.some((i) => i.path === "issues[0].gate.onTimeout"), `expected issues[0].gate.onTimeout, got ${JSON.stringify(issues)}`);
302
+ });
303
+
304
+ test("sequenceIssues: an object entry missing `issue` → rejected at issues[i].issue", () => {
305
+ const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1" } }] });
306
+ assert(issues.some((i) => i.path === "issues[0].issue"), `expected issues[0].issue, got ${JSON.stringify(issues)}`);
307
+ });
308
+
309
+ test("sequenceIssues: a fully-gated max-length sequence behind an epic gate exceeds the node ceiling → rejected", () => {
310
+ const many = Array.from({ length: MAX_SEQUENCE_ISSUES }, (_, i) => ({
311
+ gate: { kind: "npm", target: `pkg@${i + 1}` },
312
+ issue: `acme/repo#${i + 1}`,
313
+ }));
314
+ // 64 × 4 nodes + 1 epic gate = 257 > 256.
315
+ const issues = rejects({ behind: "acme/repo#999", issues: many });
316
+ assert(issues.some((i) => i.path === "issues" && /too many nodes/.test(i.message)), `expected a node-ceiling rejection, got ${JSON.stringify(issues)}`);
317
+ });