@nanobpm/nano-workforce 0.202.0 → 0.203.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +48 -0
  3. package/app/admittedStaging.test.ts +71 -0
  4. package/app/agentActivationLedger.ts +5 -0
  5. package/app/agentCompletion.test.ts +33 -0
  6. package/app/agentCompletion.ts +108 -2
  7. package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
  8. package/app/contracts.ts +23 -0
  9. package/app/deliveryGraphDispatch.ts +6 -0
  10. package/app/deliveryGraphReadModel.test.ts +2 -2
  11. package/app/deliveryGraphRun.ts +18 -0
  12. package/app/deliveryRunner.ts +9 -1
  13. package/app/escalationSteward.test.ts +452 -0
  14. package/app/escalationSteward.ts +537 -0
  15. package/app/escalationStewardPoll.test.ts +123 -0
  16. package/app/feature.test.ts +16 -0
  17. package/app/feature.ts +11 -0
  18. package/app/featureDelivery.test.ts +23 -0
  19. package/app/mcpToolSurface.ts +17 -1
  20. package/app/plan.ts +49 -6
  21. package/app/planLowering.test.ts +26 -1
  22. package/app/planLowering.ts +5 -0
  23. package/app/promotionPoll.test.ts +19 -0
  24. package/app/pullRequestReadModel.test.ts +1 -1
  25. package/app/service.test.ts +36 -0
  26. package/app/service.ts +139 -5
  27. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  28. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  29. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  30. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  31. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  32. package/docs/agent-guide.md +280 -814
  33. package/docs/mcp-runbook.md +2 -0
  34. package/e2e/escalation-steward.e2e.ts +211 -0
  35. package/nano.app.json +10 -1
  36. package/openapi.yaml +365 -0
  37. package/operations/dispatchDeliveryGraph.ts +6 -1
  38. package/operations/listActivePrs.test.ts +60 -1
  39. package/operations/listActivePrs.ts +3 -3
  40. package/operations/listAutoResolutions.ts +83 -0
  41. package/operations/reviewAutoResolution.test.ts +137 -0
  42. package/operations/reviewAutoResolution.ts +42 -0
  43. package/operations/startConvergenceLoop.ts +5 -1
  44. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  45. package/operations/startEpicSet.ts +8 -2
  46. package/operations/startFeature.ts +5 -0
  47. package/operations/startPlanFanout.ts +4 -1
  48. package/package.json +1 -1
  49. package/pages/_nav.json +12 -0
  50. package/pages/auto-resolutions.page.json +191 -0
  51. package/pages/board.page.json +17 -0
  52. package/pages/cockpit.page.json +17 -0
  53. package/pages/delivery-graph-detail.page.json +17 -0
  54. package/pages/delivery-graphs/staged.mount.js +17 -5
  55. package/pages/delivery-graphs.page.json +17 -0
  56. package/pages/epic-detail.page.json +17 -0
  57. package/pages/epic.page.json +19 -1
  58. package/pages/feature.page.json +19 -1
  59. package/pages/home.page.json +22 -0
  60. package/pages/lineage.page.json +17 -0
  61. package/pages/mcp.page.json +17 -0
  62. package/pages/overview.page.json +17 -0
  63. package/pages/tasks.page.json +17 -0
  64. package/pages/velocity.page.json +17 -0
  65. package/resources/processes/escalation-steward.bpmn +162 -0
  66. package/resources/prompts/escalation-steward.md +87 -0
  67. package/skills/nano-workforce/SKILL.md +20 -65
  68. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  69. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  70. package/test/derivation-parity/flows.ts +12 -0
  71. package/workers/converge-feature/worker.ts +6 -1
  72. package/workers/delivery-connector/worker.test.ts +72 -1
  73. package/workers/delivery-connector/worker.ts +53 -2
  74. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  75. package/workers/escalation-steward-collect/worker.ts +99 -0
  76. package/workers/escalation-steward-record/worker.test.ts +740 -0
  77. package/workers/escalation-steward-record/worker.ts +428 -0
  78. package/workers/record-wave/worker.ts +5 -1
@@ -1,317 +1,161 @@
1
1
  # Nano Workforce — agent operator guide
2
2
 
3
- You are an AI assistant helping a human operate a running **Nano Workforce**
4
- instance. Nano Workforce is a durable orchestration app (a [Nano](https://nanobpm.io)
5
- Urban app) that drives pull requests to **review convergence** against an automated
6
- reviewer, then **merges** them, and can take a whole issue and **plan → implement →
7
- converge** it across a fleet of coding agents.
8
-
9
- This document is served live by the running app so you always match the deployed
10
- version. Use it to **drive** the workforce (submit work, answer escalations) and to
11
- **debug** it (find stuck instances, relate them to PRs, inspect the models/prompts,
12
- and unstick or report problems).
3
+ You are helping a human operate a running **Nano Workforce** instance: a durable
4
+ orchestration app that drives pull requests to **review convergence** and **merges** them,
5
+ takes whole issues through **plan → implement → converge** across a fleet of coding agents,
6
+ and runs agent-authored **delivery graphs**.
13
7
 
14
8
  - **App control API base:** `__BASE__`
15
- - **Engine (Nano/Camunda-8 v2 REST) base:** `__ENGINE__`
9
+ - **Engine (Camunda-8 v2 REST) base:** `__ENGINE__`
16
10
  - **Source repository:** `nanobpm/nano-workforce`
17
11
 
18
- Everything below assumes the app control API is reachable at `__BASE__`. Most app
19
- endpoints are mounted under that base (ADR 0059), but a few siblings sit outside it —
20
- notably the interactive docs (Swagger UI) at `__BASE__/../api-docs` and the action
21
- endpoints (e.g. the cancel action at `/app/actions/cancel`). Paths below are written
22
- in full so you can tell which are under the control-API base and which are not.
12
+ **Tools first, curl as fallback.** Over MCP, call the projected tool named in each
13
+ recipe. Without MCP, use the curl door in the §10 crosswalk. If the deployment sets
14
+ `NANO_PR_WEBHOOK_SECRET`, add `-H "x-hook-secret: <secret>"` to **every** request — the
15
+ read endpoints (`/version`, `/status`, `/escalations`, `/agent`, `/agent/guide`, …) are
16
+ guarded too, not only the POSTs.
23
17
 
24
18
  ---
25
19
 
26
20
  ## 0. Orient yourself first
27
21
 
28
- Before acting, confirm what is running and what is in flight. **Prefer the projected
29
- tools** (`getVersion`, `listActivePrs`); curl is the no-MCP fallback only. Every step in
30
- this guide that has a tool is mapped in the **tool↔curl crosswalk (§10)** — reach for the
31
- tool first and drop to curl only when you are not driving this instance over MCP.
32
-
33
- ```text
34
- # Which code is live (app version, urban version, git sha/branch, uptime):
35
- tool: getVersion # GET /version
36
-
37
- # Every PR currently in flight (not converged/abandoned), with its engine
38
- # process key, status, round, and any open escalation:
39
- tool: listActivePrs # GET /status
40
- ```
22
+ Confirm what is running and what is in flight before you act.
41
23
 
42
24
  ```bash
43
- # No-MCP fallback (agent without the projected tools):
44
- curl -sS __BASE__/version | jq
45
- curl -sS __BASE__/status | jq
25
+ curl -sS __BASE__/version | jq # tool: getVersion: app/urban version, git sha, uptime
26
+ curl -sS __BASE__/status | jq # tool: listActivePrs: every PR in flight
46
27
  ```
47
28
 
48
- `listActivePrs` (the `/status` endpoint) is your primary situational-awareness surface.
49
- Each entry carries:
50
- `prKey` (`owner/repo#123`), `status`, `round`, `processKey` (the **engine process
51
- instance key** — the bridge to the engine REST API, §5), `openEscalation`,
52
- `activeWorker`/`leaseUntil` (is an agent actually working the round, or is the job
53
- just queued), and `updatedAt`.
29
+ Each `/status` entry carries `prKey` (`owner/repo#123`), `status`, `round`, `processKey`
30
+ (the **engine process-instance key**, the bridge to §5), `openEscalation`,
31
+ `activeWorker`/`leaseUntil` (an agent holds the round, or the job is just queued), and
32
+ `updatedAt`.
54
33
 
55
34
  ---
56
35
 
57
36
  ## 1. Submit a PR for review convergence
58
37
 
59
- One PR → one durable `convergence-loop`. Each round dispatches a `senior:pr-review`
60
- agent; between rounds the process parks on a durable message-catch, so review latency
61
- never holds an agent slot. The app's poller watches GitHub and republishes
62
- `review-ready` when a new review lands.
38
+ One PR → one durable `convergence-loop`. Each round runs a `senior:pr-review` agent. Between
39
+ rounds the process waits durably for the next review (the poller republishes `review-ready`),
40
+ so review latency never holds an agent slot.
63
41
 
64
42
  ```bash
65
- # Minimal: converge, then merge (if NANO_PR_AUTO_MERGE is on — the default).
43
+ # tool: startConvergenceLoop
66
44
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
67
45
  -H 'content-type: application/json' \
68
- -d '{ "pr": "owner/repo#123" }'
46
+ -d '{ "pr": "owner/repo#42", "autoMerge": true, "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
69
47
  ```
70
48
 
71
- The body is flat. Fields:
72
-
73
49
  | field | type | meaning |
74
50
  |---|---|---|
75
- | `pr` (or `url`) | string | the PR — `owner/repo#123` or a full PR URL. Required. |
76
- | `autoMerge` | boolean | **Positive per-request setting.** `false` = review only, stopping at `converged`, regardless of the global default; `true` = converge **then merge** when `NANO_PR_AUTO_MERGE` is enabled. |
77
- | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
78
- | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
79
-
80
- **Converge-only vs. converge-and-merge — choose deliberately:**
81
-
82
- ```bash
83
- # Review only — do NOT merge (use when the human wants to merge by hand,
84
- # or is only after a review pass):
85
- curl -sS -X POST __BASE__/actions/start/convergence-loop \
86
- -H 'content-type: application/json' \
87
- -d '{ "pr": "owner/repo#123", "autoMerge": false }'
51
+ | `pr` (or `url`) | string | `owner/repo#123` or a PR URL. Required. |
52
+ | `autoMerge` | boolean | `false` = review only, stop at `converged`. `true` = converge **then merge**. **Omitted = merge** (the merge path is the default). Merging also requires `NANO_PR_AUTO_MERGE` on (the default); a per-request value only ever narrows to review-only, it never forces a merge when the global switch is off. Choose deliberately — to guarantee review-only, set `autoMerge: false` explicitly. |
53
+ | `maxRounds` | integer | round cap before escalating (1–100; default `NANO_PR_MAX_ROUNDS`, 20). |
54
+ | `dependsOn` | string[] | `prKey`s that must land before this one merges. |
55
+ | `autoResolveEscalations` | boolean | opt in to the escalation steward (§3). Default `false`. |
88
56
 
89
- # Converge then merge, with a dependency barrier and a tighter round cap:
90
- curl -sS -X POST __BASE__/actions/start/convergence-loop \
91
- -H 'content-type: application/json' \
92
- -d '{ "pr": "owner/repo#42", "autoMerge": true, "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
93
- ```
94
-
95
- Submitting is **idempotent on the PR key** — re-POSTing the same PR refreshes the
96
- aggregate rather than starting a duplicate loop. The response (202) echoes the
97
- `prKey` and the engine `processKey`.
57
+ Idempotent on the PR key: re-POSTing refreshes the record instead of starting a second
58
+ loop. The 202 response echoes `prKey` and `processKey`.
98
59
 
99
60
  ---
100
61
 
101
62
  ## 2. Submit an epic (plan → implement → converge)
102
63
 
103
- Hand the fleet a whole issue. A planning agent decomposes it into levelized tasks; a
104
- parallel fan-out drives one implementation agent per task (one PR each); every opened
105
- PR is then enrolled into its own convergence loop (§1).
64
+ A planning agent splits an issue into levelled tasks. One implementation agent per task
65
+ opens a PR, and every PR is enrolled in its own convergence loop (§1).
106
66
 
107
67
  ```bash
68
+ # tool: startPlanFanout
108
69
  curl -sS -X POST __BASE__/actions/start/plan-fanout \
109
70
  -H 'content-type: application/json' \
110
71
  -d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
111
- ```
112
72
 
113
- The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL — plus a REQUIRED
114
- `baseBranch` (ADR 0003), the branch the fleet branches off and opens every PR against; a
115
- blank/absent base is rejected with a 400. Starting a
116
- plan is idempotent on the plan key; an already-running plan short-circuits. The
117
- response (202) echoes the `planKey` and engine `processKey`.
118
-
119
- **Single issue, one PR (feature run, `startFeature`)** — no planning or fan-out. Same body and
120
- base-branch admission, plus `converge` (enrol the PR in §1) and `autoMerge`:
121
-
122
- ```bash
73
+ # Single issue → one PR, no planning (tool: startFeature). Adds `converge` and `autoMerge`.
123
74
  curl -sS -X POST __BASE__/actions/start/feature \
124
75
  -H 'content-type: application/json' \
125
76
  -d '{ "issue": "owner/repo#123", "baseBranch": "main", "confirmDefaultBase": true, "converge": true, "autoMerge": true }'
126
77
  ```
127
78
 
128
- ### Base-branch admission (ADR 0003)
129
-
130
- `startPlanFanout` admits the base through one fail-fast gate before any task fans out.
131
- Four ordered rules govern which base is accepted:
132
-
133
- 1. **Required + explicit.** `baseBranch` is mandatory — a blank/absent value is a `400`
134
- (`MissingBaseBranchError`), and an implausible name is a `400` (`InvalidBaseBranchError`).
135
- There is no silent "land on the default branch" fallback.
136
- 2. **Create-if-missing, `epic/*` only.** A missing `epic/*` base is **auto-created** off the
137
- repository default branch's HEAD (idempotently — an existing branch is never reset). A
138
- missing base that is **not** `epic/*` is a clean `400` (`BaseBranchMustExistError`): a typo
139
- can't silently spawn a wrong-rooted branch, so any non-`epic/*` base must already exist.
140
- 3. **Confirm-default.** Naming the repository **default branch** as the base requires
141
- `confirmDefaultBase: true`, else `400` (`DefaultBaseNotConfirmedError`). This is a
142
- deliberate acknowledgement that every task lands directly on the default branch with no
143
- integration buffer, and any merge-to-default side effect fires per task.
144
- 4. **Shared-base guard.** If another **active** epic (status not `done`/`failed`/`abandoned`)
145
- already targets the **same repo + same custom base**, admission is a `409` (`SharedBaseError`)
146
- unless you pass `allowSharedBase: true`. The default branch is exempt — many epics target it
147
- concurrently without colliding.
148
-
149
- So the body may also carry two optional booleans — `confirmDefaultBase` and `allowSharedBase` —
150
- each a "warn you can't skip" for its rule:
79
+ Idempotent on the plan key. The 202 response echoes `planKey` and `processKey`, and the
80
+ plan's PRs appear in `/status` as ordinary convergence loops. Both doors accept
81
+ `autoResolveEscalations` (default `false`), and it propagates to every child the run spawns.
151
82
 
152
- ```bash
153
- curl -sS -X POST __BASE__/actions/start/plan-fanout \
154
- -H 'content-type: application/json' \
155
- -d '{ "issue": "owner/repo#123", "baseBranch": "main", "confirmDefaultBase": true }'
156
- ```
83
+ **Base-branch admission.** `baseBranch` is checked before anything fans out:
84
+
85
+ 1. **Required.** Blank or absent → `400 MissingBaseBranchError`. An implausible name →
86
+ `400 InvalidBaseBranchError`. There is no silent default.
87
+ 2. **Only `epic/*` is created if missing** (off the default branch's HEAD; an existing branch
88
+ is never reset). Any other missing base → `400 BaseBranchMustExistError`.
89
+ 3. **The repo default branch needs `confirmDefaultBase: true`**, else
90
+ `400 DefaultBaseNotConfirmedError`, because every task then lands straight on it.
91
+ 4. **Shared base.** Another active epic on the same repo and custom base →
92
+ `409 SharedBaseError`, unless you pass `allowSharedBase: true`. The default branch is exempt.
157
93
 
158
- Grandfathered: in-flight plans launched before this admission gate (with a `null` base branch)
159
- keep running unchanged — the requirement is enforced at admission of **new** launches, not by a
160
- database constraint.
161
-
162
- Track a plan the same way you track PRs — its `process_key` is an engine instance you
163
- can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
164
- loops.
165
-
166
- ### Build hygiene — implementation agents warm up once, then build module-scoped
167
-
168
- Implementation and CI-fix agents run on **stateless / ephemeral workers**: the
169
- workspace, including its build outputs, is thrown away between runs, so each worker pays any
170
- cold whole-reactor build tax independently. To keep iterations fast, the implementation
171
- prompts (`resources/prompts/feature.md`, `resources/prompts/fix-ci.md`) instruct every
172
- agent to, **before iterating on a build in the target repo**:
173
-
174
- 1. **Read the target repo's own `AGENTS.md` / `CONTRIBUTING.md` build section** — the
175
- recipe is authored there, never duplicated into nano-workforce (a per-repo copy here
176
- would rot as the target's build changes).
177
- 2. **Run the prescribed dependency warm-up once**, right after checkout. For a Maven
178
- monorepo that ships the fast path (e.g. camunda), that is
179
- `./mvnw install -Dquickly -T1C` — it installs every reactor SNAPSHOT into `~/.m2`.
180
- 3. **Then build only the changed module, offline, without `-am`** —
181
- `./mvnw -Dquickly -o -pl <module> <goals>`. Never run a cold `-am` reactor build per
182
- iteration.
183
-
184
- The repo-agnostic infra levers behind this — a host-persisted `~/.m2` and a shared
185
- remote Maven build cache for JVM-capable worker hosts — live in worker-host config, not
186
- in per-repo files here. The retro loop (`resources/prompts/retro.md`) promotes any
187
- durable "warm the cache, then build module-scoped" lesson into the *target repo's*
188
- `AGENTS.md`, keeping each target's recipe sharp without nano-workforce owning it.
94
+ **Build hygiene.** Workers are ephemeral, so implementation prompts (`feature.md`,
95
+ `fix-ci.md`) tell agents to read the target repo's `AGENTS.md`/`CONTRIBUTING.md` build
96
+ recipe, run its dependency warm-up once (e.g. camunda: `./mvnw install -Dquickly -T1C`),
97
+ then build only the changed module offline (`./mvnw -Dquickly -o -pl <module> <goals>`),
98
+ never a cold `-am` reactor build per iteration. The recipe lives in the target repo, not here.
189
99
 
190
100
  ---
191
101
 
192
102
  ## 3. Answer escalations (unblock a human-in-the-loop wait)
193
103
 
194
- A loop escalates only when an agent returns `needs_input`/`blocked`, or a safety net
195
- fires (round cap, a review that never arrives, a merge conflict, an unfixable CI
196
- failure). The parked process waits for a human answer.
197
-
198
- Find the open escalations, then answer them. **Prefer the `listEscalations` tool**
199
- (the projected read tool that lists every open escalation with the `userTaskKey` and form —
200
- tracked by sibling #666; on a deployment that predates it, use the `/status` / task-inbox
201
- curl fallback below —
202
- you need to answer it); curl is the no-MCP fallback.
203
-
204
- ```text
205
- # Every open escalation waiting for a human, across all four kinds — each with its
206
- # userTaskKey, prKey, kind (elementId), question/findings/task, and formVariables:
207
- tool: listEscalations
208
- ```
104
+ A loop escalates when an agent returns `needs_input`/`blocked`, or when a safety net fires
105
+ (round cap, a review that never arrives, a merge conflict, unfixable CI). The process parks
106
+ on a native BPMN user task until someone answers.
209
107
 
210
108
  ```bash
211
- # No-MCP fallback: read the open escalations off /status …
212
- curl -sS __BASE__/status | jq '.prs[] | select(.openEscalation != null)
213
- | { prKey, status, round, openEscalation }'
109
+ # tool: listEscalations: every open escalation with userTaskKey, kind, subject, question
110
+ curl -sS __BASE__/escalations | jq
214
111
  ```
215
112
 
216
- The four decision-required escalation kinds — **PR review-loop**, **implementation
217
- (feature) task**, **plan-review**, and **trial-merge** — are now native BPMN
218
- `userTask`s bearing a linked `.form`, all answered the same way through the
219
- `completeUserTask` tool (a human uses the same **task inbox** surface underneath).
220
- There is no bespoke per-kind webhook or answer page any more.
113
+ `/status` also carries a structured `openEscalation` pointer — `{ userTaskKey, kind, summary }`
114
+ (or `null`) — so you can answer straight from it. `listEscalations` remains the fuller
115
+ task-context surface (subject, question, findings). The raw task inbox is
116
+ `__BASE__/../../tasks/api/tasks`, and its UI for humans is at `__BASE__/../../tasks`.
221
117
 
222
- **List the open escalation tasks.** `listEscalations` returns each open task with its
223
- context (`prKey` / `question` / `findings` / `task`) and its kind in `elementId` /
224
- `kind`, so you do **not** need to reach for the raw Tasks inbox. No-MCP fallback only:
118
+ **Answer** through the one door, `completeUserTask`, with the variables the task's kind
119
+ expects. Answering as an agent assignee? Use `agentCompleteEscalation` instead: it records
120
+ agent identity for attribution.
225
121
 
226
- ```bash
227
- # No-MCP fallback — the un-projected engine task inbox (prefer `listEscalations`):
228
- curl -sS __BASE__/../../tasks/api/tasks | jq '.[] | { userTaskKey, elementId, variables }'
229
-
230
- # Filter to one kind (e.g. plan-review decisions) by elementId:
231
- curl -sS __BASE__/../../tasks/api/tasks \
232
- | jq '[.[] | select(.elementId == "plan-review-decision")]'
233
- ```
234
-
235
- The inbox UI is also served at `__BASE__/../../tasks` for a human to browse, filter, and
236
- answer (assignee/candidate-group and age surface on each task once assignment lands).
237
-
238
- > **`wait`-gate escalations are different — completing one does NOT re-arm the gate.** The
239
- > escalation kinds above resume a loop with your answer. A **delivery-graph `wait` node**
240
- > (§9) that elapses its bound also parks a task, but completing *that* one releases the token
241
- > **as not-ready** and the graph proceeds **past the gate** — the downstream side-effecting
242
- > node then runs against the unmet dependency. See §9.2 before clearing one.
243
-
244
- **Answer a task** by completing it with the typed variables its form expects — the
245
- completion resumes the parked process. **Prefer the `completeUserTask` tool** (or
246
- `agentCompleteEscalation` when you answer as an agent assignee, ADR 0046); the
247
- `curl … /actions/complete-user-task` app-owned door is the no-MCP fallback.
248
-
249
- ```text
250
- # PR review-loop (elementId `wait-answer`, pr-escalation form):
251
- tool: completeUserTask { "userTaskKey": "<key>",
252
- "variables": { "answer": "Cap retries at 5 and proceed." } }
253
-
254
- # Implementation (feature) task (elementId `feature-escalation`):
255
- # { "resolution": "answer", "answer": "…" } to resume, or { "resolution": "abandon" }
256
- tool: completeUserTask { "userTaskKey": "<key>",
257
- "variables": { "resolution": "answer", "answer": "Use v2." } }
258
-
259
- # Plan-review (elementId `plan-review-decision`):
260
- # { "directive": "revise", "notes": "…" } (fresh review budget) or { "directive": "proceed" }
261
- tool: completeUserTask { "userTaskKey": "<key>",
262
- "variables": { "directive": "revise", "notes": "Make issue-7 the seam." } }
263
-
264
- # Trial-merge (elementId `trial-merge-decision`):
265
- # { "action": "proceed" | "rebase" | "abandon", "notes"?: "…" }
266
- tool: completeUserTask { "userTaskKey": "<key>",
267
- "variables": { "action": "rebase", "notes": "Re-run after the fix." } }
268
- ```
122
+ | kind (`elementId`) | `variables` |
123
+ |---|---|
124
+ | PR review loop (`wait-answer`) and merge loop (`wait-merge-answer`) | `{ "answer": "…" }` |
125
+ | Implementation task (`feature-escalation`) | `{ "resolution": "answer", "answer": "…" }` or `{ "resolution": "abandon" }` |
126
+ | Plan review (`plan-review-decision`) | `{ "directive": "revise", "notes": "…" }` (fresh review budget) or `{ "directive": "proceed" }` |
127
+ | Trial merge (`trial-merge-decision`) | `{ "action": "rebase", "notes": "…" }`; `action` is `proceed`, `rebase` or `abandon` (`notes` optional) |
269
128
 
270
129
  ```bash
271
- # No-MCP fallback — the app-owned complete-user-task door (prefer the tool).
272
- # `variables` must carry the shape THIS task's escalation kind expects (see the per-kind
273
- # tool forms above): `{ answer }` here is the PR review-loop form — a feature task wants
274
- # `{ resolution, answer }`, plan-review `{ directive, notes }`, trial-merge `{ action, notes? }`.
130
+ # tool: completeUserTask
275
131
  curl -sS -X POST __BASE__/actions/complete-user-task -H 'content-type: application/json' \
276
132
  -d '{ "userTaskKey": "<key>", "variables": { "answer": "Cap retries at 5 and proceed." } }'
277
133
  ```
278
134
 
279
- **Answer a PR escalation** (both the review-loop `wait-answer` and the merge-loop
280
- `wait-merge-answer` — both are now native user tasks answered the same way, #256).
281
- Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }`
282
- through the `completeUserTask` tool; curl is the no-MCP fallback:
135
+ The answer becomes the agent's next-round context and the loop resumes. Read the
136
+ `question`/`findings` first and give the smallest answer that unblocks it. The history is
137
+ durable: completed user tasks, plus the `escalations` table shown per PR on the Convergence page.
283
138
 
284
- ```text
285
- tool: completeUserTask { "userTaskKey": "<key>",
286
- "variables": { "answer": "Yes — cap the retries at 5 and proceed." } }
287
- ```
288
-
289
- ```bash
290
- # No-MCP fallback:
291
- curl -sS -X POST __BASE__/actions/complete-user-task \
292
- -H 'content-type: application/json' \
293
- -d '{
294
- "userTaskKey": "<key>",
295
- "variables": { "answer": "Yes — cap the retries at 5 and proceed." }
296
- }'
297
- ```
139
+ > **A delivery-graph `wait` escalation is different.** Completing it releases the gate
140
+ > **as not-ready**, so the downstream work runs against the unmet dependency. Read §9.2
141
+ > before clearing one.
298
142
 
299
- The `userTaskKey` comes from `listEscalations` (or `GET /status`/the Tasks inbox). This
300
- is the ONE canonical answer door (`completeUserTask`) for every escalation kind — with
301
- `agentCompleteEscalation` the agent-assignee variant that records identity for
302
- attribution (ADR 0046); the merge loop no longer uses a durable `escalation-answered`
303
- message catch.
143
+ ### The escalation steward (opt-in auto-resolution)
304
144
 
305
- If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
145
+ Work submitted with `autoResolveEscalations: true` has its open escalations reviewed by the
146
+ **steward** every 30 minutes (`NANO_ESCALATION_STEWARD_INTERVAL`; `NANO_ESCALATION_STEWARD=off`
147
+ disables it). The steward submits the recommended choice through `completeUserTask` with
148
+ **agent** attribution. It is conservative:
306
149
 
307
- The answer is delivered to the agent as its next-round context (e.g. the `answer`,
308
- `directive`, or `action` variable), and the loop resumes. **Audit** is durable: the
309
- completed user tasks form the escalation history, and the review/merge loops' rows
310
- live in the `escalations` table (surfaced read-only per PR on the Convergence page).
150
+ - It waits a minimum age, so a human already answering wins.
151
+ - It never touches human-only kinds: merge approval, permission prompts, readiness gates,
152
+ blocked/conformance acks, abandon.
153
+ - It **abstains** when the right choice is unclear, or when a subject has already been
154
+ auto-resolved twice and escalated again.
311
155
 
312
- Guidance for the human you assist: read the escalation `question` or plan-review
313
- `findings` first, decide the smallest unblocking answer, and answer it precisely —
314
- the answer becomes the agent's next-round context.
156
+ Every resolution and abstention is recorded for review in **Auto-resolutions** (nav badge =
157
+ pending count). Read with `listAutoResolutions` (`GET __BASE__/auto-resolutions`), review with
158
+ `reviewAutoResolution` (`{ id, action: "dismiss" | "intervene", note? }`).
315
159
 
316
160
  ---
317
161
 
@@ -326,178 +170,89 @@ the answer becomes the agent's next-round context.
326
170
  blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
327
171
  ```
328
172
 
329
- PR `status` values you will see in `/status`:
330
- `converging` (a review round is live), `waiting_review` (parked for a fresh review),
331
- `escalated` (waiting on a human answer), `converged`, `waiting_deps` / `waiting_merge`
332
- / `queued` / `merging` (merge stage), `merged`, `abandoned`. A separate
333
- `incident` signal (§5) can overlay any live status when the engine parks the token on
334
- a technical fault.
173
+ PR `status` values: `converging` (round live), `waiting_review`, `escalated`, `converged`,
174
+ `waiting_deps` / `waiting_merge` / `queued` / `merging`, `merged`, `abandoned`. An
175
+ **incident** (§5) can overlay any live status when the engine parks the token on a fault.
335
176
 
336
177
  ---
337
178
 
338
179
  ## 5. Debug: find engine instances and relate them to PRs
339
180
 
340
- The app stores each PR/plan's engine **process instance key** in its `process_key`
341
- column and surfaces it as `processKey` in `/status`. That key is the join between the
342
- app's business view and the engine's execution view.
343
-
344
- **Find the instance for a PR:** take `processKey` from `listActivePrs` (`/status`), then
345
- query engine truth. **Prefer the framework `urban_debug_*` engine-debug tools** (owned by
346
- the nano-ide urban runtime — see the crosswalk in §10); the `curl __ENGINE__/…` engine
347
- REST calls are the no-MCP fallback:
348
-
349
- ```text
350
- PK = <processKey from listActivePrs>
351
-
352
- # The instance itself (state, the BPMN process it is running, start time):
353
- tool: urban_debug_search_process_instances { "filter": { "processInstanceKey": PK } }
354
-
355
- # Where is it parked? — active jobs on the instance (a CREATED senior:pr-review job
356
- # with a `worker` set means an agent has leased the round; none means it is queued).
357
- # `urban_debug_search_jobs`/`_search_variables` are projected only where the framework
358
- # offers them (sibling nano-ide epic); else drop to the curl door (see §10 crosswalk):
359
- tool: urban_debug_search_jobs { "filter": { "processInstanceKey": PK, "state": "CREATED" } } # where projected
360
-
361
- # Its variables (the aggregate/job payload the engine holds):
362
- tool: urban_debug_search_variables { "filter": { "processInstanceKey": PK } } # where projected
363
-
364
- # Is it dead-in-the-water on a technical fault? — active incidents:
365
- tool: urban_debug_search_incidents { "filter": { "processInstanceKey": PK, "state": "ACTIVE" } }
366
-
367
- # Which BPMN element/wait-state is it sitting on?
368
- tool: urban_debug_search_element_instance_wait_states { "filter": { "processInstanceKey": PK } }
369
- ```
181
+ `processKey` (DB column `process_key`) joins the app's record to the engine instance. Take it
182
+ from `/status`, then query the engine. Over MCP, the framework `urban_debug_*` tools front
183
+ these same searches (§10).
370
184
 
371
185
  ```bash
372
- # No-MCP fallback — the raw engine Camunda-8 v2 REST API:
373
- PK=<processKey-from-status>
374
-
375
- # The instance itself (state, the BPMN process it is running, start time):
376
- curl -sS -X POST __ENGINE__/process-instances/search \
377
- -H 'content-type: application/json' \
378
- -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
379
-
380
- # Where is it parked? — active jobs on the instance:
381
- curl -sS -X POST __ENGINE__/jobs/search \
382
- -H 'content-type: application/json' \
383
- -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"CREATED\" } }" | jq
384
-
385
- # Is it dead-in-the-water on a technical fault? — active incidents:
386
- curl -sS -X POST __ENGINE__/incidents/search \
387
- -H 'content-type: application/json' \
388
- -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"ACTIVE\" } }" | jq
389
-
390
- # What are the element/flow-node instances (which BPMN element is it sitting on)?
391
- curl -sS -X POST __ENGINE__/element-instances/search \
392
- -H 'content-type: application/json' \
393
- -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
186
+ PK=<processKey>
187
+ H='content-type: application/json'
188
+ # State, BPMN process, start time:
189
+ curl -sS -X POST __ENGINE__/process-instances/search -H "$H" -d "{\"filter\":{\"processInstanceKey\":\"$PK\"}}" | jq
190
+ # Where it is parked (a job with a `worker` set is leased by an agent; none = queued):
191
+ curl -sS -X POST __ENGINE__/jobs/search -H "$H" -d "{\"filter\":{\"processInstanceKey\":\"$PK\",\"state\":\"CREATED\"}}" | jq
192
+ # Technical fault?
193
+ curl -sS -X POST __ENGINE__/incidents/search -H "$H" -d "{\"filter\":{\"processInstanceKey\":\"$PK\",\"state\":\"ACTIVE\"}}" | jq
194
+ # Which BPMN element it sits on:
195
+ curl -sS -X POST __ENGINE__/element-instances/search -H "$H" -d "{\"filter\":{\"processInstanceKey\":\"$PK\"}}" | jq
196
+ # The variables the engine holds:
197
+ curl -sS -X POST __ENGINE__/variables/search -H "$H" -d "{\"filter\":{\"processInstanceKey\":\"$PK\"}}" | jq
394
198
  ```
395
199
 
396
- > The `urban_debug_search_jobs`, `urban_debug_search_variables` and
397
- > `urban_debug_get_process_definition_xml` reads are owned by the sibling nano-ide urban
398
- > epic; where a deployment's framework has not yet projected them, use the curl fallback
399
- > for that row. The three always-present engine reads are
400
- > `urban_debug_search_process_instances`, `urban_debug_search_element_instance_wait_states`
401
- > and `urban_debug_search_incidents`.
402
-
403
- The app already mirrors an ACTIVE incident onto the PR row (`incident`/incident
404
- message), so a PR that shows an incident in the UI is parked on an engine fault —
405
- inspect it with `urban_debug_search_incidents` (or `incidents/search`) above. If the
406
- engine is not at the default, the deployment's engine base is `__ENGINE__` (set via
407
- `NANOBPMN_BASE_URL` or `CAMUNDA_REST_ADDRESS`).
200
+ An active incident is mirrored onto the PR row, so a PR showing an incident in the UI is
201
+ parked on an engine fault. The engine base comes from `NANOBPMN_BASE_URL` or
202
+ `CAMUNDA_REST_ADDRESS`.
408
203
 
409
- **Relate an instance back to a PR:** if you have a `processKey` but not the PR, match
410
- it against `listActivePrs`/`/status` (`.prs[] | select(.processKey == "<PK>")`). A
411
- terminal PR is no longer in `/status`; its instance has already completed or been
412
- cancelled.
204
+ Have a `processKey` but not the PR? Use `/status | jq '.prs[] | select(.processKey == "<PK>")'`.
205
+ A terminal PR has left `/status`.
413
206
 
414
207
  ---
415
208
 
416
209
  ## 6. Debug the models and the prompts
417
210
 
418
- The behaviour is defined by durable BPMN processes and model-authored agent prompts —
419
- both live in the source repo, not in the job payload.
420
-
421
- - **Processes:** to inspect the model an instance is **actually running**, prefer the
422
- framework tool **`urban_debug_get_process_definition_xml`** (owned by the sibling
423
- nano-ide urban epic — see §10), which returns the deployed BPMN XML for a process
424
- definition; that is engine truth and cannot drift from what is live. The repo copies
425
- `resources/processes/*.bpmn` — `convergence-loop.bpmn` (review), `merge-loop.bpmn`
426
- (merge/CI-fix/rebase), `plan-fanout.bpmn` (planning), `retro.bpmn` — are the
427
- **source of truth in git** for routing, read them from the checkout when you want the
428
- authored form (or have no MCP). They can lag a running deployment, so confirm against
429
- the deployed XML when *why did this instance route here* matters. To understand *why* an
430
- instance went where it did, read the gateway conditions (FEEL expressions on the
431
- sequence flows) for the element it is parked on (§5).
432
- - **Prompts (agent base instructions):** `resources/prompts/*.md` — `review-round.md`,
433
- `plan.md`, `feature.md`, `fix-ci.md`, `rebase.md`, `trial-merge.md`, etc. They live
434
- under `resources/` so they deploy by convention (ADR 0062 — the app declares no
435
- `models`; every file under `resources/` is deployed, one `.md` per task under
436
- `resources/prompts/`). An agent's base prompt is **not** a job variable and is **not**
437
- a deploy-time `{{token}}` substitution (that templating is removed — there is no
438
- back-compat): it is delivered as a **linked resource**, the blessed **and only**
439
- prompt-modularity path:
211
+ - **Processes.** Engine truth is the deployed XML (`urban_debug_get_process_definition_xml`).
212
+ The source in git is `resources/processes/*.bpmn`: `convergence-loop.bpmn` (review),
213
+ `merge-loop.bpmn` (merge/CI-fix/rebase), `plan-fanout.bpmn`, `retro.bpmn`. The repo copy can
214
+ lag a running deployment. To see *why* an instance routed somewhere, read the FEEL
215
+ conditions on the outgoing sequence flows of the element it is parked on (§5).
216
+ - **Prompts.** `resources/prompts/*.md` (`review-round.md`, `plan.md`, `feature.md`,
217
+ `fix-ci.md`, `rebase.md`, `trial-merge.md`, …) deploy by convention. Each agent task links its
218
+ base prompt as a linked resource that the engine resolves at job activation:
440
219
 
441
220
  ```xml
442
221
  <zeebe:linkedResource resourceId="prompts/review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
443
222
  ```
444
223
 
445
- that the engine
446
- resolves to the latest deployed prompt at job activation, combined at runtime with the
447
- per-task `appendPrompt` FEEL (the task-specific slice appended to this base). Because
448
- the binding is `latest`, redeploying one of these files updates the prompt mid-epic
449
- without a process redeploy. If an agent misbehaves systematically, the prompt is the
450
- first thing to inspect/fix.
451
-
452
- > **Value-injection caveat:** never bake a per-run value (a URL, a flag) into a prompt
453
- > at deploy time — that path is gone. Pass it as a runtime job variable / `appendPrompt`
454
- > FEEL instead; deploy-time string substitution is not available.
455
- - **Job contract:** `senior:pr-review` receives `{ prUrl, repo, prNumber, round,
456
- answer? }` and must return a flat result `{ status, summary, question? }` with
457
- `status ∈ { converged, addressed, waiting, needs_input, blocked }`. A round that
458
- pushes anything (including a rebase/force-push) is `addressed`; a round with an
459
- unknown/empty result is treated as a safe `addressed` and re-enters the review wait
460
- rather than escalating.
461
-
462
- To validate a model/prompt change locally: `npm run layout:check` (BPMN diagram
463
- freshness), `npm run check:prompts` (every prompt link resolves), `npm run check`
464
- (manifest), `npm run typecheck`, `npm run lint`, `npm test`.
224
+ It is combined with the task's runtime `appendPrompt` FEEL. Because of `latest`, redeploying a
225
+ prompt changes it mid-epic with no process redeploy. A systematically misbehaving agent
226
+ usually means a prompt problem. There is no deploy-time `{{token}}` templating: pass per-run
227
+ values as job variables / `appendPrompt`.
228
+ - **Job contract.** `senior:pr-review` receives `{ prUrl, repo, prNumber, round, answer? }` and
229
+ returns `{ status, summary, question? }`, with `status ∈ { converged, addressed, waiting,
230
+ needs_input, blocked }`. Any push (including a rebase) is `addressed`. An empty or unknown
231
+ result is treated as `addressed` and re-enters the review wait.
232
+
233
+ Validate a model or prompt change with `npm run layout:check`, `check:prompts`, `check`,
234
+ `typecheck`, `lint` and `test`.
465
235
 
466
236
  ---
467
237
 
468
238
  ## 7. Unstick a stuck process
469
239
 
470
- Work through this order:
471
-
472
- 1. **Confirm it is actually stuck.** From `/status`, a PR `converging` with an
473
- `activeWorker` set is *working*, not stuck — an agent holds the round. No worker
474
- for a long time means the job is queued: is a fleet `c8ctl nano work` daemon
475
- running and subscribed to the `senior:*` task types?
476
- 2. **Check for an incident** (§5). An ACTIVE incident parks the token; the underlying
477
- fault must be resolved (or the instance cancelled and the work resubmitted). The
478
- app surfaces the incident message on the PR row.
479
- 3. **Check for an open escalation** (§3) — the process may simply be waiting for a
480
- human answer. Answer it.
481
- 4. **A review that never arrives** escalates on its own after
482
- `NANO_PR_REVIEW_WAIT_TIMEOUT` (default `PT30M`); the poller also re-nudges the
483
- reviewer periodically. If the reviewer bot is not provisioned on the repo, no
484
- review will ever land — that is a repo-config problem, not an app bug.
485
- 5. **Cancel + resubmit** as a last resort. Cancel through the **app-owned** door —
486
- **prefer the `cancelInstance` tool** (projected by sibling #667; the same
487
- record-consistent path as the UI's per-row Cancel, `POST /app/actions/cancel
488
- { "processInstanceKey": "<PK>" }`), which cancels
489
- the engine instance **and** marks the PR `abandoned` so it drops out of
490
- `listActivePrs`. Then re-submit the PR (§1) to start a fresh loop. **Do not** cancel a
491
- raw engine instance out from under the app with the engine-level
492
- `urban_debug_cancel_instance` tool (or a direct engine REST cancel) — that cancels the
493
- token but leaves the PR row inconsistent. Always go through `cancelInstance` /
494
- `POST /app/actions/cancel` so the app's record state stays consistent. Note the door
495
- sits at `/app/actions/cancel`, **outside** the `__BASE__` control-API base (ADR 0059) —
496
- until #667 projects `cancelInstance`, that curl door is the only cancel path. No-MCP
497
- fallback:
240
+ 1. **Is it actually stuck?** `converging` with an `activeWorker` means an agent is working.
241
+ No worker for a long time means the job is queued: check that a fleet `c8ctl nano work`
242
+ daemon is running and subscribed to the `senior:*` types.
243
+ 2. **Incident?** (§5) It parks the token until the fault is resolved, or until you cancel and
244
+ resubmit.
245
+ 3. **Open escalation?** (§3) Answer it.
246
+ 4. **Review never arrives?** It escalates after `NANO_PR_REVIEW_WAIT_TIMEOUT` (default `PT30M`),
247
+ and the poller re-nudges the reviewer. If no reviewer bot is provisioned on the repo, that
248
+ is a repo-config problem.
249
+ 5. **Last resort: cancel and resubmit** through the app-owned door, `cancelInstance`. It cancels
250
+ the engine instance **and** marks the record `abandoned`. Then resubmit (§1). Never use the
251
+ engine-level `urban_debug_cancel_instance` or a raw engine cancel: it leaves the PR row
252
+ inconsistent.
498
253
 
499
254
  ```bash
500
- curl -sS -X POST __BASE__/../actions/cancel -H 'content-type: application/json' \
255
+ curl -sS -X POST __BASE__/actions/cancel -H 'content-type: application/json' \
501
256
  -d '{ "processInstanceKey": "<PK>" }'
502
257
  ```
503
258
 
@@ -505,251 +260,109 @@ Work through this order:
505
260
 
506
261
  ## 8. Raise an issue or a PR against nano-workforce
507
262
 
508
- When you find a genuine bug or a missing capability in the orchestration itself
509
- (not a transient repo/agent problem), help the human file it against
510
- `nanobpm/nano-workforce`:
511
-
512
- - **Every change needs a tracked issue or PR** — no silent fixes. Open an issue
513
- first if one does not exist.
514
- - **DCO is enforced:** every commit needs a `Signed-off-by` trailer — commit with
515
- `git commit -s` (or `git rebase --signoff`).
516
- - **Work in a git worktree** off `origin/main`, on a `feat/*` or `fix/*` branch.
517
- - **Author the BPMN semantics, generate the diagram.** Never hand-edit the
518
- `bpmndi:BPMNDiagram`; run `npm run layout <file.bpmn>` and commit the result. CI
519
- fails on stale DI.
520
- - **Match the CI gates locally before pushing:** `npm run lint`, `npm run typecheck`,
521
- `npm run check`, `npm run layout:check`, `npm run check:prompts`, `npm test`.
522
- - **Copilot code review is provisioned** — drive the PR to convergence against it.
523
- - Read `AGENTS.md` (engineering principles + gates) and `SPEC.md` (behavioural source
524
- of truth) before proposing a change to a process.
525
-
526
- When describing the bug, include the concrete evidence you gathered here: the
527
- `prKey`, the engine `processKey`, the parked element / incident message (§5), and the
528
- BPMN/prompt file you believe is responsible (§6).
263
+ For a genuine orchestration bug or gap (not a transient repo/agent problem), file it against
264
+ `nanobpm/nano-workforce`. Include the evidence: `prKey`, `processKey`, the parked element or
265
+ incident message (§5), and the BPMN or prompt file you suspect (§6).
266
+
267
+ If you change code: open or claim an issue first, work in a worktree off `origin/main`, and
268
+ sign off every commit (`git commit -s`, DCO). Never hand-edit `bpmndi` (run
269
+ `npm run layout <file.bpmn>`). Run the CI gates locally (§6 list). Drive the PR to convergence
270
+ against Copilot review. Read `AGENTS.md` and `SPEC.md` before changing a process.
529
271
 
530
272
  ---
531
273
 
532
274
  ## 9. Author and run a delivery graph (ADR 0005)
533
275
 
534
- The two workflows above (§1 convergence-loop, §2 plan-fanout) are each specialised to
535
- **one** node shape ("an agent implements a slice → opens a PR"). Real delivery is often a
536
- **heterogeneous, cross-repo, partly-human graph** — e.g. *merge PR #101 → un-draft+merge
537
- PR #202 → a human does a manual OTP publish → PR #303 consumes the just-published version*. A
538
- **delivery graph** ([ADR 0005](https://github.com/nanobpm/nano-workforce/blob/main/docs/adr/0005-agent-authored-delivery-graphs.md))
539
- lets you compose exactly that as **data** and hand it to a generic runner.
540
-
541
- > **Discover the vocabulary from the surface.** Everything this section describes — the four
542
- > node kinds and their body contracts, every `wait` probe kind and **what it observes**, the
543
- > real-vs-stub connector targets, the `onTimeout` options, the poll-budget trap, and the
544
- > fact-threading rules — is also available as **structured JSON** from the read tool
545
- > **`getDeliveryGraphVocabulary`** (`GET __BASE__/delivery-graph/vocabulary`). It is derived
546
- > from the implementing code (a drift test fails the build if the two disagree), so prose and
547
- > data can never drift. Fetch it to author against the live vocabulary; this section is the
548
- > narrative companion.
549
-
550
- You author the graph as **JSON — never BPMN or code** (Decision 1: the agent must never
551
- author the executable artifact; the closed node vocabulary is the trust boundary). Your
552
- surface ends at **propose → compile → stage**: a single `compile` door validates the JSON,
553
- renders a preview, and — when valid — **stages the compiled graph as a proposal** for a
554
- human. **Dispatch is an operator action in the cockpit, not an agent endpoint** (issue #460):
555
- there is deliberately no `start` door on the agent surface, so there is nothing an agent can
556
- call — or replay — to launch a run. A human previews the staged proposal and dispatches it.
276
+ A delivery graph composes heterogeneous, cross-repo, partly-human work as **data**, for example:
277
+ *merge PR #101 → un-draft and merge PR #202 → a human does a manual OTP publish → PR #303
278
+ consumes that version.* You author **JSON only, never BPMN or code**.
557
279
 
558
- ### 9.1 The `DeliveryGraph` shape
280
+ Your surface is **propose → compile → stage**. **Dispatch is an operator action** in the
281
+ cockpit. There is no agent start door, so nothing an agent can call or replay launches a run.
559
282
 
560
- A `DeliveryGraph` is a JSON **DAG**: `{ name?, nodes[], edges[] }`.
561
-
562
- - **`nodes[]`** — each node has a unique `id`, a `kind` from the **closed allowlist**
563
- (`agent` | `wait` | `human` | `connector`), the matching per-kind config, and an
564
- optional typed `emits[]` declaration (the facts it hands forward).
565
- - **`edges[]`** — each edge is `{ from, to }` meaning *"`to` proceeds once fact `from` is
566
- observable"* (Decision 3 — edges are **discovered facts**, not declared values). `from`
567
- is either a bare **`<nodeId>`** (the degenerate "wait for the upstream node's
568
- completion" fact) or a **qualified `<nodeId>.<fact>`** referencing one of that node's
569
- declared `emits`. The whole edge set must be a DAG. Omit / `[]` for independent roots.
570
-
571
- **The four node kinds** (each delegates to an existing engine-native body — the graph
572
- layer schedules, it does not re-implement execution):
573
-
574
- | kind | config | what it does | may `emits`? |
575
- |---|---|---|---|
576
- | `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 |
577
- | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. Read-only. | yes (binds observed facts) |
578
- | `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 |
579
- | `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 |
580
-
581
- > **Provision every `agent` node with a repository — compile will NOT catch it.** Each `agent` cell
582
- > runs in an isolated checkout: the harness clones the node's **`repository`** (`owner/repo`, no
583
- > `.git`/URL) at **`baseBranch`** and cuts `feat/<node.id>` off it (#739/#776). `baseBranch` resolves
584
- > **per field** — the node's own, else the **run-level dispatch `baseBranch`**, else the repo default
585
- > (an id yielding an ill-formed ref like `a..b`/`a.lock` makes the harness skip `branch.create`, so
586
- > the agent cuts its own branch). A node with **no `repository`** is staged by compile **without a
587
- > warning**, then — absent a **run-level fallback** supplied *at dispatch* — Dispatch fails with
588
- > `N agent node(s) resolve to no repository (…)`. So:
589
- >
590
- > - **Declare `repository` on EVERY `agent` node** (and `baseBranch` when it is neither the run-level
591
- > base nor the default): a single-repo graph repeats one value; a cross-repo graph names each node's
592
- > own repo. Every repo a node's prompt changes must be that node's `repository`.
593
- > - **`repoless` is a RUN-WIDE mode, not a per-node escape hatch** — it strips isolation from *every*
594
- > node. Use it only when **every** `agent` node is checkout-less (pure research/comment work); if
595
- > even one needs a checkout, provision repositories instead.
596
- > - **Self-check before handing over the digest:** every `"kind": "agent"` node has a `repository`;
597
- > if you leave some unset, tell the operator which, and that they must pick the run-level
598
- > **repository + base-branch fallback** in the Dispatch form (the default mode rejects the graph).
599
- >
600
- > A **present** `repository`/`baseBranch` is validated at compile (`invalid-node-repository` /
601
- > `invalid-node-base-branch`); an **absent `repository`** defers to dispatch (and can fail there); an
602
- > **absent `baseBranch` is always valid** — it inherits the run-level base, else the repo default.
603
-
604
- A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
605
- intake uses): `{ kind, target, onTimeout?, match?, poll? }`, where `poll` is
606
- `{ everyMs?, timeoutMs?, backoff? }` — `everyMs` is the re-probe cadence, `timeoutMs` the
607
- total bounded budget, and `backoff ∈ fixed|exponential`.
608
-
609
- > **The bound is invisible unless you set it.** When `poll` (or `poll.timeoutMs`) is
610
- > **omitted**, the gate inherits the built-in default budget of **`PT30M` (30 minutes),
611
- > re-probing every 15s** (`DEFAULT_READINESS_TIMEOUT` / `DEFAULT_EVERY_MS`,
612
- > `app/readiness.ts`). That default is right for *"is the package published yet"* and badly
613
- > wrong for `wait[pr, merged]` / `wait[epic]`, which routinely wait **hours or days** — an
614
- > unpopulated `poll` on such a gate escalates after 30 minutes for no visible reason. Neither
615
- > `compile` nor `preview` surfaces the effective bound, so **set `poll.timeoutMs` explicitly**
616
- > on any gate that waits on a merge or an epic (see §9.4 / §9.5).
617
-
618
- The **`pr` kind** watches an
619
- in-flight PR — `target: "owner/repo#123"`, `match.prState ∈ ready|merged|mergeable|checks-green`
620
- (default `merged`) — and on a merged match binds `mergedSha` as an output fact. The **`epic`
621
- kind** (issue #568) gates on an **nwf plan-fanout epic reaching "fully merged"** — `target:
622
- "owner/repo#NN"` is the epic's durable **`planKey`** (the epic issue, *not* the engine
623
- `processInstanceKey`, so a resubmit/replay still resolves), `match.epicState ∈ merged|done`
624
- (default `merged`, both mean "every opened slice landed"). It observes the app's own aggregate
625
- (the lineage read-model), so a **failed/abandoned/mixed** epic never reports merged and the
626
- bounded wait routes to **`onTimeout`** rather than hanging; on a fully-merged match it binds
627
- `prCount` (how many slice PRs landed) as an output fact. Both `pr` and `epic` targets may also
628
- be a **`<nodeId>.<fact>` late-binding reference** the compiler resolves at dispatch (§9.4),
629
- rather than a literal handle.
630
-
631
- A **typed fact** (`emits[]` entry) is `{ name, type, description? }` where
632
- `type ∈ string|number|boolean|artifact|version|url|pr` (`artifact` = a `pkg@version` handle,
633
- `version` = a bare version, `pr` = a PR reference `owner/repo#N` an `agent` node emits for the
634
- PR it opened, late-bound by a downstream `wait[pr]`/`connector[converge*]` target — issue #548).
635
- `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is referenced
636
- downstream as `<nodeId>.<name>`. A "click done" human node or a pass-through node declares
637
- no facts.
638
-
639
- **Guarded routing + the agent classifier-emit contract.** An edge may carry a **guard** —
640
- `when: "<nodeId>.<fact>"` + `equals: <scalar>` — or be the split's single `default: true`
641
- else-branch (S7). A node whose out-edges are guarded is a **data-based exclusive split**: at
642
- runtime exactly one branch fires, chosen by the producer's emitted fact. For an **`agent`**
643
- node the fact is late-bound from the servicing job's completion: the delivery output-mapping
644
- publishes the engine variable named **exactly after the fact** (e.g. a fact `result` reads the
645
- completion variable `result`). A real `senior:*` fleet agent completes with its **Output
646
- contract** envelope (`{ status, summary, pr, question, delta }`), so — to make a guarded split
647
- fire — an agent node that declares `emits` has a **classifier emit contract** automatically
648
- appended to its prompt at dispatch: the agent MUST return each declared fact as an **extra
649
- top-level field of the same result JSON** (the `AGENT_RESULT_FILE` it already writes
650
- `status`/`summary`/`pr` to). Author side, this means: declare `emits: [{ name, type }]` on the
651
- agent node and guard the downstream edge on `<node>.<name>`; a contract-following agent returns
652
- `{ …, <name>: <value> }` and the split routes on it. If the agent cannot decide the fact it
653
- **omits** it, and the split takes its `default` (else) branch — the deadlock-safe fallback. A
654
- node that declares no `emits` gets no contract text and behaves exactly as before.
655
-
656
- **Producer completion contract (auto-injected — do NOT hand-encode it).** Every `agent` node
657
- *also* has a **producer completion contract** appended to its prompt at dispatch, alongside the
658
- idempotency preflight and — only for a node that declares `emits` — the classifier emit contract.
659
- It hands the agent the terminal-status vocabulary
660
- the `#731` producer gate enforces: the injected text tells the agent it MUST end its result with
661
- one of `AGENT_TERMINAL_SUCCESS_STATUSES` (`done` / `opened` / `skipped`) — never omit `status` or
662
- invent one. The gate routes a completion onward only when its self-reported `status` is one of that
663
- allowlist **and** every required emit is non-null; any *explicitly non-terminal* status parks the
664
- run on a human `__contract` escalation (fail-closed). (As a backward-compat concession the gate
665
- *also* routes onward an **absent/null** status — for legacy workers / stubs that report none — but
666
- the injected contract never invites a real agent to lean on that: always return an allowlisted
667
- status.) The wording is **derived from that single allowlist** (changing the
668
- list changes the prompt — no second copy), so **authors must not hand-encode status vocabulary
669
- in a node's prompt.** Unlike the emit contract, a no-emit node still receives the status block
670
- (the gate applies to it too).
283
+ > **The structured vocabulary is authoritative:** `getDeliveryGraphVocabulary`
284
+ > (`GET __BASE__/delivery-graph/vocabulary`) returns the node kinds and fields, every `wait`
285
+ > probe kind and what it observes, connector targets, `onTimeout` options, poll defaults and
286
+ > fact-threading rules. It is derived from the code, so read it before authoring. This section
287
+ > covers the workflow and the traps.
671
288
 
672
- ### 9.2 The agent loop: draft → compile → stage → ask an operator to dispatch
289
+ ### 9.1 The `DeliveryGraph` shape
673
290
 
674
- ```
675
- GET __BASE__/agent # ← you are reading it; learn the vocabulary + endpoints
676
- └─ draft a DeliveryGraph JSON
677
- └─ POST __BASE__/actions/compile-delivery-graph # validate + preview + STAGE
678
- ├─ 400 { ok:false, errors:[{path,message}] } → fix the exact offending input, recompile
679
- └─ 200 { status:"ready", message, digest, preview, reviewUrl }
680
- └─ ask the operator to preview + dispatch it in the cockpit (there is no start door)
681
- ```
291
+ `{ name?, nodes[], edges[] }`, a DAG. Each node has a unique `id`, a `kind`, that kind's
292
+ config, and optional `emits[]` (typed facts `{ name, type, description? }`, with
293
+ `type ∈ string|number|boolean|artifact|version|url|pr`). For edges, each edge is `{ from, to }`:
294
+ `to` proceeds once `from` is observable. `from` is a bare `<nodeId>` (completion) or a
295
+ qualified `<nodeId>.<fact>` naming one of that node's `emits`.
682
296
 
683
- **Compile (validate + preview + stage).** The compile door runs the semantic validator and
684
- the deterministic compiler and, on success, **stages** the compiled graph as a proposal a
685
- human can dispatch — it does **not** deploy or run anything. Recompiling a graph you are
686
- still drafting is safe: a re-compile of the same graph is idempotent, and a changed graph
687
- with the same `name` supersedes the prior staged proposal, so the cockpit shows exactly one
688
- live proposal per graph.
297
+ | kind | config | does |
298
+ |---|---|---|
299
+ | `agent` | `agent: { jobType, repository?, baseBranch?, prompt?, converge?, merge?, timeout? }` | runs an agent job in a fresh checkout of `repository`. Side-effecting. `converge`/`merge` (merge requires converge) declare land intent; a raw `senior:converge`/`senior:merge` job is rejected. `timeout` is an ISO-8601 per-node SLA overriding the run's `nodeTimeout` (default `PT1H`). |
300
+ | `wait` | `wait: <ReadinessProbe>` | a durable, bounded, read-only readiness probe `{ kind, target, match?, poll?, onTimeout? }`. Kinds: `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. |
301
+ | `human` | `human?: { formKey?, prompt? }` | a user task on the Tasks inbox (§3), answerable by a human or an agent. |
302
+ | `connector` | `connector: { target, dedupeKey?, payload?, timeout? }` | an at-least-once, deduped outbound action. Real targets: `converge`, `converge-merge`, `merge-main` (§9.4). |
303
+
304
+ > **Provision every `agent` node with a repository — compile will NOT catch it.** Each agent
305
+ > clones its node's `repository` (`owner/repo`) at `baseBranch` (node value, else the run-level
306
+ > dispatch base, else the repo default) and cuts `feat/<node.id>`. A node with no `repository`
307
+ > compiles cleanly, then Dispatch fails with `N agent node(s) resolve to no repository (…)`
308
+ > unless the operator supplies a run-level fallback. So set `repository` on **every** agent node.
309
+ > If you leave one unset, tell the operator. `repoless` is a run-wide mode for graphs where
310
+ > **no** node needs a checkout.
311
+
312
+ > **Set `poll.timeoutMs` on every merge or epic gate.** If `poll` is omitted, a `wait` inherits
313
+ > **30 minutes** (re-probing every 15 s). That suits "is the package published?", but a
314
+ > `wait[pr, merged]` or `wait[epic]` then escalates mid-review. Use e.g.
315
+ > `"poll": { "everyMs": 300000, "timeoutMs": 259200000 }` (5 min / 3 days).
316
+
317
+ **Guarded routing.** An edge may carry `when: "<nodeId>.<fact>"` + `equals: <scalar>`, or be the
318
+ split's single `default: true` branch. Exactly one branch fires. For an `agent` node, the fact
319
+ is read from a same-named top-level field in the agent's result JSON. The system adds that
320
+ instruction to the agent's prompt automatically when the node declares `emits`. If the agent
321
+ omits the fact, the `default` branch is taken.
322
+
323
+ **Result statuses are added automatically. Don't write them into your prompt.** Every agent
324
+ node's prompt gets the allowed terminal statuses (`done` / `opened` / `skipped`). A completion
325
+ moves on only with one of those statuses and every required emit present. Anything else parks
326
+ the run on a human `__contract` escalation — **except** an absent/null `status`, which a legacy
327
+ worker that populates its required emits may still advance with (the status contract binds only
328
+ when a status is actually reported).
329
+
330
+ ### 9.2 The agent loop: draft → compile → stage → ask an operator to dispatch
689
331
 
690
332
  ```bash
333
+ # tool: compileDeliveryGraph: validate + preview + STAGE (never deploys or runs)
691
334
  curl -sS -X POST __BASE__/actions/compile-delivery-graph \
692
- -H 'content-type: application/json' \
693
- -d @graph.json | jq
335
+ -H 'content-type: application/json' -d @graph.json | jq
694
336
  ```
695
337
 
696
- - `200 { status:"ready", message, digest, preview, reviewUrl }` — the graph compiled and is
697
- **staged for operator review**. `digest` is the content-address that NAMES the proposal (so
698
- you can tell the operator exactly which one to dispatch); `preview` is `{ diagram,
699
- sideEffects, humanNodes }` — `diagram` is a mermaid `flowchart` of the resolved graph,
700
- `humanNodes[]` are the stop-points where it waits for a person, and `sideEffects[]` are the
701
- `agent`/`connector` actions it **will** perform once an operator dispatches it; `reviewUrl`
702
- is a **navigational** cockpit deep-link (a pointer only — **not** a dispatch handle). The
703
- response carries **no run key, no token, and no process-instance key**: nothing you can
704
- replay to start a run. Your role ends here — hand the operator the `digest` (or `reviewUrl`)
705
- and ask them to preview and dispatch it.
706
- - `400 { ok:false, errors:[{ path, message }] }` — every error path-qualified
707
- (`nodes[2].kind`, `edges[1].from`, …) for unknown kind, dangling edge, a cycle, or an
708
- unresolvable `from` fact. Fix and recompile; nothing is staged.
709
-
710
- **Dispatch (operator-only — NOT on the agent surface).** There is deliberately no agent
711
- `start` endpoint (Decision 5/7, issue #460). Dispatch is a human action: an operator opens
712
- the **Delivery Graphs** page in the cockpit, reviews the staged proposal's rendered preview
713
- (its diagram, the human stop-points, and the side effects a dispatch authorises), and clicks
714
- **Dispatch** on the one they approve. The operator clicking Dispatch **is** the approval — it
715
- is content-addressed to the exact digest they previewed, so it cannot be a replay of some
716
- other graph. Once dispatched, the graph deploys + runs engine-natively and registers as a run
717
- aggregate, so its current phase / parked node shows in the cockpit's **Active Delivery
718
- Graphs** grid (e.g. *"parked on human node: manual OTP publish"*). A `human` node parks on the
719
- **Tasks** inbox and is answered exactly as an escalation is (§3) — its completion emits any
720
- declared facts, which downstream edges bind.
721
-
722
- > **Completing a `wait` escalation proceeds *as not-ready* — it does NOT re-arm the gate.**
723
- > A `wait` node with `onTimeout: "escalate"` (the default) that elapses its bound parks a
724
- > human-completable escalation task on the Tasks inbox. **Completing that task does not retry
725
- > the probe or wait for readiness** — it releases the token **as not-ready** and the graph
726
- > proceeds **past the gate**, so the downstream side-effecting node then runs *against the
727
- > unmet dependency* (`waitBodyLines`, `app/deliveryGraphCompiler.ts`). An operator clearing
728
- > what looks like a stuck task therefore *launches the very work the gate was holding back*.
729
- > If the dependency genuinely is not ready, do **not** complete the escalation to "unstick"
730
- > it — extend the gate's `poll.timeoutMs` and re-dispatch, or abandon the run. (Same for
731
- > `onTimeout: "continue"`, which proceeds past the gate as not-ready with **no** human stop
732
- > at all.)
733
- >
734
- > **Exception (#872):** a `wait` that declares `emits` **validates** its resume value — an
735
- > omitted/invalid value **re-parks** the task rather than releasing not-ready.
736
-
737
- > **Why the split?** Making the compile door the end of the agent surface closes a
738
- > self-approval hole: the old flow handed the same caller an approval token to re-submit with,
739
- > so any holder of the API credential approved its own graph. With no dispatch affordance on the
740
- > agent surface (capability by absence), the human in the cockpit is the only actor who can
741
- > launch side effects.
338
+ - **`200 { status:"ready", message, digest, preview, reviewUrl }`**: staged.
339
+ `preview` is `{ diagram (mermaid), sideEffects[], humanNodes[] }`. `digest` names the proposal,
340
+ and `reviewUrl` is a cockpit link (navigation only, not a dispatch handle). Give the operator
341
+ the `digest`/`reviewUrl` and ask them to dispatch.
342
+ - **`400 { ok:false, errors:[{ path, message }] }`**: path-qualified errors (unknown kind,
343
+ dangling edge, cycle, unbound fact). Fix and recompile; nothing is staged.
344
+
345
+ Recompiling is safe: the same graph is idempotent, and a changed graph with the same `name`
346
+ replaces the staged proposal. The operator previews it on the **Delivery Graphs** page and
347
+ clicks **Dispatch**. That click is the approval, bound to the previewed digest. Dispatch also
348
+ accepts `autoResolveEscalations` (§3). The running graph shows in **Active Delivery Graphs**,
349
+ and `human` nodes are answered like escalations (§3), emitting their declared facts.
350
+
351
+ > **Completing a `wait` escalation proceeds *as not-ready*. It does NOT re-arm the gate.** With
352
+ > `onTimeout: "escalate"` (default), an elapsed bound parks a task. Completing it releases the
353
+ > token past the gate, so the downstream side-effecting node runs against the unmet dependency.
354
+ > If the dependency isn't ready, don't complete it: raise `poll.timeoutMs` and re-dispatch, or
355
+ > abandon. (`onTimeout: "continue"` proceeds the same way with no human stop.) **Exception:** a
356
+ > `wait` that declares `emits` validates the resume value and re-parks on a missing or invalid one.
742
357
 
743
358
  ### 9.3 Worked example — the cross-repo human-in-the-loop release
744
359
 
745
- *Merge PR #101 (repo 1) → un-draft+merge PR #202 (repo 2) → a **human** runs the manual OTP
746
- publish and records the version → open+merge PR #303 (repo 3) consuming that version.* The
747
- `human` node **emits** a typed `version` fact, and the downstream `from:
748
- "manual-publish.publishedVersion"` edge binds it into the PR-#303 path:
360
+ The `human` node emits a `version` fact that the edge `manual-publish.publishedVersion` binds
361
+ into the next agent:
749
362
 
750
363
  ```json
751
364
  {
752
- "name": "cross-repo release: merge #101 → un-draft+merge #202 → manual OTP publish → consume in #303",
365
+ "name": "cross-repo release: merge #101 → un-draft+merge #202 → manual OTP publish → consume in repo-3",
753
366
  "nodes": [
754
367
  { "id": "merge-a", "kind": "wait",
755
368
  "wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" },
@@ -757,244 +370,97 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
757
370
  { "id": "undraft-merge-b", "kind": "agent",
758
371
  "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." } },
759
372
  { "id": "manual-publish", "kind": "human",
760
- "human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
761
- "emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
373
+ "human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget. Record the exact published version." },
374
+ "emits": [ { "name": "publishedVersion", "type": "version" } ] },
762
375
  { "id": "open-pr-c", "kind": "agent",
763
- "agent": { "jobType": "senior:feature", "repository": "acme/repo-3", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
376
+ "agent": { "jobType": "senior:feature", "repository": "acme/repo-3", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open a PR." },
377
+ "emits": [ { "name": "pr", "type": "pr" } ] },
764
378
  { "id": "merge-c", "kind": "wait",
765
- "wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" },
379
+ "wait": { "kind": "pr", "target": "open-pr-c.pr", "match": { "prState": "merged" },
766
380
  "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } }
767
381
  ],
768
382
  "edges": [
769
383
  { "from": "merge-a", "to": "undraft-merge-b" },
770
384
  { "from": "undraft-merge-b", "to": "manual-publish" },
771
385
  { "from": "manual-publish.publishedVersion", "to": "open-pr-c" },
772
- { "from": "open-pr-c", "to": "merge-c" }
386
+ { "from": "open-pr-c.pr", "to": "merge-c" }
773
387
  ]
774
388
  }
775
389
  ```
776
390
 
777
- `compile` returns this preview (abridged):
778
-
779
- ```
780
- diagram (mermaid flowchart):
781
- n4["agent: undraft-merge-b"] --> n0["human: manual-publish"]
782
- n0 -- "publishedVersion" --> n3["agent: open-pr-c"]
783
- n1["wait: merge-a"] --> n4
784
- n3 --> n2["wait: merge-c"]
785
-
786
- humanNodes: [ { nodeId: "manual-publish", emits: [ { name: "publishedVersion", type: "version" } ], … } ]
787
- sideEffects: [ { nodeId: "open-pr-c", kind: "agent", … }, { nodeId: "undraft-merge-b", kind: "agent", … } ]
788
- ```
391
+ ### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge` / `merge-main`)
789
392
 
790
- Two side-effecting `agent` nodes ⇒ the compile door **stages** the proposal and hands you a
791
- `digest` + `reviewUrl`; ask an operator to preview and **Dispatch** it in the cockpit. Once
792
- they do, the graph runs to `manual-publish`, parks it on the Tasks inbox (`now do X`), and —
793
- once a human (or agent) completes it with the `publishedVersion` — binds that fact into
794
- `open-pr-c` and carries on to `merge-c`.
393
+ A connector with one of these targets enrolls a PR in the same convergence loop as §1
394
+ (idempotent; never double-enrolls). Payload: `{ pr, autoMerge?, dependsOn? }`.
795
395
 
796
- To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr` watch, make
797
- the consumer a `wait` node with `kind: "capability"` (resolving *which published
798
- `pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
799
- fact — the fact-edge syntax is identical.
396
+ - **`converge`**: review only, stop at `converged`.
397
+ - **`converge-merge`**: converge, then merge onto the PR's own base (inside an epic, that is the
398
+ integration branch).
399
+ - **`merge-main`**: same mechanics, for landing the graph's integration PR on `main`.
800
400
 
801
- ### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge` / `merge-main`)
802
-
803
- A `connector` node with **`target: "converge-merge"`** (or **`"converge"`** / **`"merge-main"`**)
804
- enrolls an agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
805
- standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
806
- the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
807
- only job was "go run convergence yourself".
808
-
809
- - **`converge-merge`** — the **unit-level** land: drive review convergence **and then the merge
810
- loop**, landing the PR onto **its own base branch** (for a unit inside an epic that base is the
811
- epic integration branch, never `main` directly — ADR 0003 base-branch admission). Equivalent to a
812
- submit with `autoMerge: true`.
813
- - **`merge-main`** — the **graph-level** top-level land: the second level of the two-level merge
814
- (ADR 0006 §3), landing the graph/epic **integration** PR onto **`main`**. Dispatch-identical to
815
- `converge-merge` (both enroll + merge); the distinction is the *level*, kept a first-class literal
816
- so the two levels are authored explicitly rather than left emergent.
817
- - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
818
- handing off to the merge loop (equivalent to `autoMerge: false`).
819
-
820
- > **converge/merge are cell POLICY, not raw nodes.** A raw `senior:converge` / `senior:merge`
821
- > **agent** job is **not expressible** — the compiler rejects it (`raw-converge-node`). Express
822
- > "get to green, then land" via a cell node's first-class **`agent.converge` / `agent.merge`**
823
- > policy flags (`merge` requires `converge`), or, for enrolling an already-open PR, the
824
- > `connector` targets above (ADR 0006 §3 / S5).
825
-
826
- **Payload:** `{ pr: "owner/repo#123", autoMerge?: boolean, dependsOn?: string[] }`. `pr` is
827
- required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
828
- `autoMerge` defaults from the target and may be overridden per-node; `dependsOn` is unioned into
829
- the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
830
- at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
831
- redelivery never double-enrolls.
832
-
833
- **Canonical shape** — the agent opens the PR, emits it as a typed **`pr` fact**, and the connector
834
- and `wait[pr, merged]` gate **late-bind** that fact (no hardcoded PR number, no human node). The
835
- author never knows the PR number at compose time, so reference it by fact:
401
+ The PR number is unknown when you write the graph, so have the agent **emit a `pr` fact** and
402
+ reference it as `"<node>.pr"`. The reference needs a threading edge from that fact, otherwise
403
+ compile rejects it (`unbound-pr`). A connector with no `payload.pr` binds the single incoming
404
+ `pr` fact, and a literal `owner/repo#N` also works.
836
405
 
837
406
  ```json
838
407
  {
839
408
  "name": "open → converge+merge → wait merged",
840
409
  "nodes": [
841
410
  { "id": "open", "kind": "agent",
842
- "agent": { "jobType": "senior:feature", "repository": "acme/repo", "prompt": "Implement the change in acme/repo and open a PR." },
411
+ "agent": { "jobType": "senior:feature", "repository": "acme/repo", "prompt": "Implement the change and open a PR." },
843
412
  "emits": [ { "name": "pr", "type": "pr" } ] },
844
- { "id": "land", "kind": "connector",
845
- "connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
413
+ { "id": "land", "kind": "connector", "connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
846
414
  { "id": "merged", "kind": "wait",
847
415
  "wait": { "kind": "pr", "target": "open.pr", "match": { "prState": "merged" },
848
416
  "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } }
849
417
  ],
850
- "edges": [
851
- { "from": "open.pr", "to": "land" },
852
- { "from": "open.pr", "to": "merged" }
853
- ]
418
+ "edges": [ { "from": "open.pr", "to": "land" }, { "from": "open.pr", "to": "merged" } ]
854
419
  }
855
420
  ```
856
421
 
857
- The `merged` gate carries an explicit **`poll`** (re-probe every 5 minutes, budget 3 days:
858
- `timeoutMs: 259200000`) because a `wait[pr, merged]` waits on a human-paced merge — **omitting
859
- `poll` inherits the 30-minute default** (§9.1) and escalates mid-review. Set `poll.timeoutMs`
860
- to a realistic budget on any merge/epic gate.
861
-
862
- The `pr` fact is threaded along the **fact-qualified edges** (`open.pr → land`, `open.pr → merged`) —
863
- those edges are what carry the observed PR into each consumer, so they are **required** when you
864
- reference `open.pr` (the validator rejects a reference with no threading edge, `unbound-pr`). Three
865
- ways to name the target PR:
866
-
867
- - **fact reference** `"<node>.pr"` — late-bound from the upstream `agent`'s emitted `pr` fact (the
868
- shape above). The referenced fact must be declared **`pr`-typed** and threaded on an incoming edge.
869
- - **omitted** (connector only) — `payload` without a `pr` auto-binds the **single** incoming `pr`
870
- fact, so `"connector": { "target": "converge-merge" }` works when exactly one `pr` fact flows in.
871
- - **literal** `"owner/repo#N"` — still accepted for a PR you already know (a real ref is never
872
- `<node>.pr`-shaped, so it never collides with a reference).
873
-
874
- `senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
875
- on the agent node is all it takes to publish it (issue #548).
876
-
877
- > **Don't hand-author this shape — generate it.** When your intent is simply "sequence these
878
- > issues, each implemented → converged → merged (optionally behind a gate)", call the
879
- > **`sequenceIssues`** door instead of assembling the nodes/edges by hand. Its body is the intent
880
- > `{ "issues": ["owner/repo#A", "owner/repo#B", …] }` with an optional leading `"behind": "owner/repo#NN"`
881
- > gate, and it GENERATES
882
- > exactly the canonical chain above — for each issue `agent` (`senior:feature`, emits `pr`) →
883
- > `connector[converge-merge]` → `wait[pr, merged]` with a realistic `poll.timeoutMs`, threading the
884
- > `pr` fact, plus an optional leading `wait[epic]` gate (§9.5) when `behind` is given — then STAGES it
885
- > through the same compile+stage path as `compileDeliveryGraph` (it never dispatches). The issues run
886
- > in **sequence**: each issue's implementation starts once the prior issue has merged.
422
+ > **Don't hand-write that chain. Generate it.** To run issues one after another (each
423
+ > implemented, converged, then merged), call **`sequenceIssues`**
424
+ > (`POST __BASE__/actions/start/sequence-issues`, body
425
+ > `{ "issues": ["owner/repo#A", "owner/repo#B"], "behind"?: "owner/repo#NN" }`). It builds the
426
+ > chain above for each issue, with an optional leading `wait[epic]` gate, and stages it through
427
+ > compile (it never dispatches).
887
428
 
888
429
  ### 9.5 Gate a graph on an epic reaching "fully merged" (`wait[epic]`)
889
430
 
890
- Sometimes the thing you must wait for is not one PR but a **whole epic** — an nwf
891
- `plan-fanout` that fans many slice PRs across waves whose numbers are unknown at compose
892
- time. The **`wait` kind `epic`** (issue #568) gates on that epic reaching **"fully merged"**
893
- (every opened slice landed), keyed by its durable **`planKey`** (`owner/repo#NN` — the epic
894
- issue), so *"start feature B once epic A has fully landed"* is an automated edge, not a human
895
- babysitting a `confirm` gate.
431
+ `kind: "epic"` waits until every slice PR of an epic (or the PR of a feature run) has landed.
432
+ `target` is the **`planKey`** (`owner/repo#NN`, the root issue), not an engine key, so it
433
+ survives a resubmit. `match.epicState` is `merged` (default) or `done`. A failed or abandoned
434
+ epic never reports merged, so the bounded wait routes via `onTimeout`. On a match it binds
435
+ `prCount`.
896
436
 
897
437
  ```json
898
- {
899
- "name": "start #567 once epic #488 has fully merged",
900
- "nodes": [
901
- { "id": "gate-epic", "kind": "wait",
902
- "wait": { "kind": "epic", "target": "nanobpm/nano-ide#488",
903
- "match": { "epicState": "merged" },
904
- "poll": { "everyMs": 300000, "timeoutMs": 259200000 },
905
- "onTimeout": "escalate" },
906
- "emits": [ { "name": "prCount", "type": "number" } ] },
907
- { "id": "start-b", "kind": "agent",
908
- "agent": { "jobType": "senior:feature", "repository": "nanobpm/nano-workforce", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
909
- ],
910
- "edges": [ { "from": "gate-epic", "to": "start-b" } ]
911
- }
438
+ { "id": "gate-epic", "kind": "wait",
439
+ "wait": { "kind": "epic", "target": "nanobpm/nano-ide#488", "match": { "epicState": "merged" },
440
+ "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" },
441
+ "emits": [ { "name": "prCount", "type": "number" } ] }
912
442
  ```
913
443
 
914
- Semantics:
915
-
916
- - **`target` is the `planKey`** (`owner/repo#NN`, the epic issue) — the *stable business id*,
917
- not the engine `processInstanceKey` (`64200`), so a resubmit/replay still resolves.
918
- - **`match.epicState`** is `merged` (default) or its synonym `done` — both mean "every opened
919
- slice landed". The gate reads the app's own **aggregate** (the lineage read-model over
920
- `NANO_WORKFORCE_BASE_URL`), so it is level-triggered like `pr` (no missed edge).
921
- - **A failed/abandoned/mixed epic never reports merged**, so it never falsely releases the
922
- gate; the **bounded** wait elapses and routes via **`onTimeout`** (`escalate`/`continue`) —
923
- it does **not** hang.
924
- - **Set `poll.timeoutMs` to a realistic budget.** An epic reaching "fully merged" is a
925
- multi-day, human-paced event, so the example gives it `poll: { everyMs: 300000, timeoutMs:
926
- 259200000 }` (re-probe every 5 minutes, budget 3 days). **Omitting `poll` inherits the
927
- 30-minute default** (§9.1) — the gate would escalate long before the epic lands. Because this
928
- gate binds `prCount`, that escalation is **validated** (§9.2): it **re-parks** unless the
929
- operator supplies a valid `prCount`, rather than silently releasing `start-b` as not-ready.
930
- - On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
931
- output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
932
- - **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
933
- lineage thread whose **`rootRequestKey`** matches `target` **regardless of the thread's `kind`**
934
- (`feature` | `epic` | `pr` | `delivery`), and `app/lineage.ts` lands a *feature* thread on
935
- `stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
936
- observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
937
- way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
938
-
939
444
  ---
940
445
 
941
446
  ## 10. Tool ↔ curl crosswalk (MCP-first, curl is the no-MCP fallback)
942
447
 
943
- When you drive this instance over MCP, **call the projected tool** — every recipe above
944
- leads with it. `curl __BASE__/…`/`curl __ENGINE__/…` is only the **no-MCP fallback** for an
945
- agent that has no tools (see the runbook, `docs/mcp-runbook.md` §2/§3, and [§5
946
- Fallback](mcp-runbook.md#5-fallback)). This table maps every guide action to its projected
947
- tool name and its no-MCP fallback underneath (usually a curl door; a few rows fall back to reading a repo file instead).
948
-
949
- App-owned operations (projected from this app's `openapi.yaml`; the `__BASE__` control API).
950
- Rows tagged *(projected by sibling #NNN)* name a tool that lands with that sibling task —
951
- until it does, only the curl door in the third column exists on the deployment:
952
-
953
- | Guide action | Projected tool | No-MCP fallback |
954
- |---|---|---|
955
- | Which code is live (§0) | `getVersion` | `curl __BASE__/version` |
956
- | Every PR in flight (§0/§5) | `listActivePrs` | `curl __BASE__/status` |
957
- | List open escalations (§3) | `listEscalations` *(projected by sibling #666; curl door until then)* | `curl __BASE__/../../tasks/api/tasks` (the answerable list — carries each `userTaskKey`); `/status` filtered with the jq recipe just below this table is only a quick *indicator* (it surfaces `openEscalation` as a question string, **no `userTaskKey`**, so you can't answer from it) |
958
- | Answer an escalation (§3) | `completeUserTask` (agent-assignee: `agentCompleteEscalation`) | `curl -X POST __BASE__/actions/complete-user-task` |
959
- | Cancel an instance (record-consistent) (§7) | `cancelInstance` *(projected by sibling #667; curl door until then)* | `curl -X POST __BASE__/../actions/cancel` *(the `/app/actions/cancel` door, outside `__BASE__`)* |
960
- | Publish a BPMN message (§7) | `postMessage` | `curl -X POST __BASE__/actions/message` |
961
- | The operator guide itself | `getAgentInstructions` (full) / `getAgentGuide(section?)` (addressable) | `curl __BASE__/agent` / `curl __BASE__/agent/guide` |
962
-
963
- The `listEscalations` no-MCP fallback that filters `/status` with `jq` lives here, not inside the
964
- table cell above: a literal `|` can't be written in a GFM table cell without escaping it as `\|`,
965
- and this guide is served **raw** (byte-for-byte, `__BASE__`/`__ENGINE__` aside) to no-MCP agents, so
966
- the escaped form would be copied verbatim and the shell would treat `\|` as a literal argument
967
- rather than a pipe. It's only a **quick indicator** — `/status` surfaces `openEscalation` as a
968
- question string with **no `userTaskKey`**, so to actually *answer* one you still need the tasks
969
- inbox (`curl __BASE__/../../tasks/api/tasks`) or the `listEscalations` tool for its `userTaskKey`
970
- (§3). It selects the same `{ prKey, status, round, openEscalation }` fields as the §3 fallback for
971
- consistency. Copy it from here:
972
-
973
- ```sh
974
- curl __BASE__/status | jq '.prs[] | select(.openEscalation != null)
975
- | { prKey, status, round, openEscalation }'
976
- ```
977
-
978
-
979
- Framework engine-debug tools (owned by the nano-ide urban runtime, not this app; they front
980
- the engine's Camunda-8 v2 REST API at `__ENGINE__`). The first three are always projected;
981
- `urban_debug_search_jobs` / `urban_debug_search_variables` /
982
- `urban_debug_get_process_definition_xml` are owned by the sibling nano-ide urban epic — use
983
- them by name where the framework projects them, else drop to the curl door:
984
-
985
- | Guide action | Projected tool | No-MCP fallback |
448
+ | Action | Tool | No-MCP fallback |
986
449
  |---|---|---|
987
- | Search process instances (§5) | `urban_debug_search_process_instances` | `curl -X POST __ENGINE__/process-instances/search` |
988
- | Inspect element/wait states (§5) | `urban_debug_search_element_instance_wait_states` | `curl -X POST __ENGINE__/element-instances/search` |
989
- | Active incidents (§5) | `urban_debug_search_incidents` | `curl -X POST __ENGINE__/incidents/search` |
990
- | Jobs on an instance (§5) | `urban_debug_search_jobs` | `curl -X POST __ENGINE__/jobs/search` |
991
- | Instance variables (§5) | `urban_debug_search_variables` | `curl -X POST __ENGINE__/variables/search` |
992
- | Deployed BPMN model XML (§6) | `urban_debug_get_process_definition_xml` | read the source copy `resources/processes/*.bpmn` from the checkout |
993
- | Engine-level cancel — **avoid** (§7) | `urban_debug_cancel_instance` | *(use the app-owned `cancelInstance` instead — engine cancel leaves the PR row inconsistent)* |
994
-
995
- **Rule of thumb (agent-guide §5 fallback discipline):** if a row has a projected tool, use
996
- it; reach for its curl door only when you are genuinely not on MCP (or the tool's sibling
997
- task has not yet landed — see the *(projected by sibling #NNN)* tags above). The app-owned
998
- cancel door (`cancelInstance`, #667) is always preferred over the engine-level
999
- `urban_debug_cancel_instance`,
1000
- because only the app door transitions the PR/plan record to `abandoned`.
450
+ | Live version (§0) | `getVersion` | `GET __BASE__/version` |
451
+ | PRs in flight (§0) | `listActivePrs` | `GET __BASE__/status` |
452
+ | Submit a PR / epic / feature (§1–2) | `startConvergenceLoop` / `startPlanFanout` / `startFeature` | `POST __BASE__/actions/start/{convergence-loop,plan-fanout,feature}` |
453
+ | List escalations (§3) | `listEscalations` | `GET __BASE__/escalations` |
454
+ | Answer an escalation (§3) | `completeUserTask` (as agent: `agentCompleteEscalation`) | `POST __BASE__/actions/complete-user-task` (as agent: `POST __BASE__/hooks/agent-complete` with `agentId` required) |
455
+ | Auto-resolutions (§3) | `listAutoResolutions` / `reviewAutoResolution` | `GET __BASE__/auto-resolutions` / `POST __BASE__/auto-resolutions/review` |
456
+ | Cancel a run (§7) | `cancelInstance` | `POST __BASE__/actions/cancel` |
457
+ | Publish a BPMN message | `postMessage` | `POST __BASE__/actions/message` |
458
+ | Delivery graphs (§9) | `getDeliveryGraphVocabulary` / `compileDeliveryGraph` / `sequenceIssues` | `GET __BASE__/delivery-graph/vocabulary`, `POST __BASE__/actions/{compile-delivery-graph,start/sequence-issues}` |
459
+ | This guide | `getAgentInstructions` (full) / `getAgentGuide(section?)` | `GET __BASE__/agent` / `GET __BASE__/agent/guide` |
460
+
461
+ The engine-debug tools come from the urban framework and front `__ENGINE__` (§5).
462
+ `urban_debug_search_process_instances`, `urban_debug_search_element_instance_wait_states` and
463
+ `urban_debug_search_incidents` are always available. `urban_debug_search_jobs`,
464
+ `urban_debug_search_variables` and `urban_debug_get_process_definition_xml` exist where the
465
+ framework provides them; otherwise use the §5 curl searches or read `resources/processes/` from
466
+ the checkout. **Never use `urban_debug_cancel_instance`**: use `cancelInstance` (§7).