@nanobpm/nano-workforce 0.159.1 → 0.161.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/deliveryGraphVocabulary.test.ts +102 -0
- package/app/deliveryGraphVocabulary.ts +319 -0
- package/app/readiness.ts +10 -5
- package/docs/agent-guide.md +15 -0
- package/docs/mcp-runbook.md +16 -0
- package/e2e/addressable-guide.e2e.ts +89 -0
- package/openapi.yaml +410 -0
- package/operations/getAgentGuide.test.ts +100 -0
- package/operations/getAgentGuide.ts +92 -0
- package/operations/getDeliveryGraphVocabulary.ts +25 -0
- package/package.json +1 -1
- package/test/deliveryGraphVocabulary-mcp.test.ts +62 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// app/deliveryGraphVocabulary.ts — the delivery-graph vocabulary + wait-probe semantics as
|
|
2
|
+
// STRUCTURED DATA (epic nano-workforce#605, S3/#609). Served by GET /app/api/delivery-graph/vocabulary
|
|
3
|
+
// (operationId `getDeliveryGraphVocabulary`, a read tool projected onto the MCP surface like
|
|
4
|
+
// `getAgentInstructions`), so an agent can DISCOVER the closed node/probe/connector vocabulary and the
|
|
5
|
+
// non-obvious wait semantics from the surface instead of reading source (the evidence session in #605:
|
|
6
|
+
// an agent had to grep `app/readiness.ts` to learn `wait[epic]` also gates a feature run).
|
|
7
|
+
//
|
|
8
|
+
// Derivation over duplication (AGENTS.md — "no drift surfaces"). Everything that has a closed,
|
|
9
|
+
// compiler-enforced source of truth is DERIVED from it, never re-typed:
|
|
10
|
+
// • node kinds ← `DELIVERY_NODE_KINDS` (app/deliveryGraph.ts — the trust boundary)
|
|
11
|
+
// • fact types ← `DELIVERY_FACT_TYPES` (app/deliveryGraph.ts)
|
|
12
|
+
// • guardable scalars ← `DELIVERY_GUARD_SCALAR_TYPES`
|
|
13
|
+
// • wait probe kinds ← `PROBE_KINDS` (app/readiness.ts — what `parseProbe` accepts)
|
|
14
|
+
// • pr conditions ← `PR_CONDITIONS` (app/readiness.ts)
|
|
15
|
+
// • epic conditions ← `EPIC_CONDITIONS` (app/readiness.ts)
|
|
16
|
+
// • onTimeout options ← `ON_TIMEOUTS` (app/readiness.ts)
|
|
17
|
+
// • poll defaults ← `DEFAULT_TIMEOUT_MS`/`DEFAULT_EVERY_MS`/`DEFAULT_READINESS_TIMEOUT`
|
|
18
|
+
// • real connector targets ← `converge`/`converge-merge`/`merge-main` (app/convergeTargets.ts)
|
|
19
|
+
// The prose (body contracts, what each probe OBSERVES, the poll-budget trap, fact-threading) is
|
|
20
|
+
// co-located here; `app/deliveryGraphVocabulary.test.ts` is the drift guard — it fails the build if a
|
|
21
|
+
// probe kind / connector target / node kind is added to the compiler without a vocabulary entry.
|
|
22
|
+
import {
|
|
23
|
+
CONVERGE_MERGE_TARGET,
|
|
24
|
+
CONVERGE_TARGET,
|
|
25
|
+
convergeOnlyForTarget,
|
|
26
|
+
MERGE_MAIN_TARGET,
|
|
27
|
+
} from "./convergeTargets.ts";
|
|
28
|
+
import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_EVERY_MS,
|
|
31
|
+
DEFAULT_READINESS_TIMEOUT,
|
|
32
|
+
DEFAULT_TIMEOUT_MS,
|
|
33
|
+
EPIC_CONDITIONS,
|
|
34
|
+
ON_TIMEOUTS,
|
|
35
|
+
PR_CONDITIONS,
|
|
36
|
+
PROBE_KINDS,
|
|
37
|
+
} from "./readiness.ts";
|
|
38
|
+
|
|
39
|
+
/** A node-kind entry: the closed `kind`, its per-kind config key + required/optional body fields, and
|
|
40
|
+
* whether it is side-effecting / may emit facts. `body` names the executable engine-native surface the
|
|
41
|
+
* graph layer schedules onto (the graph layer does NOT re-implement execution). */
|
|
42
|
+
export interface NodeKindEntry {
|
|
43
|
+
kind: string;
|
|
44
|
+
configKey: string;
|
|
45
|
+
requiredFields: string[];
|
|
46
|
+
optionalFields: string[];
|
|
47
|
+
sideEffecting: boolean;
|
|
48
|
+
mayEmit: boolean;
|
|
49
|
+
summary: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A wait-probe entry: the closed `kind`, the `match` fields it reads, and — crucially — WHAT it
|
|
53
|
+
* OBSERVES (the read that decides readiness) and WHEN it is ready. */
|
|
54
|
+
export interface WaitProbeEntry {
|
|
55
|
+
kind: string;
|
|
56
|
+
target: string;
|
|
57
|
+
matchFields: string[];
|
|
58
|
+
conditions?: string[];
|
|
59
|
+
observes: string;
|
|
60
|
+
ready: string;
|
|
61
|
+
binds?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A connector target: whether it is a REAL side-effecting target (a converge-enrollment target that
|
|
65
|
+
* dispatches through `submitPr`) or a FORWARD-DECLARED stub (the connector I/O surface is an ADR 0005
|
|
66
|
+
* non-goal — an unrecognised target returns a deterministic acknowledgement and performs no I/O). */
|
|
67
|
+
export interface ConnectorTargetEntry {
|
|
68
|
+
target: string;
|
|
69
|
+
status: "real" | "forward-declared";
|
|
70
|
+
convergeOnlyDefault?: boolean;
|
|
71
|
+
summary: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An `onTimeout` option: what the bounded wait does when the engine timer arm fires. */
|
|
75
|
+
export interface OnTimeoutEntry {
|
|
76
|
+
value: string;
|
|
77
|
+
meaning: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The whole structured vocabulary the read tool returns. */
|
|
81
|
+
export interface DeliveryGraphVocabulary {
|
|
82
|
+
adr: string;
|
|
83
|
+
summary: string;
|
|
84
|
+
nodeKinds: NodeKindEntry[];
|
|
85
|
+
factTypes: string[];
|
|
86
|
+
guardScalarTypes: string[];
|
|
87
|
+
waitProbeKinds: WaitProbeEntry[];
|
|
88
|
+
connectorTargets: ConnectorTargetEntry[];
|
|
89
|
+
onTimeout: OnTimeoutEntry[];
|
|
90
|
+
pollBudget: {
|
|
91
|
+
defaultTimeoutMs: number;
|
|
92
|
+
defaultTimeoutIso: string;
|
|
93
|
+
defaultEveryMs: number;
|
|
94
|
+
rule: string;
|
|
95
|
+
};
|
|
96
|
+
factThreading: {
|
|
97
|
+
rule: string;
|
|
98
|
+
details: string[];
|
|
99
|
+
};
|
|
100
|
+
guideSection: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Node kinds (DERIVED from DELIVERY_NODE_KINDS — the closed allowlist / trust boundary) ─────────
|
|
104
|
+
const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
|
|
105
|
+
agent: {
|
|
106
|
+
configKey: "agent",
|
|
107
|
+
requiredFields: ["jobType"],
|
|
108
|
+
optionalFields: ["prompt", "converge", "merge"],
|
|
109
|
+
sideEffecting: true,
|
|
110
|
+
mayEmit: true,
|
|
111
|
+
summary:
|
|
112
|
+
"A worker runs an agent job type (the fan-out body, e.g. `senior:feature`). First-class " +
|
|
113
|
+
"`converge?`/`merge?` cell-policy flags declare review-convergence / landing intent (`merge` " +
|
|
114
|
+
"requires `converge`); a raw `senior:converge`/`senior:merge` jobType is rejected (`raw-converge-node`). " +
|
|
115
|
+
"An `agent` that opens a PR emits it as a `pr`-typed fact so downstream connector/wait nodes late-bind it.",
|
|
116
|
+
},
|
|
117
|
+
wait: {
|
|
118
|
+
configKey: "wait",
|
|
119
|
+
requiredFields: ["kind", "target"],
|
|
120
|
+
optionalFields: ["match", "poll", "onTimeout", "credentialEnv"],
|
|
121
|
+
sideEffecting: false,
|
|
122
|
+
mayEmit: true,
|
|
123
|
+
summary:
|
|
124
|
+
"A durable, bounded, read-only readiness probe (a `ReadinessProbe` verbatim). `wait.kind` selects " +
|
|
125
|
+
"the probe (see waitProbeKinds); `poll` is `{ everyMs?, timeoutMs?, backoff? }`. Binds observed " +
|
|
126
|
+
"facts (e.g. a merged `pr` binds `mergedSha`).",
|
|
127
|
+
},
|
|
128
|
+
human: {
|
|
129
|
+
configKey: "human",
|
|
130
|
+
requiredFields: [],
|
|
131
|
+
optionalFields: ["formKey", "prompt"],
|
|
132
|
+
sideEffecting: false,
|
|
133
|
+
mayEmit: true,
|
|
134
|
+
summary:
|
|
135
|
+
"A scheduled user task + form (the Tasks inbox). Blocks dependents, SLA-bounded, answerable by a " +
|
|
136
|
+
"human OR an agent. Config is optional (no required field).",
|
|
137
|
+
},
|
|
138
|
+
connector: {
|
|
139
|
+
configKey: "connector",
|
|
140
|
+
requiredFields: ["target"],
|
|
141
|
+
optionalFields: ["dedupeKey", "payload"],
|
|
142
|
+
sideEffecting: true,
|
|
143
|
+
mayEmit: true,
|
|
144
|
+
summary:
|
|
145
|
+
"An automated, side-effecting outbound action. `payload` for a converge target is " +
|
|
146
|
+
"`{ pr, convergeOnly?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
|
|
147
|
+
"reference, or omitted to auto-bind the single incoming `pr` fact). Carries a `dedupeKey` " +
|
|
148
|
+
"(at-least-once safe). See connectorTargets for which targets are real vs. forward-declared.",
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ── Wait probe kinds (DERIVED from PROBE_KINDS — what `parseProbe` accepts) ───────────────────────
|
|
153
|
+
const WAIT_PROBE_DETAIL: Record<string, Omit<WaitProbeEntry, "kind">> = {
|
|
154
|
+
http: {
|
|
155
|
+
target: "a URL",
|
|
156
|
+
matchFields: ["status", "bodyIncludes"],
|
|
157
|
+
observes: "an HTTP GET against `target` (optional `credentialEnv` supplies an Authorization credential by env-key name).",
|
|
158
|
+
ready: "the response status matches `match.status` (default: any 2xx) and the body contains `match.bodyIncludes` if set.",
|
|
159
|
+
},
|
|
160
|
+
command: {
|
|
161
|
+
target: "a shell command",
|
|
162
|
+
matchFields: ["exitCode", "stdoutIncludes"],
|
|
163
|
+
observes: "running `target` as a subprocess (the escape hatch for the long tail — `gh`, `curl`, `docker manifest inspect`).",
|
|
164
|
+
ready: "the exit code matches `match.exitCode` (default 0) and stdout contains `match.stdoutIncludes` if set.",
|
|
165
|
+
},
|
|
166
|
+
npm: {
|
|
167
|
+
target: "a `pkg@version` (or bare `pkg`)",
|
|
168
|
+
matchFields: ["version", "stdoutIncludes"],
|
|
169
|
+
observes: "the npm registry for a published version of the package.",
|
|
170
|
+
ready: "`match.version` (default: the version in `pkg@version`) is published.",
|
|
171
|
+
},
|
|
172
|
+
"github-check": {
|
|
173
|
+
target: "an `owner/repo@ref`",
|
|
174
|
+
matchFields: ["conclusion", "checkName"],
|
|
175
|
+
observes: "the GitHub check runs on `ref`.",
|
|
176
|
+
ready: "the check run's conclusion matches `match.conclusion` (default `success`), restricted to `match.checkName` if set.",
|
|
177
|
+
},
|
|
178
|
+
capability: {
|
|
179
|
+
target: "a package/context handle",
|
|
180
|
+
matchFields: ["capabilityRef", "package", "verifyCommand"],
|
|
181
|
+
observes:
|
|
182
|
+
"the publish-provenance substrate: which published `package` version first carries the `capabilityRef` " +
|
|
183
|
+
"issue/PR — an optional `verifyCommand` runs once at the poll-budget boundary as a gated empirical fallback.",
|
|
184
|
+
ready: "a published version of `match.package` carries `match.capabilityRef` in its provenance.",
|
|
185
|
+
binds: ["resolvedArtifact"],
|
|
186
|
+
},
|
|
187
|
+
pr: {
|
|
188
|
+
target: "an `owner/repo#N` PR (or a `<node>.pr` fact reference the compiler late-binds at dispatch)",
|
|
189
|
+
matchFields: ["prState"],
|
|
190
|
+
conditions: [...PR_CONDITIONS],
|
|
191
|
+
observes:
|
|
192
|
+
"the live GitHub state of a single in-flight PR. The ACTION (landing it) stays in a connector/merge " +
|
|
193
|
+
"node body; this kind only OBSERVES, so it is level-triggered (no missed edge).",
|
|
194
|
+
ready: "the PR reaches `match.prState` (default `merged`; one of the pr conditions).",
|
|
195
|
+
binds: ["mergedSha"],
|
|
196
|
+
},
|
|
197
|
+
epic: {
|
|
198
|
+
target:
|
|
199
|
+
"the epic's durable `planKey` — `owner/repo#NN`, the epic ISSUE, not the engine processInstanceKey, " +
|
|
200
|
+
"so a resubmit/replay still resolves (may also be a `<node>.fact` late-binding reference)",
|
|
201
|
+
matchFields: ["epicState"],
|
|
202
|
+
conditions: [...EPIC_CONDITIONS],
|
|
203
|
+
observes:
|
|
204
|
+
"the app's OWN lineage read-model (`/lineage?root=<planKey>`), resolved by `parseEpicLineage` to the " +
|
|
205
|
+
"thread whose `rootRequestKey` matches the planKey — REGARDLESS OF the thread's `kind` (feature | epic | " +
|
|
206
|
+
"pr | delivery). Because `app/lineage.ts` lands a FEATURE thread on `stage:\"merged\"` once its PR merges, " +
|
|
207
|
+
"`wait[epic]` gates a single-PR FEATURE RUN just as well as a plan-fanout epic: point `target` at the " +
|
|
208
|
+
"feature/epic root issue and it observes that thread's aggregate frontier. A failed/abandoned/mixed epic " +
|
|
209
|
+
"settles on another terminal (`abandoned`/`resolved`/`converged`) and never reports merged, so it never " +
|
|
210
|
+
"falsely releases the gate — the bounded wait routes via `onTimeout` instead of hanging.",
|
|
211
|
+
ready: "the lineage thread reaches `stage:\"merged\" && active:false` (every opened slice/PR landed). `match.epicState` (default `merged`; `done` is a synonym) both mean \"fully merged\".",
|
|
212
|
+
binds: ["prCount"],
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── Connector targets (real = the converge-enrollment set from convergeTargets.ts) ────────────────
|
|
217
|
+
const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
|
|
218
|
+
{
|
|
219
|
+
target: CONVERGE_TARGET,
|
|
220
|
+
status: "real",
|
|
221
|
+
convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_TARGET),
|
|
222
|
+
summary: "Converge-only: drive review convergence and STOP at `converged`, never handing off to the merge loop.",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
target: CONVERGE_MERGE_TARGET,
|
|
226
|
+
status: "real",
|
|
227
|
+
convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
|
|
228
|
+
summary:
|
|
229
|
+
"Unit-level land: drive review convergence AND the merge loop, landing the PR onto its OWN base branch " +
|
|
230
|
+
"(for a unit inside an epic that base is the epic integration branch, never `main` directly).",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
target: MERGE_MAIN_TARGET,
|
|
234
|
+
status: "real",
|
|
235
|
+
convergeOnlyDefault: convergeOnlyForTarget(MERGE_MAIN_TARGET),
|
|
236
|
+
summary:
|
|
237
|
+
"Graph-level top-level land (two-level merge, ADR 0006 §3): land the graph/epic INTEGRATION PR onto `main`. " +
|
|
238
|
+
"Dispatch-identical to `converge-merge`; the distinction is the LEVEL, kept a first-class literal.",
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
/** The sentinel that describes ANY non-converge target: the connector I/O surface is forward-declared
|
|
243
|
+
* (ADR 0005 non-goal), so an unrecognised `target` hits the default stub action and performs no real
|
|
244
|
+
* side effect. Included so a caller learns the real-vs-stub split without reading `deliveryConnector.ts`. */
|
|
245
|
+
const FORWARD_DECLARED_ENTRY: ConnectorTargetEntry = {
|
|
246
|
+
target: "<any other target>",
|
|
247
|
+
status: "forward-declared",
|
|
248
|
+
summary:
|
|
249
|
+
"Forward-declared stub: the concrete connector I/O scheme is an ADR 0005 non-goal. A target outside the " +
|
|
250
|
+
"converge-enrollment set returns a deterministic acknowledgement (`connector stub — I/O surface " +
|
|
251
|
+
"forward-declared`) and fires NO real side effect until a real action is injected.",
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const ON_TIMEOUT_DETAIL: Record<string, string> = {
|
|
255
|
+
escalate: "park a human-in-the-loop escalation (the Tasks inbox) when the bounded wait elapses; a human/agent decides whether to extend the budget or abandon.",
|
|
256
|
+
fail: "terminate the gate as failed. NOTE: not yet supported on a `wait` node (blocked on engine terminate-end wiring); the compiler rejects `onTimeout: fail` on a wait.",
|
|
257
|
+
continue: "proceed as if ready when the wait elapses — use ONLY when downstream can tolerate a not-yet-ready upstream (a soft gate).",
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Build the structured delivery-graph vocabulary. Pure — no I/O; every closed set is imported from
|
|
261
|
+
* its owning module so this can never silently drift from what the compiler/runner actually accept. */
|
|
262
|
+
export function deliveryGraphVocabulary(): DeliveryGraphVocabulary {
|
|
263
|
+
const nodeKinds: NodeKindEntry[] = DELIVERY_NODE_KINDS.map((kind) => {
|
|
264
|
+
const detail = NODE_KIND_DETAIL[kind];
|
|
265
|
+
if (!detail) throw new Error(`deliveryGraphVocabulary: no detail for node kind '${kind}' (drift — extend NODE_KIND_DETAIL)`);
|
|
266
|
+
return { kind, ...detail };
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const waitProbeKinds: WaitProbeEntry[] = PROBE_KINDS.map((kind) => {
|
|
270
|
+
const detail = WAIT_PROBE_DETAIL[kind];
|
|
271
|
+
if (!detail) throw new Error(`deliveryGraphVocabulary: no detail for wait probe kind '${kind}' (drift — extend WAIT_PROBE_DETAIL)`);
|
|
272
|
+
return { kind, ...detail };
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
adr: "ADR 0005 — agent-authored delivery graphs",
|
|
277
|
+
summary:
|
|
278
|
+
"A delivery graph is a JSON DAG `{ name?, nodes[], edges[] }` an agent authors as DATA (never BPMN/code — " +
|
|
279
|
+
"the closed node vocabulary is the trust boundary). The agent surface ends at propose → compile → stage; " +
|
|
280
|
+
"DISPATCH is an operator-only cockpit action. This tool surfaces the closed vocabulary + the non-obvious " +
|
|
281
|
+
"wait/poll/fact-threading semantics so they are discoverable, not source-only.",
|
|
282
|
+
nodeKinds,
|
|
283
|
+
factTypes: [...DELIVERY_FACT_TYPES],
|
|
284
|
+
guardScalarTypes: [...DELIVERY_GUARD_SCALAR_TYPES],
|
|
285
|
+
waitProbeKinds,
|
|
286
|
+
connectorTargets: [...REAL_CONNECTOR_TARGETS, FORWARD_DECLARED_ENTRY],
|
|
287
|
+
onTimeout: ON_TIMEOUTS.map((value) => {
|
|
288
|
+
const meaning = ON_TIMEOUT_DETAIL[value];
|
|
289
|
+
if (!meaning) throw new Error(`deliveryGraphVocabulary: no meaning for onTimeout '${value}' (drift — extend ON_TIMEOUT_DETAIL)`);
|
|
290
|
+
return { value, meaning };
|
|
291
|
+
}),
|
|
292
|
+
pollBudget: {
|
|
293
|
+
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
|
|
294
|
+
defaultTimeoutIso: DEFAULT_READINESS_TIMEOUT,
|
|
295
|
+
defaultEveryMs: DEFAULT_EVERY_MS,
|
|
296
|
+
rule:
|
|
297
|
+
`POLL-BUDGET TRAP: an omitted \`poll\`/\`poll.timeoutMs\` inherits the built-in default budget of ` +
|
|
298
|
+
`${DEFAULT_READINESS_TIMEOUT} (${DEFAULT_TIMEOUT_MS} ms), re-probing every ${DEFAULT_EVERY_MS} ms. ` +
|
|
299
|
+
`That default is right for "is the package published yet" but badly wrong for \`wait[pr, merged]\` / ` +
|
|
300
|
+
`\`wait[epic]\`, which routinely wait hours or days — such a gate would escalate after 30 minutes for ` +
|
|
301
|
+
`no visible reason (neither compile nor preview surfaces the effective bound). ALWAYS set a realistic ` +
|
|
302
|
+
`\`poll.timeoutMs\` explicitly on any merge or epic gate.`,
|
|
303
|
+
},
|
|
304
|
+
factThreading: {
|
|
305
|
+
rule:
|
|
306
|
+
"A node's emitted `fact` is carried to a consumer ONLY by an EDGE. An edge `from` is either a bare " +
|
|
307
|
+
"`<nodeId>` (the node's completion fact) or a qualified `<nodeId>.<fact>` referencing a declared `emits`. " +
|
|
308
|
+
"A node that references a fact (e.g. a connector/`wait[pr]` late-binding `open.pr`) MUST have an incoming " +
|
|
309
|
+
"edge threading that fact from every producer — an unthreaded reference is rejected (`unbound-pr`).",
|
|
310
|
+
details: [
|
|
311
|
+
"The referenced fact must be declared in the producer's `emits[]` with the right `type` (a `pr` reference must be `pr`-typed).",
|
|
312
|
+
"A connector `payload` may OMIT `pr` to auto-bind the SINGLE incoming `pr` fact; with two `pr` facts flowing in you must name one.",
|
|
313
|
+
"Only scalar facts (`string`/`number`/`boolean`) may be referenced by an edge `when` guard; `artifact`/`version`/`url`/`pr` are not guardable.",
|
|
314
|
+
"The whole edge set must be a DAG; a self-edge or cycle is rejected.",
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
guideSection: "docs/agent-guide.md §9 (Author and run a delivery graph)",
|
|
318
|
+
};
|
|
319
|
+
}
|
package/app/readiness.ts
CHANGED
|
@@ -56,11 +56,16 @@ export type OnTimeout = "escalate" | "fail" | "continue";
|
|
|
56
56
|
/** Backoff policy between poll attempts. */
|
|
57
57
|
export type Backoff = "fixed" | "exponential";
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// The CLOSED per-field vocabularies `parseProbe` validates against — the single runtime source of
|
|
60
|
+
// truth for "which probe kinds / onTimeout options / conditions are legal". Exported so the
|
|
61
|
+
// delivery-graph vocabulary surface (`app/deliveryGraphVocabulary.ts`, S3/#609) DERIVES its
|
|
62
|
+
// structured description from these exact arrays and a drift test fails the build if a kind/condition
|
|
63
|
+
// is added here without a matching vocabulary entry (AGENTS.md: no drift surfaces).
|
|
64
|
+
export const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
|
|
65
|
+
export const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
|
|
66
|
+
export const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
|
|
67
|
+
export const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
|
|
68
|
+
export const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
|
|
64
69
|
|
|
65
70
|
/** The per-kind readiness predicate. Every field is optional; each kind reads only the ones it
|
|
66
71
|
* understands and applies a sensible default when a field is absent (see the matchers below). */
|
package/docs/agent-guide.md
CHANGED
|
@@ -412,6 +412,15 @@ PR #202 → a human does a manual OTP publish → PR #303 consumes the just-publ
|
|
|
412
412
|
**delivery graph** ([ADR 0005](https://github.com/nanobpm/nano-workforce/blob/main/docs/adr/0005-agent-authored-delivery-graphs.md))
|
|
413
413
|
lets you compose exactly that as **data** and hand it to a generic runner.
|
|
414
414
|
|
|
415
|
+
> **Discover the vocabulary from the surface.** Everything this section describes — the four
|
|
416
|
+
> node kinds and their body contracts, every `wait` probe kind and **what it observes**, the
|
|
417
|
+
> real-vs-stub connector targets, the `onTimeout` options, the poll-budget trap, and the
|
|
418
|
+
> fact-threading rules — is also available as **structured JSON** from the read tool
|
|
419
|
+
> **`getDeliveryGraphVocabulary`** (`GET __BASE__/delivery-graph/vocabulary`). It is derived
|
|
420
|
+
> from the implementing code (a drift test fails the build if the two disagree), so prose and
|
|
421
|
+
> data can never drift. Fetch it to author against the live vocabulary; this section is the
|
|
422
|
+
> narrative companion.
|
|
423
|
+
|
|
415
424
|
You author the graph as **JSON — never BPMN or code** (Decision 1: the agent must never
|
|
416
425
|
author the executable artifact; the closed node vocabulary is the trust boundary). Your
|
|
417
426
|
surface ends at **propose → compile → stage**: a single `compile` door validates the JSON,
|
|
@@ -741,3 +750,9 @@ Semantics:
|
|
|
741
750
|
B before its dependency merged. Size `timeoutMs` to how long the epic realistically takes.
|
|
742
751
|
- On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
|
|
743
752
|
output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
|
|
753
|
+
- **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
|
|
754
|
+
lineage thread whose **`rootRequestKey`** matches `target` **regardless of the thread's `kind`**
|
|
755
|
+
(`feature` | `epic` | `pr` | `delivery`), and `app/lineage.ts` lands a *feature* thread on
|
|
756
|
+
`stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
|
|
757
|
+
observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
|
|
758
|
+
way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
|
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
|
+
});
|