@nanobpm/nano-workforce 0.159.0 → 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 CHANGED
@@ -1,3 +1,15 @@
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
+
7
+ ## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery-graph:** read-after-write consistency for listStagedProposals (S2) ([#617](https://github.com/nanobpm/nano-workforce/issues/617)) ([bd0246c](https://github.com/nanobpm/nano-workforce/commit/bd0246cc81241041320aa483b0a0789f5e372031)), closes [#608](https://github.com/nanobpm/nano-workforce/issues/608)
12
+
1
13
  ## [0.159.0](https://github.com/nanobpm/nano-workforce/compare/v0.158.1...v0.159.0) (2026-08-29)
2
14
 
3
15
  ### Features
@@ -15,6 +15,7 @@ import {
15
15
  DELIVERY_PROPOSAL_TTL_MS,
16
16
  deliveryGraphProposals,
17
17
  getStagedProposal,
18
+ isLiveStaged,
18
19
  isProposalExpired,
19
20
  markProposalDismissed,
20
21
  markProposalDispatched,
@@ -82,6 +83,21 @@ test("isProposalExpired: past → true, future → false, blank/corrupt → true
82
83
  assertEquals(isProposalExpired("garbage", at), true);
83
84
  });
84
85
 
86
+ test("isLiveStaged: the ONE liveness predicate — only a `staged`, not-yet-expired row is live (#608)", () => {
87
+ const at = new Date("2024-06-01T00:00:00.000Z");
88
+ const live = row({ createdAt: "2024-05-31T23:00:00.000Z" }); // staged, TTL a day out → live
89
+ assertEquals(isLiveStaged(live, at), true);
90
+ // A future-created staged row is trivially live too (TTL further out).
91
+ assertEquals(isLiveStaged(row(), new Date()), true);
92
+ // Every non-`staged` status is NOT live, regardless of TTL.
93
+ for (const status of ["superseded", "dispatched", "expired", "dismissed"] as const) {
94
+ assertEquals(isLiveStaged({ ...live, status }, at), false);
95
+ }
96
+ // A `staged` row whose TTL has elapsed is NOT live (mirrors isProposalExpired, fail-closed).
97
+ assertEquals(isLiveStaged({ ...live, expires_at: "2024-05-30T00:00:00.000Z" }, at), false);
98
+ assertEquals(isLiveStaged({ ...live, expires_at: "" }, at), false);
99
+ });
100
+
85
101
  test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a dispatch endpoint", () => {
86
102
  const url = proposalReviewUrl("abc123", "https://cockpit.example");
87
103
  assertEquals(url, "https://cockpit.example/app/pages/delivery-graphs#proposal-abc123");
@@ -207,9 +207,25 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
207
207
  return toWrite;
208
208
  }
209
209
 
210
- /** Load a proposal that is live and dispatchable RIGHT NOW: it exists, is `staged` (not superseded or
211
- * already dispatched), and has not aged out of its TTL. Returns null otherwise, so the cockpit
212
- * dispatch action refuses a stale/unknown/already-dispatched digest cleanly. */
210
+ /** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
211
+ * `staged` (not superseded / dispatched / expired / dismissed) AND has not aged out of its TTL. This is
212
+ * the SINGLE SOURCE OF TRUTH the digest read (`getStagedProposal`), the list read
213
+ * (`listStagedProposals`), and — through them — the cockpit App-View, the dispatch/preview/dismiss
214
+ * doors, and the MCP `listStagedProposals` tool all resolve liveness through, so no two readers can ever
215
+ * drift on which rows are live (AGENTS.md "Derivation over duplication"). A read-after-write from
216
+ * `compileDeliveryGraph` is trustworthy because this predicate is evaluated over the SAME durable
217
+ * `delivery_graph_proposals` store the compile door commits the row to (the app's single default
218
+ * `app.data` source) — a freshly staged row (`status: 'staged'`, `expires_at` a full TTL in the future)
219
+ * is live by construction, with no projection/read-model between the write and the read to lag behind. */
220
+ export function isLiveStaged(row: DeliveryGraphProposal, at: Date = new Date()): boolean {
221
+ return row.status === "staged" && !isProposalExpired(row.expires_at, at);
222
+ }
223
+
224
+ /** Load a proposal that is live and dispatchable RIGHT NOW: it exists and satisfies {@link isLiveStaged}
225
+ * (it is `staged` — not superseded or already dispatched — and has not aged out of its TTL). Returns
226
+ * null otherwise, so the cockpit dispatch action refuses a stale/unknown/already-dispatched digest
227
+ * cleanly. Reads the authoritative `delivery_graph_proposals` row by primary key from the same
228
+ * `app.data` store the compile door stages into — a read-your-writes lookup, no read-model in between. */
213
229
  export async function getStagedProposal(
214
230
  data: DataLayer,
215
231
  digest: string,
@@ -217,24 +233,28 @@ export async function getStagedProposal(
217
233
  ): Promise<DeliveryGraphProposal | null> {
218
234
  const row = await deliveryGraphProposals(data).get(digest);
219
235
  if (!row) return null;
220
- if (row.status !== "staged") return null;
221
- if (isProposalExpired(row.expires_at, at)) return null;
222
- return row;
236
+ return isLiveStaged(row, at) ? row : null;
223
237
  }
224
238
 
225
- /** Every LIVE staged proposal — `status = 'staged'` AND not aged out of its TTL — newest first. The
226
- * staged App-View (`pages/delivery-graphs/staged.mount.js`) polls this to render the Preview-DI +
227
- * Dispatch list. Mirrors `getStagedProposal`'s freshness guard (`isProposalExpired`) so an
228
- * expired-but-not-yet-swept row is never offered for preview/dispatch, unlike a raw
229
- * `status = 'staged'` datasource filter which cannot express a `expires_at > now` cutoff and so lingers
230
- * an aged-out row until the sweep realises the TTL. Read-only; no write. */
239
+ /** Every LIVE staged proposal — {@link isLiveStaged} (`status = 'staged'` AND not aged out of its TTL)
240
+ * newest first. The staged App-View (`pages/delivery-graphs/staged.mount.js`) and the MCP
241
+ * `listStagedProposals` tool both poll THIS door to render/answer the Preview-DI + Dispatch list.
242
+ *
243
+ * READ-AFTER-WRITE GUARANTEE (issue #608). The list is served by a fresh query against the authoritative
244
+ * `delivery_graph_proposals` table on the app's single default `app.data` source the SAME store, in
245
+ * the SAME scope, the `compileDeliveryGraph`/`stageProposal` write commits to. There is no
246
+ * projection/read-model or cache between the write and this read, so a digest `compileDeliveryGraph`
247
+ * just returned is listed on the very next call with NO intervening delay (the write is committed before
248
+ * the compile door responds). Liveness is decided by the shared {@link isLiveStaged} predicate — NOT a
249
+ * second `status = 'staged'` datasource filter, which cannot express an `expires_at > now` cutoff and so
250
+ * would linger an aged-out row until the poller's sweep realises the TTL. Read-only; no write. */
231
251
  export async function listStagedProposals(
232
252
  data: DataLayer,
233
253
  at: Date = new Date(),
234
254
  ): Promise<DeliveryGraphProposal[]> {
235
255
  const rows = await deliveryGraphProposals(data).find({ status: "staged" });
236
256
  return rows
237
- .filter((row) => !isProposalExpired(row.expires_at, at))
257
+ .filter((row) => isLiveStaged(row, at))
238
258
  .sort((a, b) => b.created_at.localeCompare(a.created_at));
239
259
  }
240
260
 
@@ -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
- const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability", "pr", "epic"];
60
- const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
61
- const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
62
- const PR_CONDITIONS: readonly PrCondition[] = ["ready", "merged", "mergeable", "checks-green"];
63
- const EPIC_CONDITIONS: readonly EpicCondition[] = ["merged", "done"];
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). */
@@ -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.
@@ -0,0 +1,132 @@
1
+ // Delivery-graph read-after-write regression net (epic #605 slice S2, issue #608).
2
+ //
3
+ // PINS the acceptance guarantee: immediately after `compileDeliveryGraph` returns a staged `digest`,
4
+ // `listStagedProposals` returns THAT digest — with NO intervening delay. In the evidence session
5
+ // (issue #608) a `compileDeliveryGraph` that returned `status:"ready"` with `digest:"ca8fb90a1b0c"`
6
+ // was followed by a `listStagedProposals` that answered `{"count":0,"proposals":[]}` — the read and
7
+ // the write had disagreed, so an agent could not confirm its own staging. The fix serves both from a
8
+ // single source of truth: the read (`listStagedProposals` / `getStagedProposal`, unified through the
9
+ // one `isLiveStaged` predicate) queries the SAME `delivery_graph_proposals` store, in the SAME scope,
10
+ // that the compile/stage write commits to — no read-model or cache in between.
11
+ //
12
+ // This case is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports
13
+ // `bootMcpHarness` and drives the REAL runtime-served `/app/mcp` surface over the client handshake —
14
+ // the exact transport an agent uses — so a regression that reintroduces the disagreement (a lagging
15
+ // projection, a divergent read filter, a supersede that drops the just-written row) fails the build.
16
+ // It does NOT re-implement the handshake (see the harness module header's EXTENSION SEAM).
17
+ //
18
+ // Run with `npm run e2e`.
19
+ import assert from "node:assert/strict";
20
+ import { after, before, describe, test } from "node:test";
21
+ import { assertObjectBodyAccepted, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
22
+
23
+ /** A minimal side-effecting graph — `compileDeliveryGraph` STAGES it (unlike the pure `previewDelivery
24
+ * Graph`), which is exactly the write whose visibility we verify. Two agent nodes → two side effects. */
25
+ const STAGES_A_PROPOSAL = {
26
+ name: "S2 read-after-write A",
27
+ nodes: [
28
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
29
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
30
+ ],
31
+ edges: [{ from: "open", to: "cut" }],
32
+ };
33
+
34
+ /** A dedicated logical key for the supersede test, distinct from the first test's key so the case is
35
+ * SELF-CONTAINED — it stages BOTH its own V1 and V2 in-test and asserts per-logical-key, rather than
36
+ * depending on an earlier test having staged a predecessor. */
37
+ const SUPERSEDE_KEY = "S2 supersede key";
38
+
39
+ /** V1 for the supersede test — the predecessor that staging V2 must retire. */
40
+ const SUPERSEDE_V1 = {
41
+ name: SUPERSEDE_KEY,
42
+ nodes: [
43
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
44
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
45
+ ],
46
+ edges: [{ from: "open", to: "cut" }],
47
+ };
48
+
49
+ /** V2 — a STRUCTURALLY different graph (an extra node → different compiled BPMN → different content
50
+ * digest) sharing the SAME `name` (logical key) as V1, so staging it supersedes V1. The read after it
51
+ * must show the SECOND digest (the one the compile just returned), never the superseded first. */
52
+ const SUPERSEDE_V2 = {
53
+ name: SUPERSEDE_KEY,
54
+ nodes: [
55
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
56
+ { id: "notes", kind: "agent", agent: { jobType: "senior:demo", prompt: "draft the release notes" } },
57
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
58
+ ],
59
+ edges: [
60
+ { from: "open", to: "notes" },
61
+ { from: "notes", to: "cut" },
62
+ ],
63
+ };
64
+
65
+ interface StagedRow { digest: string; title: string | null }
66
+ interface ListBody { count: number; proposals: StagedRow[] }
67
+
68
+ /** Compile a graph over MCP and return the staged digest the door reports (asserting it staged). */
69
+ async function compileAndStage(h: McpHarness, graph: unknown): Promise<string> {
70
+ const res = await h.callTool("compileDeliveryGraph", { body: graph });
71
+ assertObjectBodyAccepted(res, "compileDeliveryGraph"); // the object body was NOT stringified
72
+ assert.ok(!res.isError, `compileDeliveryGraph must stage a valid graph: ${res.text}`);
73
+ const json = res.json as { status?: string; digest?: string } | undefined;
74
+ assert.equal(json?.status, "ready", `compileDeliveryGraph must report status:"ready": ${res.text}`);
75
+ assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
76
+ return json.digest;
77
+ }
78
+
79
+ /** The current live staged list, read over the SAME MCP surface. */
80
+ async function listStaged(h: McpHarness): Promise<ListBody> {
81
+ const res = await h.callTool("listStagedProposals", {});
82
+ assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
83
+ const json = res.json as ListBody | undefined;
84
+ assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
85
+ return json;
86
+ }
87
+
88
+ describe("S2 — listStagedProposals read-after-write is trustworthy (#608)", () => {
89
+ let h: McpHarness;
90
+ before(async () => { h = await bootMcpHarness(); });
91
+ after(async () => { await h.stop(); });
92
+
93
+ test("a digest compileDeliveryGraph just returned is listed on the very next call — no delay", async () => {
94
+ const digest = await compileAndStage(h, STAGES_A_PROPOSAL);
95
+ // The immediate read — no sleep, no retry, no poll — must show the write.
96
+ const list = await listStaged(h);
97
+ const digests = list.proposals.map((p) => p.digest);
98
+ assert.ok(
99
+ digests.includes(digest),
100
+ `the digest ${digest} compileDeliveryGraph just returned must appear in listStagedProposals ` +
101
+ `immediately (got ${JSON.stringify(digests)})`,
102
+ );
103
+ });
104
+
105
+ test("supersede keeps read-after-write honest: the read shows the LATEST returned digest, not the superseded one", async () => {
106
+ // SELF-CONTAINED: stage V1 then V2 for the SAME logical key inside this test — V2 supersedes V1. The
107
+ // read must reflect the digest THIS compile returned (the live one) and the superseded predecessor
108
+ // must be gone, with no dependence on any other test having staged first.
109
+ const digestV1 = await compileAndStage(h, SUPERSEDE_V1);
110
+ const digestV2 = await compileAndStage(h, SUPERSEDE_V2);
111
+ assert.notEqual(digestV1, digestV2, "V1 and V2 must differ in content so V2 genuinely supersedes V1");
112
+ const list = await listStaged(h);
113
+ const digests = list.proposals.map((p) => p.digest);
114
+ assert.ok(
115
+ digests.includes(digestV2),
116
+ `after superseding, listStagedProposals must show the latest digest ${digestV2} (got ${JSON.stringify(digests)})`,
117
+ );
118
+ assert.ok(
119
+ !digests.includes(digestV1),
120
+ `the superseded predecessor digest ${digestV1} must be gone from listStagedProposals (got ${JSON.stringify(digests)})`,
121
+ );
122
+ // Exactly one live proposal FOR THIS logical key — filter by title so the assertion is per-logical-key
123
+ // and does not couple to how many other proposals exist in the shared store.
124
+ const forLogicalKey = list.proposals.filter((p) => p.title === SUPERSEDE_KEY);
125
+ assert.equal(
126
+ forLogicalKey.length,
127
+ 1,
128
+ `exactly one live staged proposal must remain for the logical key ${JSON.stringify(SUPERSEDE_KEY)} (got ${JSON.stringify(forLogicalKey.map((p) => p.digest))})`,
129
+ );
130
+ assert.equal(forLogicalKey[0]?.digest, digestV2, "the sole remaining live proposal must be the latest digest");
131
+ });
132
+ });
package/openapi.yaml CHANGED
@@ -4029,11 +4029,25 @@ paths:
4029
4029
  operationId: listStagedProposals
4030
4030
  summary: List the LIVE staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
4031
4031
  description: >-
4032
- The read behind the staged-proposals App-View: every `staged` delivery-graph proposal that has
4033
- not aged out of its TTL, newest first, projected to the Preview-DI + Dispatch metadata (the
4034
- `graph`/`preview` payloads are omitted — the App-View recompiles by `digest` for the DI preview).
4035
- Mirrors the `previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-
4036
- yet-swept row is never listed. Read-only.
4032
+ The read behind the staged-proposals App-View AND the MCP `listStagedProposals` tool: every
4033
+ `staged` delivery-graph proposal that has not aged out of its TTL, newest first, projected to the
4034
+ Preview-DI + Dispatch metadata (the `graph`/`preview` payloads are omitted — the App-View
4035
+ recompiles by `digest` for the DI preview). Mirrors the
4036
+ `previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-yet-swept row
4037
+ is never listed. Read-only.
4038
+
4039
+
4040
+ FRESHNESS / CONSISTENCY (issue #608). This read is READ-AFTER-WRITE consistent with
4041
+ `compileDeliveryGraph`: it is served by a fresh query against the SAME durable
4042
+ `delivery_graph_proposals` store, in the SAME scope, that the compile/stage write commits to (the
4043
+ app's single default data source) — there is no projection, read-model, or cache between the
4044
+ write and this read. So a `digest` that `compileDeliveryGraph` just returned as `status: "ready"`
4045
+ is guaranteed to appear here on the very next call, with NO intervening delay and no polling
4046
+ needed (the row is committed before the compile door responds). The one caveat is scope: the MCP
4047
+ endpoint and the cockpit must resolve the SAME app deployment — point the MCP client at the same
4048
+ instance/mount whose `reviewUrl` the compile door returned, or the read and the write address
4049
+ different databases and disagree. Returns an empty list only when nothing is genuinely staged
4050
+ (or every staged row has aged out of its TTL).
4037
4051
  security:
4038
4052
  - hookSecret: []
4039
4053
  - {}
@@ -4050,6 +4064,262 @@ paths:
4050
4064
  application/json:
4051
4065
  schema:
4052
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"
4053
4323
  /actions/delivery-graph/library/save:
4054
4324
  post:
4055
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.159.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
+ });