@nanobpm/nano-workforce 0.200.1 → 0.200.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.200.2](https://github.com/nanobpm/nano-workforce/compare/v0.200.1...v0.200.2) (2026-10-03)
2
+
3
+ ### Documentation
4
+
5
+ * **agent-guide:** require repository on every delivery-graph agent node ([#851](https://github.com/nanobpm/nano-workforce/issues/851)) ([d6770ae](https://github.com/nanobpm/nano-workforce/commit/d6770ae2f6acc490e102aecaf0cab17ca5d10119)), closes [#850](https://github.com/nanobpm/nano-workforce/issues/850) [#850](https://github.com/nanobpm/nano-workforce/issues/850) [#505](https://github.com/nanobpm/nano-workforce/issues/505) [#850](https://github.com/nanobpm/nano-workforce/issues/850) [739/#776](https://github.com/739/nano-workforce/issues/776) [#505](https://github.com/nanobpm/nano-workforce/issues/505)
6
+
1
7
  ## [0.200.1](https://github.com/nanobpm/nano-workforce/compare/v0.200.0...v0.200.1) (2026-10-03)
2
8
 
3
9
  ### Performance Improvements
@@ -6,7 +6,9 @@
6
6
  // key sets are byte-identical to the closed sets in `app/deliveryGraph.ts` / `app/readiness.ts` /
7
7
  // `app/convergeTargets.ts` (AGENTS.md — "no drift surfaces").
8
8
  import assert from "node:assert/strict";
9
+ import { readFileSync } from "node:fs";
9
10
  import { test } from "node:test";
11
+ import { parse as parseYaml } from "yaml";
10
12
  import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
11
13
  import { DELIVERY_FACT_TYPES, DELIVERY_GUARD_SCALAR_TYPES, DELIVERY_NODE_KINDS } from "./deliveryGraph.ts";
12
14
  import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
@@ -100,3 +102,80 @@ test("fact-threading rule names the unbound-pr rejection", () => {
100
102
  const vocab = deliveryGraphVocabulary();
101
103
  assert.match(vocab.factThreading.rule, /unbound-pr/);
102
104
  });
105
+
106
+ // Resolve a node kind's per-kind CONFIG sub-schema (the authoritative OpenAPI contract for that
107
+ // kind's body) from the parsed spec. `DeliveryNode<Kind>` is `allOf: [DeliveryNodeCommon, { properties:
108
+ // { <configKey>: {…} } }]`, so find the allOf member carrying the `<configKey>` sub-schema and return
109
+ // that member's `<configKey>` VALUE. For most kinds that value is the inline config object; for the
110
+ // `wait` kind it is the `{ $ref: "#/components/schemas/ReadinessProbe" }` object (the config is
111
+ // referenced, not inlined), which this helper returns AS-IS — the caller follows the reference itself.
112
+ // Returns null only when the kind's node schema has NO inline `<configKey>` member at all (which the
113
+ // caller's assert.ok rejects), never for the `wait` kind.
114
+ function nodeConfigSchema(
115
+ spec: Record<string, any>,
116
+ nodeSchemaName: string,
117
+ configKey: string,
118
+ ): Record<string, any> | null {
119
+ const schema = spec?.components?.schemas?.[nodeSchemaName];
120
+ if (!schema) return null;
121
+ const sub = (schema.allOf as Array<Record<string, any>> | undefined)?.map((m) => m?.properties?.[configKey]).find(Boolean);
122
+ return sub ?? null;
123
+ }
124
+
125
+ /** Derive the (sorted) required + optional field sets from a config sub-schema's own `required`/`properties`. */
126
+ function fieldSets(configSchema: Record<string, any>): { required: string[]; optional: string[] } {
127
+ const required = [...((configSchema.required as string[] | undefined) ?? [])].sort();
128
+ const optional = Object.keys((configSchema.properties as Record<string, unknown>) ?? {})
129
+ .filter((k) => !required.includes(k))
130
+ .sort();
131
+ return { required, optional };
132
+ }
133
+
134
+ test("every node-kind entry's field sets are DERIVED from its OpenAPI DeliveryNode<Kind>.<configKey> contract (#739/#850)", () => {
135
+ // The #850 failure mode is drift between a `DeliveryNode<Kind>.<configKey>` (the authoritative
136
+ // OpenAPI contract) and this vocabulary surface. A literal field list duplicated here would NOT
137
+ // catch it: if OpenAPI adds or removes a config property, a hand-copied list stays green while the
138
+ // surface lies. So derive the required/optional sets from the PARSED spec and compare the sets
139
+ // directly — the vocabulary cannot silently drift from the contract it claims to mirror. This
140
+ // guards EVERY kind, not just `agent`: the connector entry omitting the real `timeout` field
141
+ // (PR #851 review) is the same drift class the agent-only guard missed.
142
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
143
+ const spec = parseYaml(readFileSync(`${ROOT}openapi.yaml`, "utf8")) as Record<string, any>;
144
+
145
+ const vocab = deliveryGraphVocabulary();
146
+ for (const entry of vocab.nodeKinds) {
147
+ const nodeSchemaName = `DeliveryNode${entry.kind[0]!.toUpperCase()}${entry.kind.slice(1)}`;
148
+ let configSchema = nodeConfigSchema(spec, nodeSchemaName, entry.configKey);
149
+ assert.ok(
150
+ configSchema,
151
+ `openapi.yaml components.schemas.${nodeSchemaName} must carry an inline '${entry.configKey}' config sub-schema`,
152
+ );
153
+ // The `wait` kind's config is `$ref: "#/components/schemas/ReadinessProbe"` — follow the reference.
154
+ if (typeof configSchema!.$ref === "string") {
155
+ const refName = configSchema!.$ref.replace(/^#\/components\/schemas\//, "");
156
+ configSchema = spec?.components?.schemas?.[refName];
157
+ assert.ok(configSchema?.properties, `${refName} (the wait config contract) must declare its properties`);
158
+ }
159
+ const { required, optional } = fieldSets(configSchema!);
160
+ assert.deepEqual(
161
+ [...entry.requiredFields].sort(),
162
+ required,
163
+ `${entry.kind}.requiredFields drifted from ${nodeSchemaName}.${entry.configKey}.required in openapi.yaml`,
164
+ );
165
+ assert.deepEqual(
166
+ [...entry.optionalFields].sort(),
167
+ optional,
168
+ `${entry.kind}.optionalFields drifted from ${nodeSchemaName}.${entry.configKey}'s optional properties in openapi.yaml`,
169
+ );
170
+ }
171
+
172
+ // The non-obvious rules an authoring agent must learn from the surface prose: an ABSENT repository
173
+ // is not compile-rejected (a run-level fallback can satisfy it) — it fails at the OPERATOR's
174
+ // Dispatch; and the per-node SLA timeout override exists.
175
+ const agent = vocab.nodeKinds.find((n) => n.kind === "agent");
176
+ assert.ok(agent, "agent node-kind entry must exist");
177
+ assert.match(agent.summary, /resolve to no repository/, "names the dispatch-time failure the author must pre-empt");
178
+ assert.match(agent.summary, /invalid-node-repository/, "names the compile-time validation for a present value");
179
+ assert.match(agent.summary, /repoless/, "names the checkout-less opt-out");
180
+ assert.match(agent.summary, /per-node ISO-8601 `timeout`/, "names the per-node SLA timeout override (#505)");
181
+ });
@@ -105,13 +105,29 @@ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
105
105
  agent: {
106
106
  configKey: "agent",
107
107
  requiredFields: ["jobType"],
108
- optionalFields: ["prompt", "converge", "merge"],
108
+ optionalFields: ["repository", "baseBranch", "prompt", "converge", "merge", "timeout"],
109
109
  sideEffecting: true,
110
110
  mayEmit: true,
111
111
  summary:
112
- "A worker runs an agent job type (the fan-out body, e.g. `senior:feature`). First-class " +
112
+ "A worker runs an agent job type (the fan-out body, e.g. `senior:feature`) in an isolated " +
113
+ "checkout. Per-node provisioning (#739/#776): declare `repository` (`owner/repo`, no `.git`/URL) " +
114
+ "on EVERY agent node — the harness clones it at `baseBranch` (omit it to use the run-level " +
115
+ "dispatch `baseBranch`, else the repo's default branch) and cuts `feat/<node.id>` off it " +
116
+ "(when that derived ref is a valid Git branch name; an id like `a..b`/`a.lock` yields an " +
117
+ "ill-formed ref, so the harness omits `branch.create` and the agent cuts its own branch). A node " +
118
+ "with no `repository` can only be satisfied by the run-level dispatch fallback, so COMPILE " +
119
+ "stages it without a warning; at Dispatch it resolves to the run-level `repository` + `baseBranch` " +
120
+ "fallback when the operator supplies one, and the Dispatch fails with `N agent node(s) " +
121
+ "resolve to no repository (…)` ONLY when neither the node nor the run supplies a repository " +
122
+ "(a present value IS compile-validated: `invalid-node-repository`/" +
123
+ "`invalid-node-base-branch`); a genuinely checkout-less graph must be dispatched `repoless: true`, " +
124
+ "which strips isolation from every node. First-class " +
113
125
  "`converge?`/`merge?` cell-policy flags declare review-convergence / landing intent (`merge` " +
114
126
  "requires `converge`); a raw `senior:converge`/`senior:merge` jobType is rejected (`raw-converge-node`). " +
127
+ "An optional per-node ISO-8601 `timeout` (#505) overrides the run-level `nodeTimeout` (and the " +
128
+ "`PT1H` default) for THIS node's bounded-timeout → escalate boundary, so a legitimately-long node " +
129
+ "(e.g. a full `senior:feature`) outlasts a quick gate without a spurious escalation; absent → the " +
130
+ "run/default value. " +
115
131
  "An `agent` that opens a PR emits it as a `pr`-typed fact so downstream connector/wait nodes late-bind it.",
116
132
  },
117
133
  wait: {
@@ -138,14 +154,16 @@ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
138
154
  connector: {
139
155
  configKey: "connector",
140
156
  requiredFields: ["target"],
141
- optionalFields: ["dedupeKey", "payload"],
157
+ optionalFields: ["dedupeKey", "payload", "timeout"],
142
158
  sideEffecting: true,
143
159
  mayEmit: true,
144
160
  summary:
145
161
  "An automated, side-effecting outbound action. `payload` for a converge target is " +
146
162
  "`{ pr, autoMerge?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
147
163
  "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.",
164
+ "(at-least-once safe). An optional per-node ISO-8601 `timeout` (#505) overrides the run-level " +
165
+ "`nodeTimeout` (and the `PT1H` default) for THIS node's bounded-timeout → escalate boundary; " +
166
+ "absent → the run/default value. See connectorTargets for which targets are real vs. forward-declared.",
149
167
  },
150
168
  };
151
169
 
@@ -564,10 +564,33 @@ layer schedules, it does not re-implement execution):
564
564
 
565
565
  | kind | config | what it does | may `emits`? |
566
566
  |---|---|---|---|
567
- | `agent` | `agent: { jobType, prompt?, converge?, merge? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** First-class **`converge?` / `merge?`** cell policy (§9.4) — a **declared, compiler-validated** completion-policy flag that *declares* review-convergence / landing intent (`merge` requires `converge`); a raw `senior:converge`/`senior:merge` job is rejected. This slice adds + validates the flags; the delivery-graph execution wiring that consumes them lands in a follow-up slice. | yes |
567
+ | `agent` | `agent: { jobType, repository?, baseBranch?, prompt?, converge?, merge?, timeout? }` | a worker runs an agent job type (the fan-out body) in a fresh checkout of its `repository` (see *Provision every agent node* below). **Side-effecting.** First-class **`converge?` / `merge?`** cell policy (§9.4) — a **declared, compiler-validated** completion-policy flag that *declares* review-convergence / landing intent (`merge` requires `converge`); a raw `senior:converge`/`senior:merge` job is rejected. This slice adds + validates the flags; the delivery-graph execution wiring that consumes them lands in a follow-up slice. **`timeout?`** is an OPTIONAL per-node ISO-8601 SLA (#505) that overrides the run-level `nodeTimeout` (and the `PT1H` default) for this node's bounded-timeout → escalate boundary, so a legitimately-long node outlasts a quick gate without a spurious escalation. | yes |
568
568
  | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. Read-only. | yes (binds observed facts) |
569
569
  | `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
570
- | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Three **real targets** ship today — **`converge`**, **`converge-merge`** (unit → base branch) and **`merge-main`** (graph → `main`, the two-level top-level land) (§9.4); other targets are a forward-declared stub. | yes |
570
+ | `connector` | `connector: { target, dedupeKey?, payload?, timeout? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Three **real targets** ship today — **`converge`**, **`converge-merge`** (unit → base branch) and **`merge-main`** (graph → `main`, the two-level top-level land) (§9.4); other targets are a forward-declared stub. **`timeout?`** — a per-node ISO-8601 SLA (#505), same semantics as the `agent` row. | yes |
571
+
572
+ > **Provision every `agent` node with a repository — compile will NOT catch it.** Each `agent` cell
573
+ > runs in an isolated checkout: the harness clones the node's **`repository`** (`owner/repo`, no
574
+ > `.git`/URL) at **`baseBranch`** and cuts `feat/<node.id>` off it (#739/#776). `baseBranch` resolves
575
+ > **per field** — the node's own, else the **run-level dispatch `baseBranch`**, else the repo default
576
+ > (an id yielding an ill-formed ref like `a..b`/`a.lock` makes the harness skip `branch.create`, so
577
+ > the agent cuts its own branch). A node with **no `repository`** is staged by compile **without a
578
+ > warning**, then — absent a **run-level fallback** supplied *at dispatch* — Dispatch fails with
579
+ > `N agent node(s) resolve to no repository (…)`. So:
580
+ >
581
+ > - **Declare `repository` on EVERY `agent` node** (and `baseBranch` when it is neither the run-level
582
+ > base nor the default): a single-repo graph repeats one value; a cross-repo graph names each node's
583
+ > own repo. Every repo a node's prompt changes must be that node's `repository`.
584
+ > - **`repoless` is a RUN-WIDE mode, not a per-node escape hatch** — it strips isolation from *every*
585
+ > node. Use it only when **every** `agent` node is checkout-less (pure research/comment work); if
586
+ > even one needs a checkout, provision repositories instead.
587
+ > - **Self-check before handing over the digest:** every `"kind": "agent"` node has a `repository`;
588
+ > if you leave some unset, tell the operator which, and that they must pick the run-level
589
+ > **repository + base-branch fallback** in the Dispatch form (the default mode rejects the graph).
590
+ >
591
+ > A **present** `repository`/`baseBranch` is validated at compile (`invalid-node-repository` /
592
+ > `invalid-node-base-branch`); an **absent `repository`** defers to dispatch (and can fail there); an
593
+ > **absent `baseBranch` is always valid** — it inherits the run-level base, else the repo default.
571
594
 
572
595
  A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
573
596
  intake uses): `{ kind, target, onTimeout?, match?, poll? }`, where `poll` is
@@ -720,12 +743,12 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
720
743
  "wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" },
721
744
  "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } },
722
745
  { "id": "undraft-merge-b", "kind": "agent",
723
- "agent": { "jobType": "senior:feature", "converge": true, "merge": true, "prompt": "Take draft PR acme/repo-2#202 out of draft; converge it to green and land it." } },
746
+ "agent": { "jobType": "senior:feature", "repository": "acme/repo-2", "converge": true, "merge": true, "prompt": "Take draft PR acme/repo-2#202 out of draft; converge it to green and land it." } },
724
747
  { "id": "manual-publish", "kind": "human",
725
748
  "human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
726
749
  "emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
727
750
  { "id": "open-pr-c", "kind": "agent",
728
- "agent": { "jobType": "senior:feature", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
751
+ "agent": { "jobType": "senior:feature", "repository": "acme/repo-3", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
729
752
  { "id": "merge-c", "kind": "wait",
730
753
  "wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" },
731
754
  "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } }
@@ -804,7 +827,7 @@ author never knows the PR number at compose time, so reference it by fact:
804
827
  "name": "open → converge+merge → wait merged",
805
828
  "nodes": [
806
829
  { "id": "open", "kind": "agent",
807
- "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." },
830
+ "agent": { "jobType": "senior:feature", "repository": "acme/repo", "prompt": "Implement the change in acme/repo and open a PR." },
808
831
  "emits": [ { "name": "pr", "type": "pr" } ] },
809
832
  { "id": "land", "kind": "connector",
810
833
  "connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
@@ -870,7 +893,7 @@ babysitting a `confirm` gate.
870
893
  "onTimeout": "escalate" },
871
894
  "emits": [ { "name": "prCount", "type": "number" } ] },
872
895
  { "id": "start-b", "kind": "agent",
873
- "agent": { "jobType": "senior:feature", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
896
+ "agent": { "jobType": "senior:feature", "repository": "nanobpm/nano-workforce", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
874
897
  ],
875
898
  "edges": [ { "from": "gate-epic", "to": "start-b" } ]
876
899
  }
package/openapi.yaml CHANGED
@@ -2025,6 +2025,7 @@ components:
2025
2025
  kind: agent
2026
2026
  agent:
2027
2027
  jobType: senior:feature
2028
+ repository: nanobpm/nano-workforce
2028
2029
  prompt: Implement nanobpm/nano-workforce#567 and open a PR.
2029
2030
  edges:
2030
2031
  - { from: gate-epic, to: start-b }
@@ -2406,7 +2407,7 @@ components:
2406
2407
  # cockpit paste). `previewDeliveryGraph` takes `{ "graphJson": "<serialized DeliveryGraph>" }`.
2407
2408
  example:
2408
2409
  graphJson: >-
2409
- {"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}
2410
+ {"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","repository":"nanobpm/nano-workforce","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}
2410
2411
  required:
2411
2412
  - graphJson
2412
2413
  properties:
@@ -5016,6 +5017,7 @@ paths:
5016
5017
  kind: agent
5017
5018
  agent:
5018
5019
  jobType: senior:feature
5020
+ repository: nanobpm/nano-workforce
5019
5021
  prompt: Implement nanobpm/nano-workforce#567 and open a PR.
5020
5022
  edges:
5021
5023
  - from: gate-epic
@@ -5467,7 +5469,7 @@ paths:
5467
5469
  type: object
5468
5470
  additionalProperties: false
5469
5471
  example:
5470
- graphJson: '{"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}'
5472
+ graphJson: '{"name":"start #567 once epic #488 has fully merged","nodes":[{"id":"gate-epic","kind":"wait","wait":{"kind":"epic","target":"nanobpm/nano-ide#488","match":{"epicState":"merged"},"poll":{"everyMs":300000,"timeoutMs":259200000},"onTimeout":"escalate"},"emits":[{"name":"prCount","type":"number"}]},{"id":"start-b","kind":"agent","agent":{"jobType":"senior:feature","repository":"nanobpm/nano-workforce","prompt":"Implement nanobpm/nano-workforce#567 and open a PR."}}],"edges":[{"from":"gate-epic","to":"start-b"}]}'
5471
5473
  required:
5472
5474
  - graphJson
5473
5475
  properties:
@@ -5876,10 +5878,10 @@ paths:
5876
5878
  - kind: agent
5877
5879
  configKey: agent
5878
5880
  requiredFields: [jobType]
5879
- optionalFields: [prompt, converge, merge]
5881
+ optionalFields: [repository, baseBranch, prompt, converge, merge, timeout]
5880
5882
  sideEffecting: true
5881
5883
  mayEmit: true
5882
- summary: "A worker runs an agent job type; an agent that opens a PR emits it as a `pr` fact."
5884
+ summary: "A worker runs an agent job type in an isolated checkout; declare `repository` (`owner/repo`) on every agent node — the harness clones it at `baseBranch` (else the run-level dispatch base, else the repo default). Optional per-node ISO-8601 `timeout`, plus `converge?`/`merge?` cell-policy flags. An agent that opens a PR emits it as a `pr` fact."
5883
5885
  - kind: wait
5884
5886
  configKey: wait
5885
5887
  requiredFields: [kind, target]
@@ -5890,7 +5892,7 @@ paths:
5890
5892
  - kind: connector
5891
5893
  configKey: connector
5892
5894
  requiredFields: [target]
5893
- optionalFields: [dedupeKey, payload]
5895
+ optionalFields: [dedupeKey, payload, timeout]
5894
5896
  sideEffecting: true
5895
5897
  mayEmit: true
5896
5898
  summary: "An automated outbound action; payload for a converge target is { pr, autoMerge?, dependsOn? }."
@@ -61,10 +61,15 @@ test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
61
61
  // The closed node vocabulary: assert the exact config snippet for each of the four kinds,
62
62
  // so the test fails if §9's node-kind table is removed or reworded — not merely if the bare
63
63
  // words "agent"/"wait"/"human"/"connector" appear anywhere else in the guide.
64
- assert(md.includes("agent: { jobType, prompt?, converge?, merge? }"), "documents the agent node config");
64
+ assert(md.includes("agent: { jobType, repository?, baseBranch?, prompt?, converge?, merge?, timeout? }"), "documents the agent node config");
65
+ // Per-node repository provisioning is only enforced at DISPATCH (a run-level fallback can satisfy it),
66
+ // so compile stages an unprovisioned graph silently — the guide must tell the authoring agent to
67
+ // declare `repository` on every agent node, or the operator's Dispatch fails out of the agent's loop.
68
+ assert(md.includes("Provision every `agent` node with a repository"), "documents per-node repository provisioning");
69
+ assert(md.includes("resolve to no repository"), "quotes the dispatch-time failure the agent must pre-empt");
65
70
  assert(md.includes("wait: <ReadinessProbe>"), "documents the wait node config");
66
71
  assert(md.includes("human?: { formKey?, prompt? }"), "documents the human node config");
67
- assert(md.includes("connector: { target, dedupeKey?, payload? }"), "documents the connector node config");
72
+ assert(md.includes("connector: { target, dedupeKey?, payload?, timeout? }"), "documents the connector node config");
68
73
  // The fact-edge syntax: an edge is `{ from, to }` and `from` may be a qualified `<nodeId>.<fact>`.
69
74
  assert(md.includes("each edge is `{ from, to }`"), "documents the edge shape");
70
75
  assert(md.includes("qualified `<nodeId>.<fact>`"), "documents the qualified fact-edge syntax");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.200.1",
3
+ "version": "0.200.2",
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",
@@ -60,3 +60,35 @@ test("the served payload matches the response schema's required keys (data ⇄ c
60
60
  assert(key in payload, `served vocabulary is missing required schema key '${key}'`);
61
61
  }
62
62
  });
63
+
64
+ test("the worked example's nodeKind field contract cannot drift from the runtime vocabulary", () => {
65
+ // The spec's `example:` is a hand-authored YAML copy of the served payload (the runtime cannot be
66
+ // called from a static spec). It is a deliberately curated SUBSET (fewer kinds / terser summaries),
67
+ // but the per-kind FIELD CONTRACT it advertises is consumer-facing: a stale `optionalFields` makes
68
+ // agents author nodes that omit real fields (#739/#776 — `repository`/`baseBranch`/`timeout`), and a
69
+ // stale `configKey`/`sideEffecting`/`mayEmit` misleads them the same way. Guard the class, not one
70
+ // field: every nodeKind the example shows must carry EXACTLY the runtime kind's whole contract object
71
+ // — `kind`, `configKey`, `requiredFields`, `optionalFields`, `sideEffecting`, `mayEmit` — so no part
72
+ // of the two surfaces can drift apart again. Only `summary` is excluded: the example's is a
73
+ // deliberately terser curated paraphrase of the runtime's (see this block's preamble).
74
+ const doc = parseYaml(SPEC_TEXT) as Record<string, any>;
75
+ const exampleKinds = doc.paths["/delivery-graph/vocabulary"].get.responses["200"].content["application/json"].schema
76
+ .example?.nodeKinds as Array<Record<string, unknown>> | undefined;
77
+ assert(Array.isArray(exampleKinds) && exampleKinds.length > 0, "the worked example must list nodeKinds");
78
+
79
+ // Compare the full per-kind contract minus `summary` (the sole intentionally-divergent field).
80
+ const contract = ({ summary: _summary, ...rest }: Record<string, unknown>): Record<string, unknown> => rest;
81
+
82
+ const runtimeByKind = new Map(
83
+ (deliveryGraphVocabulary().nodeKinds as Array<Record<string, unknown>>).map((k) => [k.kind, k]),
84
+ );
85
+ for (const shown of exampleKinds) {
86
+ const runtime = runtimeByKind.get(shown.kind);
87
+ assert(runtime, `example nodeKind '${String(shown.kind)}' is not a real runtime kind (stale/removed)`);
88
+ assert.deepEqual(
89
+ contract(shown),
90
+ contract(runtime!),
91
+ `example nodeKind '${String(shown.kind)}' field contract (configKey/requiredFields/optionalFields/sideEffecting/mayEmit) drifted from runtime`,
92
+ );
93
+ }
94
+ });