@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 +12 -0
- package/app/agentGuide.test.ts +115 -0
- package/app/agentGuide.ts +109 -0
- package/app/deliveryGraphStage.ts +108 -0
- package/app/sequenceIssues.test.ts +186 -0
- package/app/sequenceIssues.ts +236 -0
- package/docs/agent-guide.md +11 -0
- package/docs/mcp-runbook.md +16 -0
- package/e2e/addressable-guide.e2e.ts +89 -0
- package/e2e/sequenceIssues.e2e.ts +94 -0
- package/openapi.yaml +296 -0
- package/operations/compileDeliveryGraph.ts +21 -59
- package/operations/getAgentGuide.test.ts +100 -0
- package/operations/getAgentGuide.ts +92 -0
- package/operations/sequenceIssues.test.ts +101 -0
- package/operations/sequenceIssues.ts +55 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -150,6 +150,22 @@ Agents without MCP are unchanged — resolve the instance, then
|
|
|
150
150
|
live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
|
|
151
151
|
before.
|
|
152
152
|
|
|
153
|
+
### Addressable guide (MCP) — `getAgentGuide`
|
|
154
|
+
|
|
155
|
+
The full guide is ~43KB — a single `getAgentInstructions` call can overrun a tool-result
|
|
156
|
+
limit. Over MCP, prefer the **addressable** companion tool `getAgentGuide(section?)`
|
|
157
|
+
(`GET /app/api/agent/guide`):
|
|
158
|
+
|
|
159
|
+
- **No argument** → a compact **table of contents**: every stable section id
|
|
160
|
+
(`orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`, `debug`,
|
|
161
|
+
`debug-models`, `unstick`, `raise-issue`, `delivery-graphs`) with a one-line summary.
|
|
162
|
+
- **`section=<id>`** → **only** that section's markdown, small enough to fit a typical
|
|
163
|
+
limit. An unknown id is rejected with `issues[{path,message}]` listing the valid ids.
|
|
164
|
+
|
|
165
|
+
The section ids are the single source of truth in `app/agentGuide.ts` (`GUIDE_SECTIONS`),
|
|
166
|
+
derived-and-checked against the authored `docs/agent-guide.md`. The `getAgentInstructions`
|
|
167
|
+
/ `GET /agent` full-guide door is unchanged — the addressable tool is additive.
|
|
168
|
+
|
|
153
169
|
## 6. Regression harness — pin the MCP surface from nwf's side
|
|
154
170
|
|
|
155
171
|
The MCP projection layer (schema shape, argument encoding, session handshake) is
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Addressable operator-guide regression net (epic #605 slice S5, issue #611).
|
|
2
|
+
//
|
|
3
|
+
// Drives the app's REAL runtime-served MCP endpoint (`/app/mcp`, ADR 0067) via the reusable
|
|
4
|
+
// `e2e/support/mcp-harness.ts` module (slice S1, #607) — it does NOT re-implement the handshake.
|
|
5
|
+
// It PINS the addressable-guide contract from a client's point of view:
|
|
6
|
+
//
|
|
7
|
+
// • `getAgentGuide` is projected with a `$ref`-free, explicitly-typed input schema (S0 invariant);
|
|
8
|
+
// • no argument → a compact table of contents listing every stable section id + summary, small
|
|
9
|
+
// enough to fit a typical tool-result limit;
|
|
10
|
+
// • `section=<id>` → ONLY that section, far smaller than the whole guide (the defect this fixes:
|
|
11
|
+
// `getAgentInstructions` returns ~43KB that overran the limit);
|
|
12
|
+
// • an unknown id → a uniform `issues[{path,message}]` validation error;
|
|
13
|
+
// • the full-guide fallback door (`getAgentInstructions`) is UNCHANGED — still the whole guide.
|
|
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 { assertSchemaSelfContained, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
|
|
19
|
+
|
|
20
|
+
describe("S5 — the addressable operator guide over MCP (#611)", () => {
|
|
21
|
+
let h: McpHarness;
|
|
22
|
+
before(async () => {
|
|
23
|
+
h = await bootMcpHarness();
|
|
24
|
+
});
|
|
25
|
+
after(async () => {
|
|
26
|
+
await h.stop();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("getAgentGuide is projected with a self-contained, explicitly-typed input schema", async () => {
|
|
30
|
+
const tools = await h.listTools();
|
|
31
|
+
const tool = tools.find((t) => t.name === "getAgentGuide");
|
|
32
|
+
assert(tool, "getAgentGuide must be projected onto the MCP surface");
|
|
33
|
+
assertSchemaSelfContained(tool!.inputSchema, "getAgentGuide");
|
|
34
|
+
const props = (tool!.inputSchema as { properties?: Record<string, unknown> }).properties ?? {};
|
|
35
|
+
const section = props.section as { type?: string } | undefined;
|
|
36
|
+
assert(section, "getAgentGuide must expose a `section` argument");
|
|
37
|
+
assert.equal(section!.type, "string", "`section` must be an explicitly-typed string");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("no argument returns a compact table of contents with every section id", async () => {
|
|
41
|
+
const res = await h.callTool("getAgentGuide", {});
|
|
42
|
+
assert(!res.isError, `getAgentGuide (TOC) must not error: ${res.text}`);
|
|
43
|
+
const body = res.json as { kind?: string; sections?: { id: string; title: string; summary: string }[] };
|
|
44
|
+
assert.equal(body.kind, "toc");
|
|
45
|
+
assert(Array.isArray(body.sections) && body.sections.length > 0, "the TOC must list sections");
|
|
46
|
+
const ids = body.sections!.map((s) => s.id);
|
|
47
|
+
for (const id of ["orient", "submit-pr", "submit-epic", "escalations", "delivery-graphs"]) {
|
|
48
|
+
assert(ids.includes(id), `the TOC must list "${id}"`);
|
|
49
|
+
}
|
|
50
|
+
for (const s of body.sections!) {
|
|
51
|
+
assert(s.title.length > 0 && s.summary.length > 0, `TOC entry "${s.id}" needs a title + summary`);
|
|
52
|
+
}
|
|
53
|
+
// The whole point: the TOC is tiny relative to the ~43KB monolith.
|
|
54
|
+
assert(res.text.length < 4000, "the TOC must fit comfortably under a tool-result limit");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("section=delivery-graphs returns ONLY that section, under a typical result budget", async () => {
|
|
58
|
+
const res = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
|
|
59
|
+
assert(!res.isError, `getAgentGuide(delivery-graphs) must not error: ${res.text}`);
|
|
60
|
+
const body = res.json as { kind?: string; section?: { id: string; instructions: string } };
|
|
61
|
+
assert.equal(body.kind, "section");
|
|
62
|
+
assert.equal(body.section?.id, "delivery-graphs");
|
|
63
|
+
assert(body.section!.instructions.length > 200, "the section must carry real content");
|
|
64
|
+
assert(!body.section!.instructions.includes("__BASE__"), "placeholders must be substituted");
|
|
65
|
+
|
|
66
|
+
// It must be smaller than the full guide the fallback door still serves — a proper subset.
|
|
67
|
+
const full = await h.callTool("getAgentInstructions", {});
|
|
68
|
+
assert(res.text.length < full.text.length, "one section must be smaller than the whole guide");
|
|
69
|
+
assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an unknown section id is rejected with issues[{path,message}]", async () => {
|
|
73
|
+
const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
|
|
74
|
+
assert(res.isError, "an unknown section id must surface as a tool-level error");
|
|
75
|
+
const body = res.json as { issues?: { path: string; message: string }[] };
|
|
76
|
+
assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
|
|
77
|
+
assert.equal(body.issues![0].path, "section");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("the full-guide fallback door is unchanged — still the whole guide", async () => {
|
|
81
|
+
const res = await h.callTool("getAgentInstructions", {});
|
|
82
|
+
assert(!res.isError, `getAgentInstructions must still answer: ${res.text}`);
|
|
83
|
+
const body = res.json as { instructions?: string };
|
|
84
|
+
assert(typeof body.instructions === "string", "getAgentInstructions still returns the full guide");
|
|
85
|
+
// The monolith still contains a section the addressable tool now carves out — no content regression.
|
|
86
|
+
assert(body.instructions!.includes("delivery graph"), "the full guide still contains every section");
|
|
87
|
+
assert(body.instructions!.length > 10000, "the full guide door is unshrunk");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -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
|
+
});
|