@nanobpm/nano-workforce 0.180.0 → 0.182.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.
@@ -35,7 +35,9 @@ function handAuthored(behind: string | null, issues: string[]): AnyGraph {
35
35
  nodes.push({
36
36
  id: `open-${n}`,
37
37
  kind: "agent",
38
- agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.` },
38
+ // #739: the generator stamps each agent cell with the issue's OWN repository, so a multi-repo
39
+ // sequence provisions each cell's isolation envelope from that issue's repo.
40
+ agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.`, repository: issue.split("#")[0] },
39
41
  emits: [{ name: "pr", type: "pr" }],
40
42
  });
41
43
  nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
@@ -106,6 +108,15 @@ test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connec
106
108
  assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
107
109
  });
108
110
 
111
+ test("sequenceIssues: each agent cell is stamped with its OWN issue's repository — a cross-repo sequence (#739)", () => {
112
+ // Two issues in DIFFERENT repos → each `open-*` agent node declares that issue's repository, so the
113
+ // delivery runner provisions each cell's isolation envelope from its own repo (no uniform run-level
114
+ // repository, no `repoless`). This is the generator half of #739.
115
+ const graph = ok({ issues: ["acme/one#1", "beta/two#2"] });
116
+ assertEquals(graph.nodes.find((n) => n.id === "open-1").agent.repository, "acme/one");
117
+ assertEquals(graph.nodes.find((n) => n.id === "open-2").agent.repository, "beta/two");
118
+ });
119
+
109
120
  test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
110
121
  const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
111
122
  const gate = graph.nodes.find((n) => n.id === "gate-epic");
@@ -184,3 +195,134 @@ test("sequenceIssues: more than the max issues → rejected", () => {
184
195
  const issues = rejects({ issues: many });
185
196
  assert(issues.some((i) => i.path === "issues"));
186
197
  });
198
+
199
+ // ── Interleaved gates (issue #740) ──────────────────────────────────────────────────────────────
200
+
201
+ 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", () => {
202
+ const graph = ok({
203
+ issues: [
204
+ "nanobpm/nano-ide#557",
205
+ { gate: { kind: "npm", target: "@nanobpm/agentic@0.13.0" }, issue: "jwulf/c8ctl-plugin-nano#186" },
206
+ "nanobpm/nano-workforce#738",
207
+ ],
208
+ });
209
+ // 3 issues × 3 canonical nodes + 1 interleaved gate node = 10.
210
+ assertEquals(graph.nodes.length, 10);
211
+ const gate = graph.nodes.find((n) => n.id === "gate-2");
212
+ assert(gate, "the interleaved gate node must exist");
213
+ assertEquals(gate.kind, "wait");
214
+ assertEquals(gate.wait.kind, "npm");
215
+ assertEquals(gate.wait.target, "@nanobpm/agentic@0.13.0");
216
+ // The gate carries the bounded default budget (not the 30-min trap).
217
+ assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
218
+ assertEquals(gate.wait.onTimeout, "escalate");
219
+ // Gated on the prior merge: merged-1 → gate-2. Gated on the publish → the agent waits on the gate:
220
+ // gate-2 → open-2. Together the agent (open-2) starts only after issue-1 merged AND the npm publish.
221
+ assert(graph.edges.some((e) => e.from === "merged-1" && e.to === "gate-2"), "gate waits on the prior merge");
222
+ assert(graph.edges.some((e) => e.from === "gate-2" && e.to === "open-2"), "the agent waits on the gate");
223
+ // No direct merged-1 → open-2 edge — the gate is spliced in between.
224
+ assert(!graph.edges.some((e) => e.from === "merged-1" && e.to === "open-2"), "the gate replaces the direct sequence edge");
225
+ // The third (ungated) issue still sequences directly off the second's merge.
226
+ assert(graph.edges.some((e) => e.from === "merged-2" && e.to === "open-3"), "an ungated issue keeps the direct sequence edge");
227
+ });
228
+
229
+ test("sequenceIssues: a gate on the FIRST issue behind an epic gate chains gate-epic → gate-1 → open-1", () => {
230
+ const graph = ok({
231
+ behind: "acme/repo#100",
232
+ issues: [{ gate: { kind: "github-check", target: "acme/repo@main" }, issue: "acme/repo#1" }],
233
+ });
234
+ assert(graph.nodes.some((n) => n.id === "gate-epic"), "the leading epic gate exists");
235
+ assert(graph.nodes.some((n) => n.id === "gate-1"), "the interleaved first-issue gate exists");
236
+ assert(graph.edges.some((e) => e.from === "gate-epic" && e.to === "gate-1"), "epic gate → interleaved gate");
237
+ assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "interleaved gate → agent");
238
+ assert(!graph.edges.some((e) => e.from === "gate-epic" && e.to === "open-1"), "the interleaved gate is spliced before the agent");
239
+ });
240
+
241
+ test("sequenceIssues: a gated first issue with NO behind gate starts the gate immediately (no predecessor edge)", () => {
242
+ const graph = ok({ issues: [{ gate: { kind: "npm", target: "pkg@1.0.0" }, issue: "acme/repo#1" }] });
243
+ // No inbound edge to gate-1 (nothing precedes it); the agent still waits on the gate.
244
+ assert(!graph.edges.some((e) => e.to === "gate-1"), "an ungated-first gate has no predecessor edge");
245
+ assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "the agent waits on the gate");
246
+ });
247
+
248
+ test("sequenceIssues: a gate's `kind`/`target` are trimmed before validation AND persistence (no whitespace-poisoned probe)", () => {
249
+ // Surrounding whitespace must neither trip a confusing "unknown kind" rejection nor survive into
250
+ // the generated `wait` node, where a stray trailing space silently probes the wrong target.
251
+ const graph = ok({ issues: [{ gate: { kind: " npm ", target: " pkg@1.0.0 " }, issue: "acme/repo#1" }] });
252
+ const gate = graph.nodes.find((n) => n.id === "gate-1");
253
+ assert(gate, "the interleaved gate node must exist");
254
+ assertEquals(gate.wait.kind, "npm");
255
+ assertEquals(gate.wait.target, "pkg@1.0.0");
256
+ });
257
+
258
+ test("sequenceIssues: an object entry accepts optional gate fields (match, poll, onTimeout, credentialEnv)", () => {
259
+ const graph = ok({
260
+ issues: [
261
+ {
262
+ gate: {
263
+ kind: "http",
264
+ target: "https://example.test/ready",
265
+ match: { status: 200 },
266
+ poll: { everyMs: 1000, timeoutMs: 60000 },
267
+ onTimeout: "continue",
268
+ credentialEnv: "MY_TOKEN",
269
+ },
270
+ issue: "acme/repo#1",
271
+ },
272
+ ],
273
+ });
274
+ const gate = graph.nodes.find((n) => n.id === "gate-1");
275
+ assertEquals(gate.wait.match, { status: 200 });
276
+ assertEquals(gate.wait.poll, { everyMs: 1000, timeoutMs: 60000 });
277
+ assertEquals(gate.wait.onTimeout, "continue");
278
+ assertEquals(gate.wait.credentialEnv, "MY_TOKEN");
279
+ });
280
+
281
+ test("sequenceIssues: a bare-string entry is byte-for-byte identical to the object form without a gate", () => {
282
+ const bare = ok({ issues: ["acme/repo#1", "acme/repo#2"] });
283
+ const objs = ok({ issues: [{ issue: "acme/repo#1" }, { issue: "acme/repo#2" }] });
284
+ assertEquals(objs.nodes, bare.nodes);
285
+ assertEquals(objs.edges, bare.edges);
286
+ });
287
+
288
+ test("sequenceIssues: a graph with an interleaved gate passes validateDeliveryGraph AND compiles", async () => {
289
+ const graph = ok({
290
+ issues: [
291
+ "acme/repo#1",
292
+ { gate: { kind: "npm", target: "@scope/pkg@2.0.0" }, issue: "acme/repo#2" },
293
+ ],
294
+ });
295
+ assertEquals(validateDeliveryGraph(graph), []);
296
+ const compiled = await compileDeliveryGraph(graph);
297
+ assert(compiled.ok, `expected the gated graph to compile, got ${JSON.stringify(compiled)}`);
298
+ });
299
+
300
+ test("sequenceIssues: an unknown gate kind → rejected at issues[i].gate.kind", () => {
301
+ const issues = rejects({ issues: [{ gate: { kind: "no-such-probe", target: "x" }, issue: "acme/repo#1" }] });
302
+ assert(issues.some((i) => i.path === "issues[0].gate.kind"), `expected issues[0].gate.kind, got ${JSON.stringify(issues)}`);
303
+ });
304
+
305
+ test("sequenceIssues: a gate missing its target → rejected at issues[i].gate.target", () => {
306
+ const issues = rejects({ issues: [{ gate: { kind: "npm" }, issue: "acme/repo#1" }] });
307
+ assert(issues.some((i) => i.path === "issues[0].gate.target"), `expected issues[0].gate.target, got ${JSON.stringify(issues)}`);
308
+ });
309
+
310
+ test("sequenceIssues: a gate with onTimeout:fail → rejected at issues[i].gate.onTimeout", () => {
311
+ const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1", onTimeout: "fail" }, issue: "acme/repo#1" }] });
312
+ assert(issues.some((i) => i.path === "issues[0].gate.onTimeout"), `expected issues[0].gate.onTimeout, got ${JSON.stringify(issues)}`);
313
+ });
314
+
315
+ test("sequenceIssues: an object entry missing `issue` → rejected at issues[i].issue", () => {
316
+ const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1" } }] });
317
+ assert(issues.some((i) => i.path === "issues[0].issue"), `expected issues[0].issue, got ${JSON.stringify(issues)}`);
318
+ });
319
+
320
+ test("sequenceIssues: a fully-gated max-length sequence behind an epic gate exceeds the node ceiling → rejected", () => {
321
+ const many = Array.from({ length: MAX_SEQUENCE_ISSUES }, (_, i) => ({
322
+ gate: { kind: "npm", target: `pkg@${i + 1}` },
323
+ issue: `acme/repo#${i + 1}`,
324
+ }));
325
+ // 64 × 4 nodes + 1 epic gate = 257 > 256.
326
+ const issues = rejects({ behind: "acme/repo#999", issues: many });
327
+ assert(issues.some((i) => i.path === "issues" && /too many nodes/.test(i.message)), `expected a node-ceiling rejection, got ${JSON.stringify(issues)}`);
328
+ });
@@ -19,8 +19,9 @@
19
19
  // `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
20
20
  // (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
21
21
  // `issues[{path,message}]` rather than only surfacing at compile time.
22
- import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
22
+ import type { DeliveryEdge, DeliveryGraph, DeliveryNode, ReadinessProbe } from "../nano-generated/api-io.d.ts";
23
23
  import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
24
+ import { GRAPH_MAX_NODES } from "./deliveryGraph.ts";
24
25
  import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
25
26
  import { type ParsedIssue, parseIssue } from "./plan.ts";
26
27
 
@@ -31,10 +32,42 @@ export interface SequenceIssueError {
31
32
  readonly message: string;
32
33
  }
33
34
 
34
- /** The `sequenceIssues` intent body an optional leading `behind` gate plus the ordered `issues`. */
35
+ /** An interleaved `wait` GATE that must go green before a given issue's agent starts (issue #740).
36
+ * Reuses the exact probe schema `wait` nodes already accept — the `npm`/`github-check`/`http`/…
37
+ * vocabulary — so an author can insert e.g. "wait for `@nanobpm/agentic@0.13.0` to publish" between
38
+ * two sequence steps without falling off the intent onto raw node/edge JSON. */
39
+ export interface SequenceGate {
40
+ /** The wait-probe kind (must be a known kind in the S3 vocabulary — `npm`, `github-check`, `http`,
41
+ * `command`, `capability`, `pr`, `epic`). */
42
+ kind: string;
43
+ /** The probe target — kind-specific (`pkg@version` for `npm`, `owner/repo@ref` for `github-check`,
44
+ * a URL for `http`, …). */
45
+ target: string;
46
+ /** Optional kind-specific readiness `match` fields (e.g. `{ version }` for `npm`). */
47
+ match?: Record<string, unknown>;
48
+ /** Optional poll budget; defaults to the bounded merge-gate budget ({@link MERGE_POLL}) so a gate
49
+ * never falls into the 30-minute default trap. */
50
+ poll?: { everyMs?: number; timeoutMs?: number };
51
+ /** What to do when the gate never goes green within its budget — `escalate` (default) or `continue`.
52
+ * `fail` is rejected (not yet supported by the compiler/engine). */
53
+ onTimeout?: "escalate" | "continue";
54
+ /** Optional env-key name supplying a credential for the probe (`http`/`capability`). */
55
+ credentialEnv?: string;
56
+ }
57
+
58
+ /** One `issues[]` entry with an OPTIONAL leading {@link SequenceGate} — the object form. A bare string
59
+ * entry (no gate) keeps today's behaviour byte-for-byte. */
60
+ export interface SequenceIssueEntry {
61
+ gate?: SequenceGate;
62
+ issue: string;
63
+ }
64
+
65
+ /** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`,
66
+ * each of which may be a bare `owner/repo#N` string OR a `{ gate?, issue }` object interleaving a
67
+ * `wait` gate before that issue's agent (issue #740). */
35
68
  export interface SequenceIssuesIntent {
36
69
  behind?: string;
37
- issues: string[];
70
+ issues: (string | SequenceIssueEntry)[];
38
71
  }
39
72
 
40
73
  /** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
@@ -63,6 +96,30 @@ export const MAX_SEQUENCE_ISSUES = 64;
63
96
  * downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
64
97
  const PR_EMIT = { name: "pr", type: "pr" as const };
65
98
 
99
+ /** A validated, ready-to-emit interleaved gate: `poll` and `onTimeout` are resolved to concrete values
100
+ * (defaults applied) so {@link assembleGraph} can drop it straight into a `wait` node's config. */
101
+ interface BuiltGate {
102
+ kind: ReadinessProbe["kind"];
103
+ target: string;
104
+ match?: Record<string, unknown>;
105
+ poll: { everyMs?: number; timeoutMs?: number };
106
+ onTimeout: "escalate" | "continue";
107
+ credentialEnv?: string;
108
+ }
109
+
110
+ /** A parsed `issues[]` entry: the parsed issue (carrying its `owner/repo#N` target, used to stamp the
111
+ * generated agent node's own `repository` — issue #739) plus its optional interleaved gate (#740). */
112
+ interface ParsedEntry {
113
+ issue: ParsedIssue;
114
+ gate: BuiltGate | null;
115
+ }
116
+
117
+ /** Narrow a validated string to the closed `ReadinessProbe["kind"]` union — backed by the runtime
118
+ * vocabulary set so the type guard and the door validation share one source of truth. */
119
+ function isProbeKind(kind: string, probeKinds: Set<string>): kind is ReadinessProbe["kind"] {
120
+ return probeKinds.has(kind);
121
+ }
122
+
66
123
  /** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
67
124
  * `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
68
125
  * past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
@@ -74,12 +131,111 @@ function parseIssueRef(ref: unknown): ParsedIssue | null {
74
131
  return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
75
132
  }
76
133
 
134
+ /** Validate one interleaved `gate` spec against the probe vocabulary, applying defaults. Returns the
135
+ * built gate or path-qualified `issues[{path,message}]` rejections (keyed under `path`, e.g.
136
+ * `issues[1].gate.kind`). Reuses the same probe schema `wait` nodes accept: `{ kind, target, match?,
137
+ * poll?, onTimeout?, credentialEnv? }`. */
138
+ function parseGate(raw: unknown, path: string, probeKinds: Set<string>): { gate?: BuiltGate; errors: SequenceIssueError[] } {
139
+ const errors: SequenceIssueError[] = [];
140
+ if (!isRecord(raw)) {
141
+ return { errors: [{ path, message: "`gate` must be an object carrying a `wait` probe (`{ kind, target, … }`)." }] };
142
+ }
143
+
144
+ // Trim before validating AND before persisting: harmless surrounding whitespace must neither
145
+ // trip a confusing "unknown kind" rejection nor survive into the generated `wait` node, where a
146
+ // stray trailing space silently probes the wrong target (`pkg@1.0.0 ` → a gate that never goes
147
+ // green). The persisted value is always the trimmed one.
148
+ let validKind: ReadinessProbe["kind"] | null = null;
149
+ const kind = raw.kind;
150
+ if (typeof kind !== "string" || kind.trim() === "") {
151
+ errors.push({ path: `${path}.kind`, message: "`gate.kind` is required and must be a non-empty wait-probe kind." });
152
+ } else {
153
+ const trimmedKind = kind.trim();
154
+ if (!isProbeKind(trimmedKind, probeKinds)) {
155
+ errors.push({ path: `${path}.kind`, message: `wait-probe kind \`${trimmedKind}\` is not in the delivery-graph vocabulary.` });
156
+ } else {
157
+ validKind = trimmedKind;
158
+ }
159
+ }
160
+
161
+ let validTarget: string | null = null;
162
+ const target = raw.target;
163
+ if (typeof target !== "string" || target.trim() === "") {
164
+ errors.push({ path: `${path}.target`, message: "`gate.target` is required and must be a non-empty string." });
165
+ } else {
166
+ validTarget = target.trim();
167
+ }
168
+
169
+ let match: Record<string, unknown> | undefined;
170
+ if (raw.match !== undefined) {
171
+ if (!isRecord(raw.match)) {
172
+ errors.push({ path: `${path}.match`, message: "`gate.match`, when present, must be an object of readiness fields." });
173
+ } else {
174
+ match = raw.match;
175
+ }
176
+ }
177
+
178
+ const poll: { everyMs?: number; timeoutMs?: number } = { ...MERGE_POLL };
179
+ if (raw.poll !== undefined) {
180
+ if (!isRecord(raw.poll)) {
181
+ errors.push({ path: `${path}.poll`, message: "`gate.poll`, when present, must be `{ everyMs?, timeoutMs? }`." });
182
+ } else {
183
+ for (const key of ["everyMs", "timeoutMs"] as const) {
184
+ const v = raw.poll[key];
185
+ if (v === undefined) continue;
186
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1) {
187
+ errors.push({ path: `${path}.poll.${key}`, message: `\`gate.poll.${key}\`, when present, must be a positive integer (milliseconds).` });
188
+ } else {
189
+ poll[key] = v;
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ let onTimeout: "escalate" | "continue" = "escalate";
196
+ if (raw.onTimeout !== undefined) {
197
+ if (raw.onTimeout !== "escalate" && raw.onTimeout !== "continue") {
198
+ errors.push({
199
+ path: `${path}.onTimeout`,
200
+ message: "`gate.onTimeout` must be `escalate` (default) or `continue` — `fail` is not supported on a `wait` node.",
201
+ });
202
+ } else {
203
+ onTimeout = raw.onTimeout;
204
+ }
205
+ }
206
+
207
+ let credentialEnv: string | undefined;
208
+ if (raw.credentialEnv !== undefined) {
209
+ if (typeof raw.credentialEnv !== "string" || raw.credentialEnv.trim() === "") {
210
+ errors.push({ path: `${path}.credentialEnv`, message: "`gate.credentialEnv`, when present, must be a non-empty env-key name." });
211
+ } else {
212
+ credentialEnv = raw.credentialEnv;
213
+ }
214
+ }
215
+
216
+ if (errors.length > 0 || validKind === null || validTarget === null) return { errors };
217
+ return {
218
+ gate: {
219
+ kind: validKind,
220
+ target: validTarget,
221
+ ...(match ? { match } : {}),
222
+ poll,
223
+ onTimeout,
224
+ ...(credentialEnv ? { credentialEnv } : {}),
225
+ },
226
+ errors: [],
227
+ };
228
+ }
229
+
77
230
  /**
78
231
  * Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
79
232
  * `issues[{path,message}]` rejections for invalid input. Validates:
80
233
  * - `issues` is a non-empty array within {@link MAX_SEQUENCE_ISSUES};
81
- * - every `issues[i]` and the optional `behind` parse as an `owner/repo#N` reference;
82
- * - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard).
234
+ * - every `issues[i]` (a bare ref, or a `{ gate?, issue }` object) and the optional `behind` parse
235
+ * as an `owner/repo#N` reference;
236
+ * - each interleaved `gate` names a wait-probe kind known to the S3 vocabulary (issue #740);
237
+ * - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard);
238
+ * - the generated graph stays within the compiler's node ceiling.
83
239
  * Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
84
240
  */
85
241
  export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
@@ -89,8 +245,12 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
89
245
  const rawIssues = body.issues;
90
246
  const rawBehind = body.behind;
91
247
 
92
- // ── `issues`: a non-empty, bounded array of parseable refs ───────────────────────────────────
93
- const parsedIssues: string[] = [];
248
+ // The vocabulary is needed up front to validate interleaved gate `kind`s as each entry is parsed.
249
+ const vocab = deliveryGraphVocabulary();
250
+ const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
251
+
252
+ // ── `issues`: a non-empty, bounded array of parseable refs, each optionally carrying a gate ────
253
+ const parsedEntries: ParsedEntry[] = [];
94
254
  if (!Array.isArray(rawIssues)) {
95
255
  issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
96
256
  } else if (rawIssues.length === 0) {
@@ -101,16 +261,26 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
101
261
  message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
102
262
  });
103
263
  } else {
104
- rawIssues.forEach((ref, i) => {
105
- const parsed = parseIssueRef(ref);
264
+ rawIssues.forEach((entry, i) => {
265
+ // A bare string entry is a gate-less issue (today's behaviour, byte-for-byte). An object entry
266
+ // carries a required `issue` ref plus an OPTIONAL leading `gate` (issue #740).
267
+ const rawRef = isRecord(entry) ? entry.issue : entry;
268
+ const parsed = parseIssueRef(rawRef);
106
269
  if (!parsed) {
270
+ const shown = isRecord(entry) ? String(rawRef) : String(entry);
107
271
  issues.push({
108
- path: `issues[${i}]`,
109
- message: `\`${String(ref)}\` is not a valid \`owner/repo#N\` issue reference.`,
272
+ path: isRecord(entry) ? `issues[${i}].issue` : `issues[${i}]`,
273
+ message: `\`${shown}\` is not a valid \`owner/repo#N\` issue reference.`,
110
274
  });
111
275
  return;
112
276
  }
113
- parsedIssues.push(parsed.planKey);
277
+ let gate: BuiltGate | null = null;
278
+ if (isRecord(entry) && entry.gate !== undefined && entry.gate !== null) {
279
+ const parsedGate = parseGate(entry.gate, `issues[${i}].gate`, probeKinds);
280
+ issues.push(...parsedGate.errors);
281
+ gate = parsedGate.gate ?? null;
282
+ }
283
+ parsedEntries.push({ issue: parsed, gate });
114
284
  });
115
285
  }
116
286
 
@@ -135,7 +305,6 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
135
305
  // ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
136
306
  // structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
137
307
  // surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
138
- const vocab = deliveryGraphVocabulary();
139
308
  const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
140
309
  if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
141
310
  issues.push({
@@ -143,28 +312,44 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
143
312
  message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
144
313
  });
145
314
  }
146
- const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
147
315
  for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
148
316
  if (!probeKinds.has(kind)) {
149
317
  issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
150
318
  }
151
319
  }
152
320
 
321
+ // ── Node-budget guard: interleaved gates add a node per gated issue, so a fully-gated max-length
322
+ // sequence (plus the optional `behind` gate) can push past the compiler's node ceiling. Reject it
323
+ // at the door so a SUCCESSFULLY generated graph always validates `by construction`. ──
324
+ if (issues.length === 0) {
325
+ const projectedNodes = (behindKey ? 1 : 0) + parsedEntries.reduce((n, e) => n + 3 + (e.gate ? 1 : 0), 0);
326
+ if (projectedNodes > GRAPH_MAX_NODES) {
327
+ issues.push({
328
+ path: "issues",
329
+ message: `the generated graph would have too many nodes (${projectedNodes}) — the limit is ${GRAPH_MAX_NODES}; use fewer issues or interleaved gates.`,
330
+ });
331
+ }
332
+ }
333
+
153
334
  if (issues.length > 0) return { ok: false, issues };
154
335
 
155
- return { ok: true, graph: assembleGraph(parsedIssues, behindKey) };
336
+ return { ok: true, graph: assembleGraph(parsedEntries, behindKey) };
156
337
  }
157
338
 
158
- /** Assemble the canonical node/edge chain for the (already-validated) issue keys + optional gate. */
159
- function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryGraph {
339
+ /** Assemble the canonical node/edge chain for the (already-validated) parsed entries + optional gate.
340
+ * Each entry emits `agent → connector[converge-merge] → wait[pr, merged]`; an entry with an
341
+ * interleaved `gate` gets a leading `wait[<gate.kind>]` node spliced between the prior sequence step
342
+ * and its agent, so the agent starts only once the prior issue merged AND the gate went green. Each
343
+ * agent node is stamped with its own `repository` (issue #739) from the issue it implements. */
344
+ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): DeliveryGraph {
160
345
  const nodes: DeliveryNode[] = [];
161
346
  const edges: DeliveryEdge[] = [];
162
347
 
163
348
  // Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
164
- const GATE_ID = "gate-epic";
349
+ const EPIC_GATE_ID = "gate-epic";
165
350
  if (behindKey) {
166
351
  nodes.push({
167
- id: GATE_ID,
352
+ id: EPIC_GATE_ID,
168
353
  kind: "wait",
169
354
  wait: {
170
355
  kind: EPIC_PROBE_KIND,
@@ -177,18 +362,50 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
177
362
  });
178
363
  }
179
364
 
180
- issueKeys.forEach((issueKey, i) => {
365
+ entries.forEach((entry, i) => {
366
+ const issue = entry.issue;
181
367
  const n = i + 1;
182
368
  const openId = `open-${n}`;
183
369
  const landId = `land-${n}`;
184
370
  const mergedId = `merged-${n}`;
185
371
  const prRef = `${openId}.pr`;
372
+ const issueKey = issue.planKey;
373
+
374
+ // The sequence predecessor whose completion releases THIS issue: the prior issue's merge, else the
375
+ // leading epic gate for the first issue (null when the first issue is ungated by `behind`).
376
+ const predecessor = i === 0 ? (behindKey ? EPIC_GATE_ID : null) : `merged-${i}`;
186
377
 
187
- // agent opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
378
+ // Interleaved gate (issue #740): a `wait[<kind>]` node the issue's agent waits on. It inherits the
379
+ // sequence predecessor's edge (so it only starts probing once the prior issue merged) and the
380
+ // agent then waits on the gate — gating the agent on the prior merge AND the gate condition.
381
+ let agentPredecessor = predecessor;
382
+ if (entry.gate) {
383
+ const gateId = `gate-${n}`;
384
+ const g = entry.gate;
385
+ nodes.push({
386
+ id: gateId,
387
+ kind: "wait",
388
+ wait: {
389
+ kind: g.kind,
390
+ target: g.target,
391
+ ...(g.match ? { match: g.match } : {}),
392
+ poll: { ...g.poll },
393
+ onTimeout: g.onTimeout,
394
+ ...(g.credentialEnv ? { credentialEnv: g.credentialEnv } : {}),
395
+ },
396
+ });
397
+ if (predecessor) edges.push({ from: predecessor, to: gateId });
398
+ agentPredecessor = gateId;
399
+ }
400
+
401
+ // agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind. Stamp the
402
+ // node's OWN `repository` (#739) from the `owner/repo#N` it implements, so a cross-repo sequence
403
+ // provisions each cell's isolation envelope node-locally — no uniform run-level repo, no `repoless`.
404
+ // The base branch is left to the run-level fallback (an issue ref names no branch — issue #739 Notes).
188
405
  nodes.push({
189
406
  id: openId,
190
407
  kind: "agent",
191
- agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.` },
408
+ agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.`, repository: issue.repo },
192
409
  emits: [{ ...PR_EMIT }],
193
410
  });
194
411
  // connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
@@ -214,18 +431,16 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
214
431
  edges.push({ from: prRef, to: landId });
215
432
  edges.push({ from: prRef, to: mergedId });
216
433
 
217
- // Sequence: this issue's agent starts once the PRIOR issue merged; the first waits on the gate.
218
- if (i === 0) {
219
- if (behindKey) edges.push({ from: GATE_ID, to: openId });
220
- } else {
221
- edges.push({ from: `merged-${i}`, to: openId });
222
- }
434
+ // Sequence: this issue's agent starts once its predecessor (prior merge / epic gate / interleaved
435
+ // gate) released. A first ungated issue has no predecessor edge (it starts immediately).
436
+ if (agentPredecessor) edges.push({ from: agentPredecessor, to: openId });
223
437
  });
224
438
 
439
+ const gateCount = entries.filter((e) => e.gate).length;
225
440
  const name =
226
- issueKeys.length === 1
227
- ? `sequence ${issueKeys[0]}`
228
- : `sequence ${issueKeys.length} issues${behindKey ? ` behind ${behindKey}` : ""}`;
441
+ entries.length === 1
442
+ ? `sequence ${entries[0].issue.planKey}`
443
+ : `sequence ${entries.length} issues${gateCount > 0 ? ` with ${gateCount} gate${gateCount === 1 ? "" : "s"}` : ""}${behindKey ? ` behind ${behindKey}` : ""}`;
229
444
 
230
445
  return { name, nodes, edges };
231
446
  }
@@ -69,6 +69,49 @@ describe("S5 — the addressable operator guide over MCP (#611)", () => {
69
69
  assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
70
70
  });
71
71
 
72
+ test("section pagination: start/length pages a section and nextStart walks it to completion (#740)", async () => {
73
+ const whole = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
74
+ const wholeSection = (whole.json as { section?: { instructions: string } }).section!;
75
+ const wholeChars = Array.from(wholeSection.instructions);
76
+
77
+ const PAGE = 4000;
78
+ let start = 0;
79
+ let assembled = "";
80
+ let pages = 0;
81
+ let total = -1;
82
+ for (;;) {
83
+ const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start, length: PAGE });
84
+ assert(!res.isError, `a paged fetch must not error: ${res.text}`);
85
+ const body = res.json as {
86
+ kind?: string;
87
+ section?: { id: string; instructions: string; start: number; length: number; totalLength: number; nextStart: number | null };
88
+ };
89
+ assert.equal(body.kind, "section");
90
+ const s = body.section!;
91
+ assert.equal(s.id, "delivery-graphs");
92
+ assert(Array.from(s.instructions).length <= PAGE, "a page must be bounded by `length`");
93
+ assert.equal(s.start, start);
94
+ if (total !== -1) assert.equal(s.totalLength, total, "totalLength is stable across pages");
95
+ total = s.totalLength;
96
+ assembled += s.instructions;
97
+ pages++;
98
+ if (s.nextStart === null) break;
99
+ start = s.nextStart;
100
+ assert(pages < 100, "pagination must terminate");
101
+ }
102
+ assert(pages > 1, "a large section must span multiple pages at this window");
103
+ assert.equal(total, wholeChars.length, "totalLength equals the full section length");
104
+ assert.equal(assembled, wholeSection.instructions, "the reassembled pages equal the whole section");
105
+ });
106
+
107
+ test("section pagination: a non-integer/negative start is a uniform issues[{path,message}] 400 (#740)", async () => {
108
+ const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start: -5 });
109
+ assert(res.isError, "an invalid pagination arg must surface as a tool-level error");
110
+ const body = res.json as { issues?: { path: string; message: string }[] };
111
+ assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
112
+ assert(body.issues!.some((i) => i.path.includes("start")), "the issue must name the `start` argument");
113
+ });
114
+
72
115
  test("an unknown section id is rejected with issues[{path,message}]", async () => {
73
116
  const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
74
117
  assert(res.isError, "an unknown section id must surface as a tool-level error");