@nanobpm/nano-workforce 0.160.0 → 0.162.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,15 @@
1
+ ## [0.162.0](https://github.com/nanobpm/nano-workforce/compare/v0.161.0...v0.162.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * add sequenceIssues intent door for canonical delivery-graph chains ([#618](https://github.com/nanobpm/nano-workforce/issues/618)) ([698f4bf](https://github.com/nanobpm/nano-workforce/commit/698f4bf5b7e873bd1805ea4c671162db7037de5a)), closes [#605](https://github.com/nanobpm/nano-workforce/issues/605) [#610](https://github.com/nanobpm/nano-workforce/issues/610)
6
+
7
+ ## [0.161.0](https://github.com/nanobpm/nano-workforce/compare/v0.160.0...v0.161.0) (2026-08-29)
8
+
9
+ ### Features
10
+
11
+ * addressable operator guide via getAgentGuide(section?) over MCP ([#615](https://github.com/nanobpm/nano-workforce/issues/615)) ([8d59f54](https://github.com/nanobpm/nano-workforce/commit/8d59f541e25478ff7a076debc053d988f1d79b29)), closes [#611](https://github.com/nanobpm/nano-workforce/issues/611)
12
+
1
13
  ## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
2
14
 
3
15
  ### Features
@@ -0,0 +1,115 @@
1
+ // Tests for the addressable operator guide (epic #605 slice S5, issue #611): the stable section
2
+ // registry, the fence-aware markdown splitter, the table of contents, and per-section rendering.
3
+ // The drift guard here is the load-bearing test — it fails the build if the authored guide
4
+ // (`docs/agent-guide.md`) grows/loses a `## ` section without a matching `GUIDE_SECTIONS` entry, so
5
+ // the addressable surface can never silently diverge from the prose.
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
11
+ import {
12
+ GUIDE_SECTIONS,
13
+ guideToc,
14
+ renderAgentGuide,
15
+ renderGuideSection,
16
+ splitGuideSections,
17
+ } from "./agentGuide.ts";
18
+
19
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
20
+ const RAW_GUIDE = readFileSync(join(REPO_ROOT, "docs", "agent-guide.md"), "utf8");
21
+
22
+ test("DRIFT GUARD: every `## ` section in docs/agent-guide.md has exactly one registry entry", () => {
23
+ const { sections } = splitGuideSections(RAW_GUIDE);
24
+ assertEquals(
25
+ sections.length,
26
+ GUIDE_SECTIONS.length,
27
+ `docs/agent-guide.md has ${sections.length} top-level sections but GUIDE_SECTIONS lists ` +
28
+ `${GUIDE_SECTIONS.length}. Add/remove a { id, summary } entry so the addressable surface ` +
29
+ "matches the prose.",
30
+ );
31
+ });
32
+
33
+ test("section ids are unique and non-empty; summaries are non-empty", () => {
34
+ const ids = new Set<string>();
35
+ for (const s of GUIDE_SECTIONS) {
36
+ assert(s.id.length > 0, "section id must be non-empty");
37
+ assert(!ids.has(s.id), `duplicate section id "${s.id}"`);
38
+ ids.add(s.id);
39
+ assert(s.summary.trim().length > 0, `section "${s.id}" needs a one-line summary`);
40
+ }
41
+ });
42
+
43
+ test("the fence-aware splitter ignores `## ` inside fenced code blocks", () => {
44
+ const raw = [
45
+ "# Title",
46
+ "",
47
+ "## 0. Real heading",
48
+ "body",
49
+ "```",
50
+ "## not a heading (inside a fence)",
51
+ "```",
52
+ "more body",
53
+ "## 1. Second heading",
54
+ "tail",
55
+ ].join("\n");
56
+ const { sections } = splitGuideSections(raw);
57
+ assertEquals(sections.length, 2);
58
+ assertEquals(sections[0].title, "0. Real heading");
59
+ assert(sections[0].body.includes("## not a heading"), "fenced content stays in its section body");
60
+ assertEquals(sections[1].title, "1. Second heading");
61
+ });
62
+
63
+ test("guideToc lists every registry id with a derived title and its summary", () => {
64
+ const toc = guideToc();
65
+ assertEquals(toc.length, GUIDE_SECTIONS.length);
66
+ for (let i = 0; i < toc.length; i++) {
67
+ assertEquals(toc[i].id, GUIDE_SECTIONS[i].id);
68
+ assertEquals(toc[i].summary, GUIDE_SECTIONS[i].summary);
69
+ assert(toc[i].title.length > 0, `toc[${i}] must carry the derived heading title`);
70
+ }
71
+ // The TOC is compact by construction — comfortably under any tool-result limit.
72
+ assert(JSON.stringify(toc).length < 4000, "the table of contents must stay small");
73
+ });
74
+
75
+ test("delivery-graphs is addressable, smaller than the full guide, and base-keyed", () => {
76
+ const base = "https://example.test/app/api";
77
+ const section = renderGuideSection("delivery-graphs", base);
78
+ assert(section !== undefined, "delivery-graphs must resolve");
79
+ const full = renderAgentGuide(base);
80
+ assert(section!.length > 0);
81
+ assert(section!.length < full.length, "a single section must be smaller than the whole guide");
82
+ assert(section!.length < 30000, "the delivery-graphs section must fit a typical tool-result budget");
83
+ assert(!section!.includes("__BASE__"), "placeholders must be substituted in a section render");
84
+ assert(!section!.includes("__ENGINE__"), "engine placeholder must be substituted too");
85
+ });
86
+
87
+ test("a compact section is a small fraction of the whole guide", () => {
88
+ const base = "https://example.test/app/api";
89
+ const orient = renderGuideSection("orient", base)!;
90
+ const full = renderAgentGuide(base);
91
+ assert(orient.length < full.length / 10, "the orient section must be a small fraction of the guide");
92
+ });
93
+
94
+ test("every registry id resolves to a non-empty section whose heading matches its title", () => {
95
+ const base = "https://example.test/app/api";
96
+ const toc = guideToc();
97
+ for (const { id, title } of toc) {
98
+ const md = renderGuideSection(id, base);
99
+ assert(md !== undefined, `section "${id}" must resolve`);
100
+ assert(md!.startsWith(`## ${title}`), `section "${id}" body must open with its heading`);
101
+ }
102
+ });
103
+
104
+ test("an unknown section id resolves to undefined (the op turns that into a 400)", () => {
105
+ assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
106
+ });
107
+
108
+ test("no content regression: each section body is a verbatim slice of the raw guide", () => {
109
+ // Rendering keys examples to an instance; the UN-substituted section bodies must be exact
110
+ // substrings of the authored doc, so the addressable surface never rewrites guide content.
111
+ const { sections } = splitGuideSections(RAW_GUIDE);
112
+ for (const s of sections) {
113
+ assert(RAW_GUIDE.includes(s.body), `section "${s.title}" must be a verbatim slice of the guide`);
114
+ }
115
+ });
package/app/agentGuide.ts CHANGED
@@ -59,8 +59,117 @@ export function resolveEngineBase(): string {
59
59
  /**
60
60
  * Render the guide for a given app control-API base (e.g. "https://host/app/api"). The engine base
61
61
  * is resolved from the environment. Substitutes every `__BASE__`/`__ENGINE__` occurrence.
62
+ *
63
+ * This is the FULL guide, byte-for-byte the same content the non-MCP fallback doors serve
64
+ * (`GET /app/api/agent`, and — via the skill — `GET /app/api/agent/skill`). The addressable helpers
65
+ * below (`guideToc` / `renderGuideSection`) never touch this path, so those doors stay unchanged.
62
66
  */
63
67
  export function renderAgentGuide(apiBase: string): string {
64
68
  const base = apiBase.replace(/\/+$/, "");
65
69
  return RAW_GUIDE.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
66
70
  }
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
73
+ // Addressable guide (epic nano-workforce#605, slice S5, issue #611).
74
+ //
75
+ // The full guide is ~43KB — a single `getAgentInstructions` call can exceed an agent's tool-result
76
+ // limit, forcing it to persist the blob and carve out the section it wanted out-of-band. The guide
77
+ // is already well-structured as top-level `## N. Title` sections, so we make each one individually
78
+ // addressable: a compact table of contents (id + one-line summary), and per-section retrieval.
79
+ //
80
+ // STABLE IDS ARE THE CONTRACT. `GUIDE_SECTIONS` below is the single source of truth for the stable
81
+ // section ids and their summaries, in document order. The section *bodies* are derived by parsing
82
+ // the authored markdown (`docs/agent-guide.md`) at module load — never duplicated here — so prose
83
+ // edits never drift from the addressable surface. The parity is guarded by a drift test
84
+ // (`app/agentGuide.test.ts`): add or remove a `## ` section in the doc without updating this
85
+ // registry and the build fails.
86
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
87
+
88
+ /** One addressable top-level guide section: a stable id and a one-line summary, in document order.
89
+ * The `id` is the durable handle agents pass to `getAgentGuide(section)`; keep it stable across
90
+ * prose edits (rename the heading freely, never the id). */
91
+ export interface GuideSectionMeta {
92
+ readonly id: string;
93
+ readonly summary: string;
94
+ }
95
+
96
+ /** The stable section registry — ONE per `## N.` heading in `docs/agent-guide.md`, in order. Adding
97
+ * a section to the doc requires adding an entry here (enforced by the drift test). */
98
+ export const GUIDE_SECTIONS: readonly GuideSectionMeta[] = [
99
+ { id: "orient", summary: "Orient first: confirm which instance you're driving; see the live version and every PR in flight." },
100
+ { id: "submit-pr", summary: "Submit a PR to the review-convergence loop — review-only vs. converge-and-merge, dependency barriers, round caps." },
101
+ { id: "submit-epic", summary: "Hand a whole issue to the fleet: plan → implement → converge across coding agents, against a required base branch." },
102
+ { id: "escalations", summary: "Find and answer parked human-in-the-loop escalations (PR-review, feature, plan-review, trial-merge) by user-task key." },
103
+ { id: "lifecycle", summary: "The PR/epic lifecycle and status vocabulary, so you can reason about where a run currently sits." },
104
+ { id: "debug", summary: "Debug: find the engine process instance behind a PR and inspect its jobs, incidents, and element-instances." },
105
+ { id: "debug-models", summary: "Debug the deployed BPMN models and the agent prompts a running instance is actually using." },
106
+ { id: "unstick", summary: "Unstick a wedged process — publish a correlating message, cancel, or otherwise recover a stalled instance." },
107
+ { id: "raise-issue", summary: "Raise an issue or open a PR against the nano-workforce repository itself." },
108
+ { id: "delivery-graphs", summary: "Author, preview, compile/stage and run an agent-authored delivery graph (ADR 0005): node/wait/connector vocabulary." },
109
+ ] as const;
110
+
111
+ /** A parsed section: its stable id + summary (from the registry), the derived heading `title` (the
112
+ * markdown text after `## `, e.g. "9. Author and run a delivery graph (ADR 0005)"), and the raw
113
+ * section markdown `body` INCLUDING the heading line, with `__BASE__`/`__ENGINE__` un-substituted. */
114
+ export interface ParsedGuideSection extends GuideSectionMeta {
115
+ readonly title: string;
116
+ readonly body: string;
117
+ }
118
+
119
+ /** Split raw guide markdown into its top-level `## ` sections, fence-aware (a `## ` inside a fenced
120
+ * code block is content, not a heading). Everything before the first heading is the preamble.
121
+ * Pure/​testable — takes the raw text so the drift test can feed it the on-disk doc directly. */
122
+ export function splitGuideSections(raw: string): { preamble: string; sections: { title: string; body: string }[] } {
123
+ const lines = raw.split("\n");
124
+ const sections: { title: string; lines: string[] }[] = [];
125
+ const preambleLines: string[] = [];
126
+ let inFence = false;
127
+ let current: { title: string; lines: string[] } | null = null;
128
+ for (const line of lines) {
129
+ if (/^(```|~~~)/.test(line.trim())) inFence = !inFence;
130
+ const heading = !inFence ? /^## (.+)$/.exec(line) : null;
131
+ if (heading) {
132
+ current = { title: heading[1].trim(), lines: [line] };
133
+ sections.push(current);
134
+ } else if (current) {
135
+ current.lines.push(line);
136
+ } else {
137
+ preambleLines.push(line);
138
+ }
139
+ }
140
+ return {
141
+ preamble: preambleLines.join("\n"),
142
+ sections: sections.map((s) => ({ title: s.title, body: s.lines.join("\n").replace(/\s+$/, "") })),
143
+ };
144
+ }
145
+
146
+ /** The parsed sections of THIS deployment's guide, zipped against the stable registry, computed once
147
+ * at module load. When the doc is unreadable (fallback guide, no `## ` headings) this is empty — the
148
+ * addressable surface degrades to "no sections", never throws. Length/registry parity is asserted by
149
+ * the drift test, not at runtime, so a mismatched deploy still serves what it can. */
150
+ const PARSED_SECTIONS: readonly ParsedGuideSection[] = (() => {
151
+ const { sections } = splitGuideSections(RAW_GUIDE);
152
+ const n = Math.min(sections.length, GUIDE_SECTIONS.length);
153
+ const out: ParsedGuideSection[] = [];
154
+ for (let i = 0; i < n; i++) {
155
+ out.push({ ...GUIDE_SECTIONS[i], title: sections[i].title, body: sections[i].body });
156
+ }
157
+ return out;
158
+ })();
159
+
160
+ /** The compact table of contents: every addressable section's id, derived heading title, and stable
161
+ * one-line summary, in document order. Small by construction — safe to return whole under any
162
+ * tool-result limit. Titles reflect THIS deployment's doc; summaries come from the registry. */
163
+ export function guideToc(): { id: string; title: string; summary: string }[] {
164
+ return PARSED_SECTIONS.map((s) => ({ id: s.id, title: s.title, summary: s.summary }));
165
+ }
166
+
167
+ /** Render a SINGLE section's markdown for a given app control-API base, `__BASE__`/`__ENGINE__`
168
+ * substituted exactly as {@link renderAgentGuide} does for the whole guide. Returns `undefined` for
169
+ * an unknown id (the caller turns that into a 400 listing the valid ids). */
170
+ export function renderGuideSection(id: string, apiBase: string): string | undefined {
171
+ const section = PARSED_SECTIONS.find((s) => s.id === id);
172
+ if (!section) return undefined;
173
+ const base = apiBase.replace(/\/+$/, "");
174
+ return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
175
+ }
@@ -0,0 +1,108 @@
1
+ // nano-workforce — the ONE compile-and-stage flow shared by every agent-facing door that stages a
2
+ // delivery graph (epic nano-workforce#605). Extracted from `operations/compileDeliveryGraph.ts` (S0)
3
+ // so the intent-shaped generator doors (S4 — `sequenceIssues`) hand their CONSTRUCTED graph to the
4
+ // EXACT same deterministic compile → validate → stage path the raw `compileDeliveryGraph` door uses:
5
+ // one compiler (`compileDeliveryGraph`), one staging path (`stageProposal`), one idempotency/digest
6
+ // semantics (content-addressed by `deliveryGraphDigest`). Per AGENTS.md "Derivation over duplication:
7
+ // no drift surfaces", a generator MUST NOT re-implement a second runner or a second staging path —
8
+ // it only produces the `DeliveryGraph` and delegates here.
9
+ //
10
+ // The result mirrors the `compileDeliveryGraph` operation's wire contract exactly: a `200` carrying
11
+ // the `CompileDeliveryGraphStaged` preview + navigational `reviewUrl` (NO dispatch handle — dispatch
12
+ // stays an operator-only cockpit action, ADR 0005 Decision 7 / issue #460), or a `400` carrying the
13
+ // `CompileDeliveryGraphErrors` `{ ok:false, errors:[{path,message}] }` when the graph fails shape or
14
+ // semantic validation. Nothing is staged on a rejected compile.
15
+ import type { DataLayer } from "@nanobpm/urban";
16
+ import type { CompileDeliveryGraphErrors, CompileDeliveryGraphStaged } from "../nano-generated/api-io.d.ts";
17
+ import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
18
+ import {
19
+ buildProposalPreview,
20
+ buildProposalRow,
21
+ proposalLogicalKey,
22
+ proposalReviewUrl,
23
+ stageProposal,
24
+ } from "./deliveryGraphProposals.ts";
25
+ import { deliveryGraphDigest } from "./deliveryRunner.ts";
26
+
27
+ /** The human-readable instruction every staged compile hands back — the agent's surface ends here;
28
+ * dispatch is an operator action and there is no start endpoint (capability-by-absence, #460). */
29
+ export const STAGED_MESSAGE =
30
+ "The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
31
+
32
+ /** A successful stage — the `CompileDeliveryGraphStaged` body plus the compiled `digest` and node
33
+ * counts, so a caller can log what it staged without re-deriving them. */
34
+ export interface StagedResult {
35
+ ok: true;
36
+ status: 200;
37
+ body: CompileDeliveryGraphStaged;
38
+ digest: string;
39
+ nodeCount: number;
40
+ humanNodeCount: number;
41
+ sideEffectCount: number;
42
+ }
43
+
44
+ /** A rejected compile — the `CompileDeliveryGraphErrors` body, verbatim from the compiler. */
45
+ export interface StageErrors {
46
+ ok: false;
47
+ status: 400;
48
+ body: CompileDeliveryGraphErrors;
49
+ }
50
+
51
+ /**
52
+ * Compile a `DeliveryGraph` and, when valid, persist it as a `staged` proposal — the single
53
+ * compile+stage flow (see the module header). `graph` is the structured `DeliveryGraph` object (an
54
+ * agent-authored one from `compileDeliveryGraph`, or a generator-CONSTRUCTED one from a `start/*`
55
+ * intent door); `graphJson` is the serialised form persisted on the proposal row for the cockpit
56
+ * dispatch to re-run (the caller supplies it so a generator can persist the SAME object it compiled,
57
+ * byte-for-byte). `origin` is the public origin the request arrived on, used to build the
58
+ * navigational `reviewUrl`. Never dispatches; never throws for a malformed graph (the compiler maps
59
+ * unknown input to a clean `ok:false`).
60
+ */
61
+ export async function compileAndStageDeliveryGraph(
62
+ data: DataLayer,
63
+ graph: unknown,
64
+ graphJson: string,
65
+ origin: string,
66
+ ): Promise<StagedResult | StageErrors> {
67
+ const result = await compileDeliveryGraph(graph);
68
+ if (!result.ok) {
69
+ return { ok: false, status: 400, body: result };
70
+ }
71
+
72
+ const digest = deliveryGraphDigest(result.bpmn);
73
+ const name =
74
+ typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
75
+ ? result.resolved.name.trim()
76
+ : null;
77
+ const preview = buildProposalPreview(result);
78
+ await stageProposal(
79
+ data,
80
+ buildProposalRow({
81
+ digest,
82
+ logicalKey: proposalLogicalKey(name, digest),
83
+ title: name,
84
+ graphJson,
85
+ preview,
86
+ nodeCount: result.resolved.nodes.length,
87
+ humanNodeCount: result.humanNodes.length,
88
+ sideEffectCount: result.sideEffects.length,
89
+ sideEffecting: result.sideEffects.length > 0,
90
+ }),
91
+ );
92
+
93
+ return {
94
+ ok: true,
95
+ status: 200,
96
+ body: {
97
+ status: "ready",
98
+ message: STAGED_MESSAGE,
99
+ digest,
100
+ preview,
101
+ reviewUrl: proposalReviewUrl(digest, origin),
102
+ },
103
+ digest,
104
+ nodeCount: result.resolved.nodes.length,
105
+ humanNodeCount: result.humanNodes.length,
106
+ sideEffectCount: result.sideEffects.length,
107
+ };
108
+ }
@@ -0,0 +1,186 @@
1
+ // Tests for the `sequenceIssues` intent → canonical delivery-graph GENERATOR (epic
2
+ // nano-workforce#605, S4/#610). The core acceptance guard: the generated graph is EQUIVALENT to the
3
+ // hand-authored canonical §9.4 chain for the same inputs — same node kinds, edges, and `pr`-fact
4
+ // threading — so an agent never re-authors 13 nodes + 12 edges by hand. Also pins the input/vocabulary
5
+ // validation contract (`issues[{path,message}]`).
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
9
+ import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
10
+ import { buildSequenceGraph, MAX_SEQUENCE_ISSUES, MERGE_POLL } from "./sequenceIssues.ts";
11
+
12
+ type AnyGraph = { name?: string; nodes: any[]; edges: any[] };
13
+
14
+ function ok(intent: unknown): AnyGraph {
15
+ const res = buildSequenceGraph(intent);
16
+ assert(res.ok, `expected ok, got ${JSON.stringify(res)}`);
17
+ return res.graph as AnyGraph;
18
+ }
19
+
20
+ /** The hand-authored canonical chain for the SAME inputs — what §9.4 says an agent would build by
21
+ * hand. The generator must produce a structurally-equivalent graph. */
22
+ function handAuthored(behind: string | null, issues: string[]): AnyGraph {
23
+ const nodes: any[] = [];
24
+ const edges: any[] = [];
25
+ if (behind) {
26
+ nodes.push({
27
+ id: "gate-epic",
28
+ kind: "wait",
29
+ wait: { kind: "epic", target: behind, match: { epicState: "merged" }, poll: { ...MERGE_POLL }, onTimeout: "escalate" },
30
+ emits: [{ name: "prCount", type: "number" }],
31
+ });
32
+ }
33
+ issues.forEach((issue, i) => {
34
+ const n = i + 1;
35
+ nodes.push({
36
+ id: `open-${n}`,
37
+ kind: "agent",
38
+ agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.` },
39
+ emits: [{ name: "pr", type: "pr" }],
40
+ });
41
+ nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
42
+ nodes.push({
43
+ id: `merged-${n}`,
44
+ kind: "wait",
45
+ wait: { kind: "pr", target: `open-${n}.pr`, match: { prState: "merged" }, poll: { ...MERGE_POLL }, onTimeout: "escalate" },
46
+ });
47
+ edges.push({ from: `open-${n}.pr`, to: `land-${n}` });
48
+ edges.push({ from: `open-${n}.pr`, to: `merged-${n}` });
49
+ if (i === 0) {
50
+ if (behind) edges.push({ from: "gate-epic", to: `open-${n}` });
51
+ } else {
52
+ edges.push({ from: `merged-${i}`, to: `open-${n}` });
53
+ }
54
+ });
55
+ return { nodes, edges };
56
+ }
57
+
58
+ /** Compare two graphs by their SEMANTIC content — node set (by id) and edge set — order-insensitive. */
59
+ function assertEquivalent(actual: AnyGraph, expected: AnyGraph): void {
60
+ const byId = (g: AnyGraph) => new Map(g.nodes.map((n) => [n.id, n]));
61
+ const a = byId(actual);
62
+ const e = byId(expected);
63
+ assertEquals([...a.keys()].sort(), [...e.keys()].sort(), "same node ids");
64
+ for (const [id, node] of e) assertEquals(a.get(id), node, `node ${id} matches canonical`);
65
+ const edgeKey = (x: any) => JSON.stringify([x.from, x.to, x.when ?? null, x.equals ?? null, x.default ?? null]);
66
+ assertEquals(actual.edges.map(edgeKey).sort(), expected.edges.map(edgeKey).sort(), "same edge set");
67
+ }
68
+
69
+ test("sequenceIssues: four issues behind a gate → the exact 13-node / 12-edge canonical chain", () => {
70
+ const behind = "acme/repo#100";
71
+ const issues = ["acme/repo#1", "acme/repo#2", "acme/repo#3", "acme/repo#4"];
72
+ const graph = ok({ behind, issues });
73
+ // The evidence-session shape: 1 gate + 3 nodes/issue = 13 nodes; 8 fact edges + 1 gate edge + 3
74
+ // sequence edges = 12 edges.
75
+ assertEquals(graph.nodes.length, 13);
76
+ assertEquals(graph.edges.length, 12);
77
+ assertEquivalent(graph, handAuthored(behind, issues));
78
+ });
79
+
80
+ test("sequenceIssues: without `behind`, no leading epic gate and no gate edge", () => {
81
+ const issues = ["acme/repo#1", "acme/repo#2"];
82
+ const graph = ok({ issues });
83
+ assertEquals(graph.nodes.length, 6);
84
+ assertEquals(graph.edges.length, 5); // 2 fact edges/issue (4) + 1 sequence edge
85
+ assert(!graph.nodes.some((n) => n.id === "gate-epic"), "no epic gate without `behind`");
86
+ assertEquivalent(graph, handAuthored(null, issues));
87
+ });
88
+
89
+ test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connector(converge-merge) → wait[pr,merged]", () => {
90
+ const graph = ok({ issues: ["acme/repo#7"] });
91
+ const open = graph.nodes.find((n) => n.id === "open-1");
92
+ const land = graph.nodes.find((n) => n.id === "land-1");
93
+ const merged = graph.nodes.find((n) => n.id === "merged-1");
94
+ assertEquals(open.kind, "agent");
95
+ assertEquals(open.agent.jobType, "senior:feature");
96
+ assertEquals(open.emits, [{ name: "pr", type: "pr" }]);
97
+ assertEquals(land.kind, "connector");
98
+ assertEquals(land.connector.target, "converge-merge");
99
+ assertEquals(land.connector.payload, { pr: "open-1.pr" });
100
+ assertEquals(merged.kind, "wait");
101
+ assertEquals(merged.wait.kind, "pr");
102
+ assertEquals(merged.wait.target, "open-1.pr");
103
+ assertEquals(merged.wait.match, { prState: "merged" });
104
+ // The pr fact is threaded to BOTH consumers by fact-qualified edges (§9.4).
105
+ assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "land-1"));
106
+ assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
107
+ });
108
+
109
+ test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
110
+ const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
111
+ const gate = graph.nodes.find((n) => n.id === "gate-epic");
112
+ const merged = graph.nodes.find((n) => n.id === "merged-1");
113
+ assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
114
+ assertEquals(merged.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
115
+ assert(merged.wait.poll.timeoutMs > 30 * 60 * 1000, "budget must exceed the 30-minute default");
116
+ });
117
+
118
+ test("sequenceIssues: the generated graph passes validateDeliveryGraph AND compiles", async () => {
119
+ const graph = ok({ behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2", "acme/repo#3", "acme/repo#4"] });
120
+ assertEquals(validateDeliveryGraph(graph), []);
121
+ const compiled = await compileDeliveryGraph(graph);
122
+ assert(compiled.ok, `expected the generated graph to compile, got ${JSON.stringify(compiled)}`);
123
+ });
124
+
125
+ test("sequenceIssues: an issue URL is accepted and normalised to owner/repo#N", () => {
126
+ const graph = ok({ issues: ["https://github.com/acme/repo/issues/42"] });
127
+ const open = graph.nodes.find((n) => n.id === "open-1");
128
+ assertEquals(open.agent.prompt, "Implement acme/repo#42 and open a PR.");
129
+ });
130
+
131
+ // ── Validation: the uniform issues[{path,message}] contract ─────────────────────────────────────
132
+ function rejects(intent: unknown): Array<{ path: string; message: string }> {
133
+ const res = buildSequenceGraph(intent);
134
+ assert(!res.ok, `expected rejection, got ${JSON.stringify(res)}`);
135
+ assert(Array.isArray(res.issues) && res.issues.length > 0, "issues[] must be non-empty");
136
+ for (const iss of res.issues) {
137
+ assert(typeof iss.path === "string" && typeof iss.message === "string", `bad issue ${JSON.stringify(iss)}`);
138
+ }
139
+ return res.issues;
140
+ }
141
+
142
+ test("sequenceIssues: empty issues → rejected with issues[{path,message}]", () => {
143
+ const issues = rejects({ issues: [] });
144
+ assert(issues.some((i) => i.path === "issues"));
145
+ });
146
+
147
+ test("sequenceIssues: missing issues → rejected", () => {
148
+ const issues = rejects({});
149
+ assert(issues.some((i) => i.path === "issues"));
150
+ });
151
+
152
+ test("sequenceIssues: an unparseable issue ref → rejected at the offending index", () => {
153
+ const issues = rejects({ issues: ["acme/repo#1", "not-an-issue"] });
154
+ assert(issues.some((i) => i.path === "issues[1]"), `expected issues[1] path, got ${JSON.stringify(issues)}`);
155
+ });
156
+
157
+ test("sequenceIssues: an unparseable `behind` ref → rejected at behind", () => {
158
+ const issues = rejects({ behind: "nope", issues: ["acme/repo#1"] });
159
+ assert(issues.some((i) => i.path === "behind"));
160
+ });
161
+
162
+ test("sequenceIssues: an empty-string `behind` → rejected at behind (not silently ungated)", () => {
163
+ const issues = rejects({ behind: "", issues: ["acme/repo#1"] });
164
+ assert(issues.some((i) => i.path === "behind"), `expected behind path, got ${JSON.stringify(issues)}`);
165
+ });
166
+
167
+ test("sequenceIssues: a non-positive issue number (`#0`) → rejected at the offending index", () => {
168
+ const issues = rejects({ issues: ["acme/repo#0"] });
169
+ assert(issues.some((i) => i.path === "issues[0]"), `expected issues[0] path, got ${JSON.stringify(issues)}`);
170
+ });
171
+
172
+ test("sequenceIssues: an unsafe-integer issue number → rejected at the offending index", () => {
173
+ const issues = rejects({ issues: ["acme/repo#1", "acme/repo#99999999999999999999"] });
174
+ assert(issues.some((i) => i.path === "issues[1]"), `expected issues[1] path, got ${JSON.stringify(issues)}`);
175
+ });
176
+
177
+ test("sequenceIssues: a non-positive `behind` number (`#0`) → rejected at behind", () => {
178
+ const issues = rejects({ behind: "acme/repo#0", issues: ["acme/repo#1"] });
179
+ assert(issues.some((i) => i.path === "behind"), `expected behind path, got ${JSON.stringify(issues)}`);
180
+ });
181
+
182
+ test("sequenceIssues: more than the max issues → rejected", () => {
183
+ const many = Array.from({ length: MAX_SEQUENCE_ISSUES + 1 }, (_, i) => `acme/repo#${i + 1}`);
184
+ const issues = rejects({ issues: many });
185
+ assert(issues.some((i) => i.path === "issues"));
186
+ });