@nanobpm/nano-workforce 0.161.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,9 @@
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
+
1
7
  ## [0.161.0](https://github.com/nanobpm/nano-workforce/compare/v0.160.0...v0.161.0) (2026-08-29)
2
8
 
3
9
  ### Features
@@ -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
+ });
@@ -0,0 +1,236 @@
1
+ // nano-workforce — the `sequenceIssues` intent → canonical delivery-graph GENERATOR (epic
2
+ // nano-workforce#605, S4/#610). ADR 0005's delivery graph is a closed vocabulary, and §9.4 of the
3
+ // operator guide already names the canonical shape for "implement issue → converge → merge". But an
4
+ // agent still had to hand-author the full node/edge JSON: in the evidence session, sequencing four
5
+ // issues behind a gate meant constructing 13 nodes and 12 edges by hand. This module produces that
6
+ // exact shape from a high-level INTENT instead.
7
+ //
8
+ // The intent is `{ behind?: "owner/repo#NN", issues: ["owner/repo#A", …] }`. For each issue it emits
9
+ // the canonical chain — `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`,
10
+ // late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`) — and threads the `pr`
11
+ // fact along fact-qualified edges per §9.4. The issues run in SEQUENCE: each issue's agent starts once
12
+ // the PRIOR issue has merged. An optional leading `wait[epic]` gate (when `behind` is given) makes the
13
+ // whole sequence wait for that issue/epic/feature to be fully merged first (§9.5).
14
+ //
15
+ // It is a PURE builder: no I/O, no staging. The operation (`operations/sequenceIssues.ts`) hands the
16
+ // constructed `DeliveryGraph` to the SAME `compileAndStageDeliveryGraph` flow the raw
17
+ // `compileDeliveryGraph` door uses (one compiler, one staging path — AGENTS.md "no drift surfaces").
18
+ // The generated graph is authored to pass `validateDeliveryGraph` by construction: unique node ids,
19
+ // `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
20
+ // (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
21
+ // `issues[{path,message}]` rather than only surfacing at compile time.
22
+ import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
23
+ import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
24
+ import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
25
+ import { type ParsedIssue, parseIssue } from "./plan.ts";
26
+
27
+ /** A path-qualified validation failure — the uniform door error contract `issues[{path,message}]`
28
+ * (the same shape the runtime request-validator and the S1 harness assert). */
29
+ export interface SequenceIssueError {
30
+ readonly path: string;
31
+ readonly message: string;
32
+ }
33
+
34
+ /** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`. */
35
+ export interface SequenceIssuesIntent {
36
+ behind?: string;
37
+ issues: string[];
38
+ }
39
+
40
+ /** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
41
+ * input/vocabulary rejections. */
42
+ export type SequenceIssuesResult =
43
+ | { ok: true; graph: DeliveryGraph }
44
+ | { ok: false; issues: SequenceIssueError[] };
45
+
46
+ /** The realistic merge-gate poll budget the canonical shape uses (§9.4 / §9.1): re-probe every 5
47
+ * minutes, budget 3 days. A `wait[pr, merged]` / `wait[epic]` waits on a human-paced merge, so the
48
+ * 30-minute default poll budget is a trap — always set an explicit `poll.timeoutMs` on a merge/epic
49
+ * gate. Exported so the regression test asserts the generated gate against the canonical values. */
50
+ export const MERGE_POLL = { everyMs: 300_000, timeoutMs: 259_200_000 } as const;
51
+
52
+ /** The wait-probe kind that observes a single in-flight PR's merge state (ADR 0005 §2). */
53
+ const PR_PROBE_KIND = "pr";
54
+ /** The wait-probe kind that observes a whole epic/feature lineage reaching "fully merged" (§9.5). */
55
+ const EPIC_PROBE_KIND = "epic";
56
+ /** The agent job type each issue node runs (a full single-issue feature implementation). */
57
+ const AGENT_JOB_TYPE = "senior:feature";
58
+ /** The upper bound on issues in one sequence — keeps the generated graph within the compiler's
59
+ * 256-node ceiling (3 nodes/issue + an optional gate) with generous headroom, and bounds the intent. */
60
+ export const MAX_SEQUENCE_ISSUES = 64;
61
+
62
+ /** A `pr`-typed emit declaration — what an `agent` node publishes for the PR it opened, so the
63
+ * downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
64
+ const PR_EMIT = { name: "pr", type: "pr" as const };
65
+
66
+ /** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
67
+ * `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
68
+ * past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
69
+ * gate on it would wait forever. Reject it deterministically at the door instead (Copilot review,
70
+ * PR #618), so only `#N` with `N >= 1 && Number.isSafeInteger(N)` is accepted. */
71
+ function parseIssueRef(ref: unknown): ParsedIssue | null {
72
+ const parsed = typeof ref === "string" ? parseIssue(ref) : null;
73
+ if (!parsed) return null;
74
+ return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
75
+ }
76
+
77
+ /**
78
+ * Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
79
+ * `issues[{path,message}]` rejections for invalid input. Validates:
80
+ * - `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).
83
+ * Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
84
+ */
85
+ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
86
+ const issues: SequenceIssueError[] = [];
87
+
88
+ const body = isRecord(intent) ? intent : {};
89
+ const rawIssues = body.issues;
90
+ const rawBehind = body.behind;
91
+
92
+ // ── `issues`: a non-empty, bounded array of parseable refs ───────────────────────────────────
93
+ const parsedIssues: string[] = [];
94
+ if (!Array.isArray(rawIssues)) {
95
+ issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
96
+ } else if (rawIssues.length === 0) {
97
+ issues.push({ path: "issues", message: "`issues` must contain at least one `owner/repo#N` issue reference." });
98
+ } else if (rawIssues.length > MAX_SEQUENCE_ISSUES) {
99
+ issues.push({
100
+ path: "issues",
101
+ message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
102
+ });
103
+ } else {
104
+ rawIssues.forEach((ref, i) => {
105
+ const parsed = parseIssueRef(ref);
106
+ if (!parsed) {
107
+ issues.push({
108
+ path: `issues[${i}]`,
109
+ message: `\`${String(ref)}\` is not a valid \`owner/repo#N\` issue reference.`,
110
+ });
111
+ return;
112
+ }
113
+ parsedIssues.push(parsed.planKey);
114
+ });
115
+ }
116
+
117
+ // ── `behind` (optional): a parseable ref, when PRESENT ───────────────────────────────────────
118
+ // Only an OMITTED gate (`undefined`/`null`) is "no gate"; a present-but-empty `behind: ""` is a
119
+ // caller mistake (the schema requires `minLength: 1`), not an ungated sequence — reject it rather
120
+ // than silently dropping the gate the caller asked for (matters most when this builder is invoked
121
+ // directly, bypassing OpenAPI validation).
122
+ let behindKey: string | null = null;
123
+ if (rawBehind !== undefined && rawBehind !== null) {
124
+ const parsed = parseIssueRef(rawBehind);
125
+ if (!parsed) {
126
+ issues.push({
127
+ path: "behind",
128
+ message: `\`${String(rawBehind)}\` is not a valid \`owner/repo#N\` issue/epic reference.`,
129
+ });
130
+ } else {
131
+ behindKey = parsed.planKey;
132
+ }
133
+ }
134
+
135
+ // ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
136
+ // structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
137
+ // surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
138
+ const vocab = deliveryGraphVocabulary();
139
+ const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
140
+ if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
141
+ issues.push({
142
+ path: "issues",
143
+ message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
144
+ });
145
+ }
146
+ const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
147
+ for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
148
+ if (!probeKinds.has(kind)) {
149
+ issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
150
+ }
151
+ }
152
+
153
+ if (issues.length > 0) return { ok: false, issues };
154
+
155
+ return { ok: true, graph: assembleGraph(parsedIssues, behindKey) };
156
+ }
157
+
158
+ /** Assemble the canonical node/edge chain for the (already-validated) issue keys + optional gate. */
159
+ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryGraph {
160
+ const nodes: DeliveryNode[] = [];
161
+ const edges: DeliveryEdge[] = [];
162
+
163
+ // Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
164
+ const GATE_ID = "gate-epic";
165
+ if (behindKey) {
166
+ nodes.push({
167
+ id: GATE_ID,
168
+ kind: "wait",
169
+ wait: {
170
+ kind: EPIC_PROBE_KIND,
171
+ target: behindKey,
172
+ match: { epicState: "merged" },
173
+ poll: { ...MERGE_POLL },
174
+ onTimeout: "escalate",
175
+ },
176
+ emits: [{ name: "prCount", type: "number" }],
177
+ });
178
+ }
179
+
180
+ issueKeys.forEach((issueKey, i) => {
181
+ const n = i + 1;
182
+ const openId = `open-${n}`;
183
+ const landId = `land-${n}`;
184
+ const mergedId = `merged-${n}`;
185
+ const prRef = `${openId}.pr`;
186
+
187
+ // agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
188
+ nodes.push({
189
+ id: openId,
190
+ kind: "agent",
191
+ agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.` },
192
+ emits: [{ ...PR_EMIT }],
193
+ });
194
+ // connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
195
+ nodes.push({
196
+ id: landId,
197
+ kind: "connector",
198
+ connector: { target: CONVERGE_MERGE_TARGET, payload: { pr: prRef } },
199
+ });
200
+ // wait[pr, merged] → observe the PR reaching `merged`, with a realistic poll budget.
201
+ nodes.push({
202
+ id: mergedId,
203
+ kind: "wait",
204
+ wait: {
205
+ kind: PR_PROBE_KIND,
206
+ target: prRef,
207
+ match: { prState: "merged" },
208
+ poll: { ...MERGE_POLL },
209
+ onTimeout: "escalate",
210
+ },
211
+ });
212
+
213
+ // Thread the `pr` fact to both consumers (required — an unthreaded reference is `unbound-pr`).
214
+ edges.push({ from: prRef, to: landId });
215
+ edges.push({ from: prRef, to: mergedId });
216
+
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
+ }
223
+ });
224
+
225
+ const name =
226
+ issueKeys.length === 1
227
+ ? `sequence ${issueKeys[0]}`
228
+ : `sequence ${issueKeys.length} issues${behindKey ? ` behind ${behindKey}` : ""}`;
229
+
230
+ return { name, nodes, edges };
231
+ }
232
+
233
+ /** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
234
+ function isRecord(value: unknown): value is Record<string, unknown> {
235
+ return typeof value === "object" && value !== null && !Array.isArray(value);
236
+ }
@@ -706,6 +706,17 @@ ways to name the target PR:
706
706
  `senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
707
707
  on the agent node is all it takes to publish it (issue #548).
708
708
 
709
+ > **Don't hand-author this shape — generate it.** When your intent is simply "sequence these
710
+ > issues, each implemented → converged → merged (optionally behind a gate)", call the
711
+ > **`sequenceIssues`** door instead of assembling the nodes/edges by hand. Its body is the intent
712
+ > `{ "issues": ["owner/repo#A", "owner/repo#B", …] }` with an optional leading `"behind": "owner/repo#NN"`
713
+ > gate, and it GENERATES
714
+ > exactly the canonical chain above — for each issue `agent` (`senior:feature`, emits `pr`) →
715
+ > `connector[converge-merge]` → `wait[pr, merged]` with a realistic `poll.timeoutMs`, threading the
716
+ > `pr` fact, plus an optional leading `wait[epic]` gate (§9.5) when `behind` is given — then STAGES it
717
+ > through the same compile+stage path as `compileDeliveryGraph` (it never dispatches). The issues run
718
+ > in **sequence**: each issue's implementation starts once the prior issue has merged.
719
+
709
720
  ### 9.5 Gate a graph on an epic reaching "fully merged" (`wait[epic]`)
710
721
 
711
722
  Sometimes the thing you must wait for is not one PR but a **whole epic** — an nwf
@@ -0,0 +1,94 @@
1
+ // `sequenceIssues` intent-door regression net (epic #605 slice S4, issue #610).
2
+ //
3
+ // PINS the acceptance guarantees over the REAL runtime-served `/app/mcp` surface:
4
+ // • the door is PROJECTED as an MCP tool whose input schema is self-contained ($ref-free, explicit
5
+ // type) — an agent can discover + call it from a standard client (S0 invariant);
6
+ // • an object-body intent arrives AS AN OBJECT (not stringified), stages a delivery graph, and the
7
+ // staged digest is immediately visible via `listStagedProposals` (compile+stage reuse, S2);
8
+ // • the response carries NO dispatch handle — the door STAGES, never dispatches (operator-only);
9
+ // • invalid input (empty `issues`, an unparseable ref) is rejected with `issues[{path,message}]`.
10
+ //
11
+ // It is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports `bootMcpHarness`
12
+ // and the shared assertion helpers and drives the exact client handshake an agent uses — it does NOT
13
+ // re-implement the transport (see the harness module header's EXTENSION SEAM).
14
+ //
15
+ // Run with `npm run e2e`.
16
+ import assert from "node:assert/strict";
17
+ import { after, before, describe, test } from "node:test";
18
+ import {
19
+ assertObjectBodyAccepted,
20
+ assertSchemaSelfContained,
21
+ assertValidationIssues,
22
+ bootMcpHarness,
23
+ type McpHarness,
24
+ } from "./support/mcp-harness.ts";
25
+
26
+ const TOOL = "sequenceIssues";
27
+
28
+ interface ListBody { count: number; proposals: Array<{ digest: string; title: string | null }> }
29
+
30
+ /** The current live staged list, read over the SAME MCP surface. */
31
+ async function listStaged(h: McpHarness): Promise<ListBody> {
32
+ const res = await h.callTool("listStagedProposals", {});
33
+ assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
34
+ const json = res.json as ListBody | undefined;
35
+ assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
36
+ return json;
37
+ }
38
+
39
+ describe("S4 — sequenceIssues generates + stages the canonical chain over MCP (#610)", () => {
40
+ let h: McpHarness;
41
+ before(async () => { h = await bootMcpHarness(); });
42
+ after(async () => { await h.stop(); });
43
+
44
+ test("the tool is projected with a self-contained ($ref-free, typed) input schema", async () => {
45
+ const tools = await h.listTools();
46
+ const tool = tools.find((t) => t.name === TOOL);
47
+ assert.ok(tool, `${TOOL} must be projected onto the MCP surface`);
48
+ assertSchemaSelfContained(tool.inputSchema, TOOL);
49
+ // Dispatch is operator-only — the dispatch door must NOT be projected.
50
+ assert.ok(!tools.some((t) => t.name === "dispatchDeliveryGraph"), "dispatch stays off the agent surface");
51
+ });
52
+
53
+ test("a valid intent object stages the canonical graph — the staged digest is immediately listed", async () => {
54
+ const res = await h.callTool(TOOL, {
55
+ body: { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2", "acme/repo#3"] },
56
+ });
57
+ assertObjectBodyAccepted(res, TOOL); // the object argument arrived as an object, not a string
58
+ assert.ok(!res.isError, `${TOOL} must stage a valid intent: ${res.text}`);
59
+ const json = res.json as { status?: string; digest?: string; preview?: unknown } | undefined;
60
+ assert.equal(json?.status, "ready", `${TOOL} must report status:"ready": ${res.text}`);
61
+ assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
62
+ assert.ok(json?.preview && typeof json.preview === "object", `a preview is required: ${res.text}`);
63
+
64
+ // Read-after-write: the staged digest is visible immediately (shared compile+stage path, S2).
65
+ const list = await listStaged(h);
66
+ assert.ok(
67
+ list.proposals.some((p) => p.digest === json.digest),
68
+ `the staged digest ${json.digest} must appear in listStagedProposals immediately (got ${JSON.stringify(list.proposals.map((p) => p.digest))})`,
69
+ );
70
+
71
+ // The door STAGES, never dispatches — no run handle in the response.
72
+ for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
73
+ assert.ok(!(forbidden in (json as Record<string, unknown>)), `response must not carry a dispatch handle (${forbidden})`);
74
+ }
75
+ });
76
+
77
+ test("empty issues is rejected with the uniform issues[{path,message}] contract — nothing staged", async () => {
78
+ const before = (await listStaged(h)).proposals.length;
79
+ const res = await h.callTool(TOOL, { body: { issues: [] } });
80
+ // A tool-level door 4xx: the object arrived (not stringified) AND carries issues[{path,message}].
81
+ assertValidationIssues(res, TOOL);
82
+ assert.equal((await listStaged(h)).proposals.length, before, "a rejected intent must stage nothing");
83
+ });
84
+
85
+ test("an unparseable issue ref is rejected at the offending path", async () => {
86
+ const res = await h.callTool(TOOL, { body: { issues: ["acme/repo#1", "not-an-issue"] } });
87
+ assertValidationIssues(res, TOOL);
88
+ const json = res.json as { issues?: Array<{ path?: string }> };
89
+ assert.ok(
90
+ json.issues?.some((i) => i.path === "issues[1]"),
91
+ `the offending index must be path-qualified (got ${JSON.stringify(json.issues)})`,
92
+ );
93
+ });
94
+ });
package/openapi.yaml CHANGED
@@ -2460,6 +2460,71 @@ components:
2460
2460
  description: >-
2461
2461
  A NAVIGATIONAL cockpit deep-link to the staged proposal (helps the agent hand the human a
2462
2462
  link). It is a pointer only — NOT a dispatch handle; nothing in this response can start a run.
2463
+ SequenceIssuesIntent:
2464
+ description: >-
2465
+ The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES
2466
+ the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead
2467
+ of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to
2468
+ sequence (each issue's implementation starts once the PRIOR issue has merged) and an OPTIONAL
2469
+ leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The
2470
+ door GENERATES, then STAGES the graph through the same compile+stage flow as
2471
+ `compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR
2472
+ 0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) →
2473
+ `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
2474
+ `poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an
2475
+ unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.
2476
+ type: object
2477
+ additionalProperties: false
2478
+ example:
2479
+ behind: nanobpm/nano-ide#488
2480
+ issues:
2481
+ - nanobpm/nano-workforce#567
2482
+ - nanobpm/nano-workforce#568
2483
+ required:
2484
+ - issues
2485
+ properties:
2486
+ behind:
2487
+ type: string
2488
+ minLength: 1
2489
+ maxLength: 255
2490
+ description: >-
2491
+ OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading
2492
+ `wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every
2493
+ opened slice/PR landed, §9.5) before the first issue's implementation starts.
2494
+ issues:
2495
+ type: array
2496
+ minItems: 1
2497
+ maxItems: 64
2498
+ items:
2499
+ type: string
2500
+ minLength: 1
2501
+ maxLength: 255
2502
+ description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
2503
+ description: >-
2504
+ The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens
2505
+ a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once
2506
+ the prior issue has merged. At least one; at most 64 (keeps the generated graph within the
2507
+ compiler's node ceiling).
2508
+ SequenceIssuesRejected:
2509
+ description: >-
2510
+ A rejected `sequenceIssues` intent — the input failed validation (empty/oversized `issues`, an
2511
+ unparseable `owner/repo#N` reference, or an unknown target/probe per the delivery-graph
2512
+ vocabulary). Every failure is path-qualified so the caller can fix the exact offending input.
2513
+ Nothing was generated or staged.
2514
+ type: object
2515
+ additionalProperties: false
2516
+ required:
2517
+ - error
2518
+ - issues
2519
+ properties:
2520
+ error:
2521
+ type: string
2522
+ description: A human-readable summary of why the intent was rejected.
2523
+ issues:
2524
+ type: array
2525
+ items:
2526
+ $ref: "#/components/schemas/DeliveryCompileError"
2527
+ description: The path-qualified validation failures (at least one).
2463
2528
  FeatureStart:
2464
2529
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
2465
2530
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
@@ -3492,6 +3557,83 @@ paths:
3492
3557
  application/json:
3493
3558
  schema:
3494
3559
  $ref: "#/components/schemas/ErrorBody"
3560
+ /actions/start/sequence-issues:
3561
+ post:
3562
+ operationId: sequenceIssues
3563
+ summary: Generate + STAGE the canonical "implement issue → converge → merge" delivery graph from a high-level intent (never dispatches). (epic #605 / S4)
3564
+ description: >-
3565
+ An INTENT-SHAPED door (epic nano-workforce#605, S4). ADR 0005's delivery graph is a closed
3566
+ vocabulary and operator-guide §9.4 already names the canonical shape for "implement issue →
3567
+ converge → merge", but an agent still had to hand-author the full node/edge JSON — sequencing
3568
+ four issues behind a gate meant constructing 13 nodes and 12 edges by hand. This door takes the
3569
+ high-level intent `{ behind?, issues[] }` and GENERATES that canonical graph, then STAGES it
3570
+ through the SAME compile+stage flow the raw `compileDeliveryGraph` door uses (one compiler, one
3571
+ staging path — idempotency + digest inherited, not re-implemented). It returns a preview and a
3572
+ navigational `reviewUrl` and NOTHING that can trigger a run: dispatch is an OPERATOR action in
3573
+ the cockpit (ADR 0005 Decision 7 / issue #460).
3574
+
3575
+
3576
+ For each issue it emits the canonical chain — `agent` (`senior:feature`, emits a typed `pr`
3577
+ fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic
3578
+ `poll.timeoutMs`) — and threads the `pr` fact along fact-qualified edges (§9.4). The issues run
3579
+ in SEQUENCE: each issue's implementation starts once the PRIOR issue has merged. When `behind`
3580
+ is given, a leading `wait[epic]` gate (§9.5) makes the whole sequence wait for that reference to
3581
+ be fully merged first.
3582
+
3583
+
3584
+ INPUT — the intent OBJECT `{ "issues": ["owner/repo#A", …] }` with an OPTIONAL
3585
+ `"behind": "owner/repo#NN"` gate (this is the object-body door). SIDE EFFECTS — impure: a valid intent is STAGED as a proposal.
3586
+ IDEMPOTENCY — content-addressed by the compiled `digest` (an identical intent re-stages the same
3587
+ digest). VALIDATION — invalid input (empty `issues`, an unparseable `owner/repo#N` reference, an
3588
+ unknown target/probe per the S3 vocabulary) is a `400` with `issues: [{ path, message }]`;
3589
+ nothing is staged. NEXT — surface the returned `reviewUrl` to the operator; poll
3590
+ `listStagedProposals` to see the staged digest.
3591
+ requestBody:
3592
+ required: true
3593
+ content:
3594
+ application/json:
3595
+ schema:
3596
+ # BEGIN generated:mcp-body source=#/components/schemas/SequenceIssuesIntent (scripts/inline-mcp-bodies.ts — do not hand-edit)
3597
+ description: 'The `sequenceIssues` INTENT (epic nano-workforce#605, S4) — a high-level shape that GENERATES the canonical "implement issue → converge → merge" delivery graph (operator-guide §9.4) instead of making an agent hand-author its node/edge JSON. It names an ordered list of `issues` to sequence (each issue''s implementation starts once the PRIOR issue has merged) and an OPTIONAL leading `behind` gate (wait for that issue/epic/feature to be fully merged first, §9.5). The door GENERATES, then STAGES the graph through the same compile+stage flow as `compileDeliveryGraph` — it never dispatches (dispatch is an operator-only cockpit action, ADR 0005 Decision 7). For each issue it emits `agent` (`senior:feature`, emits a `pr` fact) → `connector` (`converge-merge`, late-binding that `pr`) → `wait[pr, merged]` (a realistic `poll.timeoutMs`), threading the `pr` fact per §9.4. Invalid input (empty `issues`, an unparseable ref) is a 400 carrying `issues: [{ path, message }]`; nothing is staged.'
3598
+ type: object
3599
+ additionalProperties: false
3600
+ example:
3601
+ behind: nanobpm/nano-ide#488
3602
+ issues:
3603
+ - nanobpm/nano-workforce#567
3604
+ - nanobpm/nano-workforce#568
3605
+ required:
3606
+ - issues
3607
+ properties:
3608
+ behind:
3609
+ type: string
3610
+ minLength: 1
3611
+ maxLength: 255
3612
+ description: OPTIONAL gate — an `owner/repo#NN` issue/epic/feature reference. When present, a leading `wait[epic]` node gates the whole sequence on that reference reaching "fully merged" (every opened slice/PR landed, §9.5) before the first issue's implementation starts.
3613
+ issues:
3614
+ type: array
3615
+ minItems: 1
3616
+ maxItems: 64
3617
+ items:
3618
+ type: string
3619
+ minLength: 1
3620
+ maxLength: 255
3621
+ description: An `owner/repo#N` issue reference to implement + converge + merge, in sequence.
3622
+ description: The ordered issues to sequence — each is implemented by a `senior:feature` agent that opens a PR, driven to convergence + merge, and the NEXT issue's implementation starts only once the prior issue has merged. At least one; at most 64 (keeps the generated graph within the compiler's node ceiling).
3623
+ # END generated:mcp-body
3624
+ responses:
3625
+ "200":
3626
+ description: The intent generated a valid delivery graph — it compiled and is STAGED for operator review; the response carries a preview and a navigational reviewUrl (no dispatch handle).
3627
+ content:
3628
+ application/json:
3629
+ schema:
3630
+ $ref: "#/components/schemas/CompileDeliveryGraphStaged"
3631
+ "400":
3632
+ description: The intent was invalid (empty/oversized issues, an unparseable reference, or an unknown target/probe) — path-qualified issues, nothing generated or staged.
3633
+ content:
3634
+ application/json:
3635
+ schema:
3636
+ $ref: "#/components/schemas/SequenceIssuesRejected"
3495
3637
  /actions/compile-delivery-graph:
3496
3638
  post:
3497
3639
  operationId: compileDeliveryGraph
@@ -12,74 +12,36 @@
12
12
  // cockpit; the response tells the agent its role ends here, turning the boundary into a self-documenting
13
13
  // protocol. A malformed graph is a 400 carrying path-qualified errors; nothing is staged.
14
14
 
15
- import { compileDeliveryGraph } from "../app/deliveryGraphCompiler.ts";
16
- import {
17
- buildProposalPreview,
18
- buildProposalRow,
19
- proposalLogicalKey,
20
- proposalReviewUrl,
21
- stageProposal,
22
- } from "../app/deliveryGraphProposals.ts";
23
- import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
15
+ import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
24
16
  import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
25
17
  import { defineOperation } from "../nano-generated/operations.ts";
26
18
 
27
- const STAGED_MESSAGE =
28
- "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.";
29
-
30
19
  export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
31
- // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
32
- // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
33
- // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
34
- // `ok:false`, never a 500.
35
- const result = await compileDeliveryGraph(body);
36
- if (!result.ok) {
37
- app.log.warn("compile-delivery-graph rejected", { errors: result.errors.length });
38
- return { status: 400, body: result };
39
- }
40
-
41
- // Persist the compiled graph as a `staged` proposal — the agent's surface ends here. Superseded by
42
- // logical key + TTL inside `stageProposal`.
43
- const digest = deliveryGraphDigest(result.bpmn);
44
- const name =
45
- typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
46
- ? result.resolved.name.trim()
47
- : null;
48
- const preview = buildProposalPreview(result);
49
- await stageProposal(
20
+ // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the shared
21
+ // compile+stage flow adds the SEMANTIC checks (acyclicity, edge integrity, fact resolution) and,
22
+ // when valid, persists the compiled graph as a `staged` proposal. A directly-invoked delegate could
23
+ // still pass `undefined` the compiler reads its input as `unknown` and maps that to a clean
24
+ // `ok:false`, never a 500. The navigational `reviewUrl` is keyed to the ORIGIN this request arrived
25
+ // on (tunnel, proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the
26
+ // operator driving this instance can actually open it (#577).
27
+ const staged = await compileAndStageDeliveryGraph(
50
28
  app.data,
51
- buildProposalRow({
52
- digest,
53
- logicalKey: proposalLogicalKey(name, digest),
54
- title: name,
55
- graphJson: JSON.stringify(body),
56
- preview,
57
- nodeCount: result.resolved.nodes.length,
58
- humanNodeCount: result.humanNodes.length,
59
- sideEffectCount: result.sideEffects.length,
60
- sideEffecting: result.sideEffects.length > 0,
61
- }),
29
+ body,
30
+ JSON.stringify(body),
31
+ resolvePublicOrigin(req),
62
32
  );
33
+ if (!staged.ok) {
34
+ app.log.warn("compile-delivery-graph rejected", { errors: staged.body.errors.length });
35
+ return { status: 400, body: staged.body };
36
+ }
63
37
 
64
38
  app.log.info("compile-delivery-graph staged", {
65
- digest,
66
- nodes: result.resolved.nodes.length,
67
- humanNodes: result.humanNodes.length,
68
- sideEffects: result.sideEffects.length,
39
+ digest: staged.digest,
40
+ nodes: staged.nodeCount,
41
+ humanNodes: staged.humanNodeCount,
42
+ sideEffects: staged.sideEffectCount,
69
43
  });
70
44
 
71
45
  // The response carries a preview + a navigational pointer and NO dispatch handle (issue #460).
72
- return {
73
- status: 200,
74
- body: {
75
- status: "ready",
76
- message: STAGED_MESSAGE,
77
- digest,
78
- preview,
79
- // Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
80
- // proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
81
- // driving this instance can actually open it (#577).
82
- reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
83
- },
84
- };
46
+ return { status: staged.status, body: staged.body };
85
47
  });
@@ -0,0 +1,101 @@
1
+ // Tests for the POST /app/api/actions/start/sequence-issues operation `sequenceIssues` (epic
2
+ // nano-workforce#605, S4/#610). The intent-shaped door GENERATES the canonical delivery graph and
3
+ // STAGES it through the SAME compile+stage flow as `compileDeliveryGraph` — the response carries only
4
+ // a preview + a navigational `reviewUrl` (no dispatch handle); dispatch stays an operator action.
5
+ // Invalid input is a 400 with `issues[{path,message}]` and nothing is staged.
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
11
+ import type { AppApi, DataLayer } from "@nanobpm/urban";
12
+ import { bootTestApp } from "@nanobpm/urban-testkit";
13
+ import { deliveryGraphProposals, listStagedProposals } from "../app/deliveryGraphProposals.ts";
14
+ import { noopLog } from "../test/log.ts";
15
+ import handler from "./sequenceIssues.ts";
16
+
17
+ const APP_ROOT = resolve(import.meta.dirname, "..");
18
+
19
+ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Promise<void> {
20
+ const dir = mkdtempSync(join(tmpdir(), "nwf-seqissues-"));
21
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
22
+ try {
23
+ const edge = { data: app.db, log: noopLog() } as unknown as AppApi;
24
+ await fn(edge, app.db);
25
+ } finally {
26
+ await app.stop?.();
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
32
+ const req = { path: "/app/api/actions/start/sequence-issues", headers: new Headers(headers) };
33
+ return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
34
+ }
35
+
36
+ test("sequence-issues: a valid intent → 200 ready, staged as a proposal, with the preview", async () => {
37
+ await withApp(async (app, data) => {
38
+ const res = await call(app, { behind: "acme/repo#100", issues: ["acme/repo#1", "acme/repo#2"] });
39
+ assertEquals(res.status, 200);
40
+ assertEquals(res.body.status, "ready");
41
+ assert(typeof res.body.digest === "string" && res.body.digest.length > 0);
42
+ assert(typeof res.body.preview === "object" && res.body.preview !== null);
43
+ assert(typeof res.body.preview.diagram === "string" && res.body.preview.diagram.length > 0);
44
+ // Staged through the SAME path as compileDeliveryGraph — visible immediately (read-after-write).
45
+ const row = await deliveryGraphProposals(data).get(res.body.digest);
46
+ assert(row, "the generated graph is staged for operator dispatch");
47
+ assertEquals(row?.status, "staged");
48
+ const live = await listStagedProposals(data);
49
+ assert(live.some((p) => p.digest === res.body.digest), "the staged digest is listed");
50
+ });
51
+ });
52
+
53
+ test("sequence-issues: the response exposes NO dispatch handle — the door stages, never dispatches", async () => {
54
+ await withApp(async (app) => {
55
+ const res = await call(app, { issues: ["acme/repo#1"] });
56
+ assertEquals(res.status, 200);
57
+ const keys = Object.keys(res.body);
58
+ for (const forbidden of ["runKey", "token", "approvalToken", "processInstanceKey", "processKey", "dispatchUrl"]) {
59
+ assert(!keys.includes(forbidden), `response must not carry a dispatch handle (${forbidden})`);
60
+ }
61
+ });
62
+ });
63
+
64
+ test("sequence-issues: an identical intent re-stages the SAME digest (idempotent), not a duplicate", async () => {
65
+ await withApp(async (app, data) => {
66
+ const a = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
67
+ const b = await call(app, { issues: ["acme/repo#1", "acme/repo#2"] });
68
+ assertEquals(a.body.digest, b.body.digest);
69
+ const live = await listStagedProposals(data);
70
+ assertEquals(live.filter((p) => p.digest === a.body.digest).length, 1);
71
+ });
72
+ });
73
+
74
+ test("sequence-issues: empty issues → 400 with issues[{path,message}], nothing staged", async () => {
75
+ await withApp(async (app, data) => {
76
+ const res = await call(app, { issues: [] });
77
+ assertEquals(res.status, 400);
78
+ assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
79
+ for (const iss of res.body.issues) {
80
+ assert(typeof iss.path === "string" && typeof iss.message === "string");
81
+ }
82
+ assertEquals((await listStagedProposals(data)).length, 0);
83
+ });
84
+ });
85
+
86
+ test("sequence-issues: an unparseable issue ref → 400 at the offending path, nothing staged", async () => {
87
+ await withApp(async (app, data) => {
88
+ const res = await call(app, { issues: ["acme/repo#1", "garbage"] });
89
+ assertEquals(res.status, 400);
90
+ assert(res.body.issues.some((i: any) => i.path === "issues[1]"));
91
+ assertEquals((await listStagedProposals(data)).length, 0);
92
+ });
93
+ });
94
+
95
+ test("sequence-issues: a missing body folds into the same 400 contract (not a 500)", async () => {
96
+ await withApp(async (app) => {
97
+ const res = await call(app, undefined);
98
+ assertEquals(res.status, 400);
99
+ assert(Array.isArray(res.body.issues) && res.body.issues.length > 0);
100
+ });
101
+ });
@@ -0,0 +1,55 @@
1
+ // POST /app/api/actions/start/sequence-issues → operationId `sequenceIssues` (epic
2
+ // nano-workforce#605, S4/#610). An INTENT-SHAPED door: instead of making an agent hand-author the
3
+ // full canonical node/edge JSON for "implement issue → converge → merge" (§9.4 — 13 nodes + 12 edges
4
+ // for four gated issues, in the evidence session), it takes the high-level intent
5
+ // `{ behind?, issues[] }` and GENERATES that canonical delivery graph, then hands it to the SAME
6
+ // compile+stage flow the raw `compileDeliveryGraph` door uses. It STAGES for operator review and
7
+ // returns a navigational `reviewUrl` and NOTHING that can trigger a run — dispatch stays an
8
+ // operator-only cockpit action (ADR 0005 Decision 7 / issue #460). No new runner, no second staging
9
+ // path: the generator only produces the `DeliveryGraph` (`buildSequenceGraph`) and delegates.
10
+ //
11
+ // Invalid input (empty `issues`, an unparseable `owner/repo#N` ref, an unknown target/probe per the
12
+ // S3 vocabulary) is a 400 carrying the uniform `issues[{path,message}]` contract; nothing is staged.
13
+ import { compileAndStageDeliveryGraph } from "../app/deliveryGraphStage.ts";
14
+ import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
15
+ import { buildSequenceGraph } from "../app/sequenceIssues.ts";
16
+ import { defineOperation } from "../nano-generated/operations.ts";
17
+
18
+ export default defineOperation("sequenceIssues", async ({ body, req }, app) => {
19
+ // Build the canonical graph from the intent. Input validation (shape, ref format, vocabulary drift)
20
+ // lives in the pure `buildSequenceGraph` and returns the uniform `issues[{path,message}]` contract —
21
+ // a directly-invoked delegate passing `undefined` folds into the same clean rejection.
22
+ const built = buildSequenceGraph(body);
23
+ if (!built.ok) {
24
+ app.log.warn("sequence-issues rejected", { issues: built.issues.length });
25
+ return { status: 400, body: { error: "invalid sequenceIssues intent", issues: built.issues } };
26
+ }
27
+
28
+ // Hand the CONSTRUCTED graph to the shared compile+stage flow — one compiler, one staging path,
29
+ // idempotency/digest inherited (AGENTS.md "no drift surfaces"). `reviewUrl` keys to the origin this
30
+ // request arrived on (tunnel/proxy prefix), not the static deployment-wide base (#577).
31
+ const staged = await compileAndStageDeliveryGraph(
32
+ app.data,
33
+ built.graph,
34
+ JSON.stringify(built.graph),
35
+ resolvePublicOrigin(req),
36
+ );
37
+ if (!staged.ok) {
38
+ // A generated graph is well-formed by construction; a compile failure here is a generator defect,
39
+ // surfaced through the SAME `issues[{path,message}]` contract (mapped from the compiler's
40
+ // path-qualified `errors`) rather than a 500.
41
+ app.log.error("sequence-issues compile failed on a generated graph", { errors: staged.body.errors.length });
42
+ return {
43
+ status: 400,
44
+ body: { error: "generated delivery graph failed to compile", issues: staged.body.errors },
45
+ };
46
+ }
47
+
48
+ app.log.info("sequence-issues staged", {
49
+ digest: staged.digest,
50
+ nodes: staged.nodeCount,
51
+ humanNodes: staged.humanNodeCount,
52
+ sideEffects: staged.sideEffectCount,
53
+ });
54
+ return { status: staged.status, body: staged.body };
55
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.161.0",
3
+ "version": "0.162.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",