@nanobpm/nano-workforce 0.159.1 → 0.160.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 +6 -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/openapi.yaml +256 -0
- package/operations/getDeliveryGraphVocabulary.ts +25 -0
- package/package.json +1 -1
- package/test/deliveryGraphVocabulary-mcp.test.ts +62 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.160.0](https://github.com/nanobpm/nano-workforce/compare/v0.159.1...v0.160.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** getDeliveryGraphVocabulary read tool — closed vocabulary + wait semantics as structured data (S3) ([#616](https://github.com/nanobpm/nano-workforce/issues/616)) ([8d3e90a](https://github.com/nanobpm/nano-workforce/commit/8d3e90a294f9ed195c5cc1b7a30cffb1799a053a)), closes [nanobpm/nano-workforce#605](https://github.com/nanobpm/nano-workforce/issues/605) [#609](https://github.com/nanobpm/nano-workforce/issues/609)
|
|
6
|
+
|
|
1
7
|
## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// app/deliveryGraphVocabulary.test.ts — the DRIFT GUARD for the delivery-graph vocabulary surface
|
|
2
|
+
// (epic nano-workforce#605, S3/#609). The vocabulary (`getDeliveryGraphVocabulary`) exists so agents
|
|
3
|
+
// discover the closed node/probe/connector vocabulary from the surface instead of reading source; if
|
|
4
|
+
// a new probe kind or connector target lands in the compiler WITHOUT a matching vocabulary entry, the
|
|
5
|
+
// surface silently lies. These tests fail the build in exactly that case: they assert the vocabulary's
|
|
6
|
+
// key sets are byte-identical to the closed sets in `app/deliveryGraph.ts` / `app/readiness.ts` /
|
|
7
|
+
// `app/convergeTargets.ts` (AGENTS.md — "no drift surfaces").
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
11
|
+
import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
|
|
12
|
+
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_EVERY_MS,
|
|
15
|
+
DEFAULT_TIMEOUT_MS,
|
|
16
|
+
EPIC_CONDITIONS,
|
|
17
|
+
ON_TIMEOUTS,
|
|
18
|
+
PR_CONDITIONS,
|
|
19
|
+
PROBE_KINDS,
|
|
20
|
+
} from "./readiness.ts";
|
|
21
|
+
|
|
22
|
+
const sorted = (xs: readonly string[]): string[] => [...xs].sort();
|
|
23
|
+
|
|
24
|
+
test("node kinds cover exactly DELIVERY_NODE_KINDS (add a kind to the compiler ⇒ must add a vocab entry)", () => {
|
|
25
|
+
const vocab = deliveryGraphVocabulary();
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
sorted(vocab.nodeKinds.map((n) => n.kind)),
|
|
28
|
+
sorted(DELIVERY_NODE_KINDS),
|
|
29
|
+
"vocabulary node kinds drifted from DELIVERY_NODE_KINDS — extend NODE_KIND_DETAIL",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("wait probe kinds cover exactly PROBE_KINDS (add a probe kind ⇒ must add a vocab entry)", () => {
|
|
34
|
+
const vocab = deliveryGraphVocabulary();
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
sorted(vocab.waitProbeKinds.map((p) => p.kind)),
|
|
37
|
+
sorted(PROBE_KINDS),
|
|
38
|
+
"vocabulary wait probe kinds drifted from PROBE_KINDS — extend WAIT_PROBE_DETAIL",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("pr / epic probe conditions match the closed PR_CONDITIONS / EPIC_CONDITIONS", () => {
|
|
43
|
+
const vocab = deliveryGraphVocabulary();
|
|
44
|
+
const pr = vocab.waitProbeKinds.find((p) => p.kind === "pr");
|
|
45
|
+
const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
|
|
46
|
+
assert.ok(pr && epic, "pr and epic probe entries must exist");
|
|
47
|
+
assert.deepEqual(sorted(pr.conditions ?? []), sorted(PR_CONDITIONS), "pr conditions drifted from PR_CONDITIONS");
|
|
48
|
+
assert.deepEqual(sorted(epic.conditions ?? []), sorted(EPIC_CONDITIONS), "epic conditions drifted from EPIC_CONDITIONS");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("every real converge-enrollment target has a real vocab entry (add a target ⇒ must add a vocab entry)", () => {
|
|
52
|
+
const vocab = deliveryGraphVocabulary();
|
|
53
|
+
const realTargets = vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target);
|
|
54
|
+
for (const target of [CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]) {
|
|
55
|
+
assert.ok(
|
|
56
|
+
realTargets.includes(target),
|
|
57
|
+
`converge target '${target}' is missing a 'real' vocabulary entry — extend REAL_CONNECTOR_TARGETS`,
|
|
58
|
+
);
|
|
59
|
+
// Guard the classification too: a target the compiler treats as converge-enrollment must be marked real.
|
|
60
|
+
assert.ok(isConvergeTarget(target), `sanity: '${target}' must be an isConvergeTarget`);
|
|
61
|
+
}
|
|
62
|
+
// Exactly the converge set is "real"; nothing else is claimed real, and the stub sentinel is present.
|
|
63
|
+
assert.deepEqual(sorted(realTargets), sorted([CONVERGE_TARGET, CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET]));
|
|
64
|
+
assert.ok(
|
|
65
|
+
vocab.connectorTargets.some((t) => t.status === "forward-declared"),
|
|
66
|
+
"the forward-declared stub sentinel must be present so agents learn the real-vs-stub split",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("onTimeout options match the closed ON_TIMEOUTS", () => {
|
|
71
|
+
const vocab = deliveryGraphVocabulary();
|
|
72
|
+
assert.deepEqual(sorted(vocab.onTimeout.map((o) => o.value)), sorted(ON_TIMEOUTS), "onTimeout options drifted from ON_TIMEOUTS");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("fact types + guard scalar types are derived verbatim", () => {
|
|
76
|
+
const vocab = deliveryGraphVocabulary();
|
|
77
|
+
assert.deepEqual(vocab.factTypes, [...DELIVERY_FACT_TYPES]);
|
|
78
|
+
assert.deepEqual(vocab.guardScalarTypes, [...DELIVERY_GUARD_SCALAR_TYPES]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("poll-budget carries the real defaults and names the 30-minute trap", () => {
|
|
82
|
+
const vocab = deliveryGraphVocabulary();
|
|
83
|
+
assert.equal(vocab.pollBudget.defaultTimeoutMs, DEFAULT_TIMEOUT_MS);
|
|
84
|
+
assert.equal(vocab.pollBudget.defaultEveryMs, DEFAULT_EVERY_MS);
|
|
85
|
+
assert.match(vocab.pollBudget.rule, /poll\.timeoutMs/);
|
|
86
|
+
assert.match(vocab.pollBudget.rule, /30 minutes|1800000/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the epic probe states the FEATURE-RUN observation semantics (the #605 evidence gap)", () => {
|
|
90
|
+
const vocab = deliveryGraphVocabulary();
|
|
91
|
+
const epic = vocab.waitProbeKinds.find((p) => p.kind === "epic");
|
|
92
|
+
assert.ok(epic, "epic probe entry must exist");
|
|
93
|
+
assert.match(epic.observes, /rootRequestKey/i);
|
|
94
|
+
assert.match(epic.observes, /regardless of/i);
|
|
95
|
+
assert.match(epic.observes, /feature/i);
|
|
96
|
+
assert.match(epic.ready, /stage:"merged"|stage:\\"merged\\"|merged.*active:false/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("fact-threading rule names the unbound-pr rejection", () => {
|
|
100
|
+
const vocab = deliveryGraphVocabulary();
|
|
101
|
+
assert.match(vocab.factThreading.rule, /unbound-pr/);
|
|
102
|
+
});
|
|
@@ -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/openapi.yaml
CHANGED
|
@@ -4064,6 +4064,262 @@ paths:
|
|
|
4064
4064
|
application/json:
|
|
4065
4065
|
schema:
|
|
4066
4066
|
$ref: "#/components/schemas/ErrorBody"
|
|
4067
|
+
/delivery-graph/vocabulary:
|
|
4068
|
+
get:
|
|
4069
|
+
operationId: getDeliveryGraphVocabulary
|
|
4070
|
+
summary: The closed delivery-graph vocabulary + wait-probe semantics as structured JSON (ADR 0005).
|
|
4071
|
+
description: >-
|
|
4072
|
+
Read tool (projected onto the MCP surface like `getAgentInstructions`). Returns the CLOSED
|
|
4073
|
+
delivery-graph vocabulary and the non-obvious wait/poll/fact-threading semantics as structured
|
|
4074
|
+
JSON, so you can discover them from the surface instead of reading source. Covers: the four node
|
|
4075
|
+
kinds (`agent`/`wait`/`human`/`connector`) with their per-kind body contracts; every wait probe
|
|
4076
|
+
kind with its `match` fields and — crucially — WHAT it OBSERVES (e.g. `epic` resolves a lineage
|
|
4077
|
+
thread by `rootRequestKey` REGARDLESS of the thread's kind, so it gates plan-fanout epics AND
|
|
4078
|
+
single-PR feature runs alike, ready on `stage:"merged" && active:false`); which connector targets
|
|
4079
|
+
are real (`converge`/`converge-merge`/`merge-main`) vs. forward-declared stubs; the `onTimeout`
|
|
4080
|
+
options; the poll-budget rule (always set a realistic `poll.timeoutMs` on merge/epic gates — the
|
|
4081
|
+
30-minute default is a trap); and the edge/fact-threading rules (a `node.fact` must be threaded by
|
|
4082
|
+
an edge to every consumer, else `unbound-pr`). Derived from the implementing code (a drift test
|
|
4083
|
+
fails the build if a probe kind / connector target is added without a vocabulary entry). Pure,
|
|
4084
|
+
read-only, idempotent — no side effects. Pairs with `compileDeliveryGraph`/`previewDeliveryGraph`:
|
|
4085
|
+
call this first to learn the vocabulary, then author a `DeliveryGraph` and compile it.
|
|
4086
|
+
security:
|
|
4087
|
+
- hookSecret: []
|
|
4088
|
+
- {}
|
|
4089
|
+
responses:
|
|
4090
|
+
"200":
|
|
4091
|
+
description: The full delivery-graph vocabulary.
|
|
4092
|
+
content:
|
|
4093
|
+
application/json:
|
|
4094
|
+
schema:
|
|
4095
|
+
type: object
|
|
4096
|
+
additionalProperties: false
|
|
4097
|
+
description: The closed delivery-graph vocabulary + wait-probe semantics, derived from the compiler/runner code.
|
|
4098
|
+
required:
|
|
4099
|
+
- adr
|
|
4100
|
+
- summary
|
|
4101
|
+
- nodeKinds
|
|
4102
|
+
- factTypes
|
|
4103
|
+
- guardScalarTypes
|
|
4104
|
+
- waitProbeKinds
|
|
4105
|
+
- connectorTargets
|
|
4106
|
+
- onTimeout
|
|
4107
|
+
- pollBudget
|
|
4108
|
+
- factThreading
|
|
4109
|
+
properties:
|
|
4110
|
+
adr:
|
|
4111
|
+
type: string
|
|
4112
|
+
description: The governing ADR (agent-authored delivery graphs).
|
|
4113
|
+
summary:
|
|
4114
|
+
type: string
|
|
4115
|
+
description: One-paragraph orientation on the graph shape and the propose→compile→stage surface.
|
|
4116
|
+
nodeKinds:
|
|
4117
|
+
type: array
|
|
4118
|
+
description: The closed node-kind allowlist with each kind's config key and body contract.
|
|
4119
|
+
items:
|
|
4120
|
+
type: object
|
|
4121
|
+
additionalProperties: false
|
|
4122
|
+
required: [kind, configKey, requiredFields, optionalFields, sideEffecting, mayEmit, summary]
|
|
4123
|
+
properties:
|
|
4124
|
+
kind:
|
|
4125
|
+
type: string
|
|
4126
|
+
description: The node kind (one of agent | wait | human | connector).
|
|
4127
|
+
configKey:
|
|
4128
|
+
type: string
|
|
4129
|
+
description: The per-kind config object key the node must carry.
|
|
4130
|
+
requiredFields:
|
|
4131
|
+
type: array
|
|
4132
|
+
items: { type: string }
|
|
4133
|
+
description: Required non-empty fields inside the per-kind config.
|
|
4134
|
+
optionalFields:
|
|
4135
|
+
type: array
|
|
4136
|
+
items: { type: string }
|
|
4137
|
+
description: Optional fields inside the per-kind config.
|
|
4138
|
+
sideEffecting:
|
|
4139
|
+
type: boolean
|
|
4140
|
+
description: Whether the node performs a side effect (agent/connector) vs. read-only (wait/human).
|
|
4141
|
+
mayEmit:
|
|
4142
|
+
type: boolean
|
|
4143
|
+
description: Whether the node may declare typed emits.
|
|
4144
|
+
summary:
|
|
4145
|
+
type: string
|
|
4146
|
+
description: The body contract / semantics of the kind.
|
|
4147
|
+
factTypes:
|
|
4148
|
+
type: array
|
|
4149
|
+
items: { type: string }
|
|
4150
|
+
description: The closed emitted-fact type allowlist.
|
|
4151
|
+
guardScalarTypes:
|
|
4152
|
+
type: array
|
|
4153
|
+
items: { type: string }
|
|
4154
|
+
description: The scalar fact types an edge `when` guard may reference.
|
|
4155
|
+
waitProbeKinds:
|
|
4156
|
+
type: array
|
|
4157
|
+
description: Every wait probe kind, its match fields, and what it observes / when it is ready.
|
|
4158
|
+
items:
|
|
4159
|
+
type: object
|
|
4160
|
+
additionalProperties: false
|
|
4161
|
+
required: [kind, target, matchFields, observes, ready]
|
|
4162
|
+
properties:
|
|
4163
|
+
kind:
|
|
4164
|
+
type: string
|
|
4165
|
+
description: The probe kind (http | command | npm | github-check | capability | pr | epic).
|
|
4166
|
+
target:
|
|
4167
|
+
type: string
|
|
4168
|
+
description: What the probe's `target` names.
|
|
4169
|
+
matchFields:
|
|
4170
|
+
type: array
|
|
4171
|
+
items: { type: string }
|
|
4172
|
+
description: The `match` fields this kind reads.
|
|
4173
|
+
conditions:
|
|
4174
|
+
type: array
|
|
4175
|
+
items: { type: string }
|
|
4176
|
+
description: The closed condition set for pr/epic kinds (else absent).
|
|
4177
|
+
observes:
|
|
4178
|
+
type: string
|
|
4179
|
+
description: The read that decides readiness (what the probe actually observes).
|
|
4180
|
+
ready:
|
|
4181
|
+
type: string
|
|
4182
|
+
description: The condition under which the probe reports ready.
|
|
4183
|
+
binds:
|
|
4184
|
+
type: array
|
|
4185
|
+
items: { type: string }
|
|
4186
|
+
description: Output facts the probe binds on a ready match.
|
|
4187
|
+
connectorTargets:
|
|
4188
|
+
type: array
|
|
4189
|
+
description: Which connector targets are real (converge-enrollment) vs. forward-declared stubs.
|
|
4190
|
+
items:
|
|
4191
|
+
type: object
|
|
4192
|
+
additionalProperties: false
|
|
4193
|
+
required: [target, status, summary]
|
|
4194
|
+
properties:
|
|
4195
|
+
target:
|
|
4196
|
+
type: string
|
|
4197
|
+
description: The connector target literal (or a sentinel for any other target).
|
|
4198
|
+
status:
|
|
4199
|
+
type: string
|
|
4200
|
+
enum: [real, forward-declared]
|
|
4201
|
+
description: real ⇒ dispatches a real side effect; forward-declared ⇒ a no-op stub.
|
|
4202
|
+
convergeOnlyDefault:
|
|
4203
|
+
type: boolean
|
|
4204
|
+
description: The default `convergeOnly` for a real converge target.
|
|
4205
|
+
summary:
|
|
4206
|
+
type: string
|
|
4207
|
+
description: What the target does.
|
|
4208
|
+
onTimeout:
|
|
4209
|
+
type: array
|
|
4210
|
+
description: The `onTimeout` options for a bounded wait and what each does.
|
|
4211
|
+
items:
|
|
4212
|
+
type: object
|
|
4213
|
+
additionalProperties: false
|
|
4214
|
+
required: [value, meaning]
|
|
4215
|
+
properties:
|
|
4216
|
+
value: { type: string }
|
|
4217
|
+
meaning: { type: string }
|
|
4218
|
+
pollBudget:
|
|
4219
|
+
type: object
|
|
4220
|
+
additionalProperties: false
|
|
4221
|
+
required: [defaultTimeoutMs, defaultTimeoutIso, defaultEveryMs, rule]
|
|
4222
|
+
description: The poll-budget defaults and the "always set poll.timeoutMs on merge/epic gates" rule.
|
|
4223
|
+
properties:
|
|
4224
|
+
defaultTimeoutMs: { type: number }
|
|
4225
|
+
defaultTimeoutIso: { type: string }
|
|
4226
|
+
defaultEveryMs: { type: number }
|
|
4227
|
+
rule: { type: string }
|
|
4228
|
+
factThreading:
|
|
4229
|
+
type: object
|
|
4230
|
+
additionalProperties: false
|
|
4231
|
+
required: [rule, details]
|
|
4232
|
+
description: The edge/fact-threading rules — a node.fact reaches a consumer only via an edge.
|
|
4233
|
+
properties:
|
|
4234
|
+
rule: { type: string }
|
|
4235
|
+
details:
|
|
4236
|
+
type: array
|
|
4237
|
+
items: { type: string }
|
|
4238
|
+
guideSection:
|
|
4239
|
+
type: string
|
|
4240
|
+
description: The operator-guide section this data mirrors (docs/agent-guide.md §9).
|
|
4241
|
+
example:
|
|
4242
|
+
adr: "ADR 0005 — agent-authored delivery graphs"
|
|
4243
|
+
summary: "A delivery graph is a JSON DAG an agent authors as DATA; the surface ends at propose → compile → stage."
|
|
4244
|
+
nodeKinds:
|
|
4245
|
+
- kind: agent
|
|
4246
|
+
configKey: agent
|
|
4247
|
+
requiredFields: [jobType]
|
|
4248
|
+
optionalFields: [prompt, converge, merge]
|
|
4249
|
+
sideEffecting: true
|
|
4250
|
+
mayEmit: true
|
|
4251
|
+
summary: "A worker runs an agent job type; an agent that opens a PR emits it as a `pr` fact."
|
|
4252
|
+
- kind: wait
|
|
4253
|
+
configKey: wait
|
|
4254
|
+
requiredFields: [kind, target]
|
|
4255
|
+
optionalFields: [match, poll, onTimeout, credentialEnv]
|
|
4256
|
+
sideEffecting: false
|
|
4257
|
+
mayEmit: true
|
|
4258
|
+
summary: "A durable, bounded, read-only readiness probe (a ReadinessProbe verbatim)."
|
|
4259
|
+
- kind: connector
|
|
4260
|
+
configKey: connector
|
|
4261
|
+
requiredFields: [target]
|
|
4262
|
+
optionalFields: [dedupeKey, payload]
|
|
4263
|
+
sideEffecting: true
|
|
4264
|
+
mayEmit: true
|
|
4265
|
+
summary: "An automated outbound action; payload for a converge target is { pr, convergeOnly?, dependsOn? }."
|
|
4266
|
+
factTypes: [string, number, boolean, artifact, version, url, pr]
|
|
4267
|
+
guardScalarTypes: [string, number, boolean]
|
|
4268
|
+
waitProbeKinds:
|
|
4269
|
+
- kind: pr
|
|
4270
|
+
target: "an owner/repo#N PR (or a <node>.pr fact reference)"
|
|
4271
|
+
matchFields: [prState]
|
|
4272
|
+
conditions: [ready, merged, mergeable, checks-green]
|
|
4273
|
+
observes: "the live GitHub state of one in-flight PR; only OBSERVES, level-triggered."
|
|
4274
|
+
ready: "the PR reaches match.prState (default merged)."
|
|
4275
|
+
binds: [mergedSha]
|
|
4276
|
+
- kind: epic
|
|
4277
|
+
target: "the epic's durable planKey (owner/repo#NN, the epic issue)"
|
|
4278
|
+
matchFields: [epicState]
|
|
4279
|
+
conditions: [merged, done]
|
|
4280
|
+
observes: "the app's lineage read-model, resolved by rootRequestKey REGARDLESS of thread kind (feature | epic | pr | delivery) — so it gates a single-PR FEATURE RUN just as well as a plan-fanout epic."
|
|
4281
|
+
ready: 'the lineage thread reaches stage:"merged" && active:false (every opened slice/PR landed).'
|
|
4282
|
+
binds: [prCount]
|
|
4283
|
+
connectorTargets:
|
|
4284
|
+
- target: converge
|
|
4285
|
+
status: real
|
|
4286
|
+
convergeOnlyDefault: true
|
|
4287
|
+
summary: "Converge-only: drive review convergence and stop at converged."
|
|
4288
|
+
- target: converge-merge
|
|
4289
|
+
status: real
|
|
4290
|
+
convergeOnlyDefault: false
|
|
4291
|
+
summary: "Unit-level land: converge AND merge onto the PR's own base branch."
|
|
4292
|
+
- target: merge-main
|
|
4293
|
+
status: real
|
|
4294
|
+
convergeOnlyDefault: false
|
|
4295
|
+
summary: "Graph-level top-level land onto main (two-level merge)."
|
|
4296
|
+
- target: "<any other target>"
|
|
4297
|
+
status: forward-declared
|
|
4298
|
+
summary: "Forward-declared stub — returns a deterministic acknowledgement, fires no real I/O."
|
|
4299
|
+
onTimeout:
|
|
4300
|
+
- value: escalate
|
|
4301
|
+
meaning: "park a human escalation when the bounded wait elapses."
|
|
4302
|
+
- value: fail
|
|
4303
|
+
meaning: "terminate the gate as failed (NOT yet supported on a wait node — rejected by the compiler)."
|
|
4304
|
+
- value: continue
|
|
4305
|
+
meaning: "proceed as if ready when the wait elapses (a soft gate)."
|
|
4306
|
+
pollBudget:
|
|
4307
|
+
defaultTimeoutMs: 1800000
|
|
4308
|
+
defaultTimeoutIso: PT30M
|
|
4309
|
+
defaultEveryMs: 15000
|
|
4310
|
+
rule: "An omitted poll.timeoutMs inherits the 30-minute default — a trap for wait[pr, merged]/wait[epic] which wait hours/days. Always set poll.timeoutMs explicitly on a merge/epic gate."
|
|
4311
|
+
factThreading:
|
|
4312
|
+
rule: "A node's emitted fact reaches a consumer ONLY via an edge (<nodeId>.<fact>); an unthreaded reference is rejected (unbound-pr)."
|
|
4313
|
+
details:
|
|
4314
|
+
- "The referenced fact must be declared in the producer's emits[] with the right type."
|
|
4315
|
+
- "A connector payload may omit pr to auto-bind the single incoming pr fact."
|
|
4316
|
+
guideSection: "docs/agent-guide.md §9 (Author and run a delivery graph)"
|
|
4317
|
+
"401":
|
|
4318
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
4319
|
+
content:
|
|
4320
|
+
application/json:
|
|
4321
|
+
schema:
|
|
4322
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
4067
4323
|
/actions/delivery-graph/library/save:
|
|
4068
4324
|
post:
|
|
4069
4325
|
operationId: saveToLibrary
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GET /app/api/delivery-graph/vocabulary → operationId `getDeliveryGraphVocabulary` (epic
|
|
2
|
+
// nano-workforce#605, S3/#609). A read tool — projected onto the MCP surface like
|
|
3
|
+
// `getAgentInstructions` — that returns the closed delivery-graph vocabulary + wait-probe semantics
|
|
4
|
+
// as STRUCTURED JSON, so an agent can discover the node/probe/connector vocabulary and the non-obvious
|
|
5
|
+
// wait/poll/fact-threading rules from the surface instead of reading source (ADR 0005).
|
|
6
|
+
//
|
|
7
|
+
// The payload is derived from the implementing code (`app/deliveryGraphVocabulary.ts`) — every closed
|
|
8
|
+
// set is imported from its owning module, and a drift test fails the build if a probe kind / connector
|
|
9
|
+
// target lands in the compiler without a vocabulary entry. Cross-linked from docs/agent-guide.md §9.
|
|
10
|
+
//
|
|
11
|
+
// Read-only. The optional shared-secret guard mirrors /agent and /version: enforced HERE only when
|
|
12
|
+
// NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
|
|
13
|
+
import { deliveryGraphVocabulary } from "../app/deliveryGraphVocabulary.ts";
|
|
14
|
+
import { envVar } from "../app/version.ts";
|
|
15
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
16
|
+
|
|
17
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
18
|
+
|
|
19
|
+
export default defineOperation("getDeliveryGraphVocabulary", ({ req }, app) => {
|
|
20
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
21
|
+
app.log.warn("getDeliveryGraphVocabulary rejected: missing/invalid shared secret");
|
|
22
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
23
|
+
}
|
|
24
|
+
return { status: 200, body: deliveryGraphVocabulary() };
|
|
25
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.160.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",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// test/deliveryGraphVocabulary-mcp.test.ts — S3/#609 surface guard.
|
|
2
|
+
//
|
|
3
|
+
// Verifies the `getDeliveryGraphVocabulary` READ tool is actually VISIBLE to agents over MCP (the
|
|
4
|
+
// Urban runtime projects `openapi.yaml` into MCP tools, ADR 0067 — zero MCP server code in nwf) and
|
|
5
|
+
// that its operation conforms to S0's self-contained convention: a `$ref`-free `200` response schema
|
|
6
|
+
// with an explicit `type: object` and a worked `example`. Drives the REAL projector (`collectOperations`
|
|
7
|
+
// from `@nanobpm/urban/toolkit`) over the checked-in spec, exactly like `test/mcp-tool-schemas.test.ts`,
|
|
8
|
+
// so a regression (the op excluded, or a re-leaked `$ref`/dropped example) fails the build.
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
|
|
12
|
+
import { parse as parseYaml } from "yaml";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { deliveryGraphVocabulary } from "../app/deliveryGraphVocabulary.ts";
|
|
15
|
+
|
|
16
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
17
|
+
const SPEC_TEXT = readFileSync(`${ROOT}openapi.yaml`, "utf8");
|
|
18
|
+
const SPEC = parseSpec(SPEC_TEXT);
|
|
19
|
+
const OP_ID = "getDeliveryGraphVocabulary";
|
|
20
|
+
|
|
21
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
22
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
|
|
24
|
+
/** Every `$ref` reachable in a schema, JSON-path-qualified for the failure message. */
|
|
25
|
+
function findRefs(node: unknown, path: string, out: string[]): void {
|
|
26
|
+
if (Array.isArray(node)) {
|
|
27
|
+
node.forEach((n, i) => findRefs(n, `${path}[${i}]`, out));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!isRecord(node)) return;
|
|
31
|
+
for (const [k, v] of Object.entries(node)) {
|
|
32
|
+
if (k === "$ref" && typeof v === "string") out.push(`${path}.$ref -> ${v}`);
|
|
33
|
+
else findRefs(v, `${path}.${k}`, out);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("getDeliveryGraphVocabulary is projected onto the MCP tool surface (not excluded)", () => {
|
|
38
|
+
const op = collectOperations(SPEC).find((o) => o.operationId === OP_ID);
|
|
39
|
+
assert(op, `${OP_ID} must be a declared operation the projector can see`);
|
|
40
|
+
assert(!op!.mcpExcluded, `${OP_ID} must be visible over MCP (no x-mcp exclusion)`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the getDeliveryGraphVocabulary 200 response schema is $ref-free with an explicit type + example", () => {
|
|
44
|
+
const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
|
|
45
|
+
const schema = doc?.paths?.["/delivery-graph/vocabulary"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema;
|
|
46
|
+
assert(isRecord(schema), "the 200 response must carry an inline application/json schema");
|
|
47
|
+
assert.equal(schema.type, "object", "the response schema must declare an explicit type: object");
|
|
48
|
+
assert("example" in schema, "the response schema must embed a worked example (S0 self-contained convention)");
|
|
49
|
+
const refs: string[] = [];
|
|
50
|
+
findRefs(schema, `${OP_ID}.responses.200`, refs);
|
|
51
|
+
assert(refs.length === 0, `${OP_ID}: 200 response schema leaks $ref(s): ${refs.join(", ")}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the served payload matches the response schema's required keys (data ⇄ contract)", () => {
|
|
55
|
+
const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
|
|
56
|
+
const schema = doc.paths["/delivery-graph/vocabulary"].get.responses["200"].content["application/json"].schema;
|
|
57
|
+
const required: string[] = schema.required ?? [];
|
|
58
|
+
const payload = deliveryGraphVocabulary() as Record<string, unknown>;
|
|
59
|
+
for (const key of required) {
|
|
60
|
+
assert(key in payload, `served vocabulary is missing required schema key '${key}'`);
|
|
61
|
+
}
|
|
62
|
+
});
|