@nanobpm/nano-workforce 0.169.0 → 0.170.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.
@@ -25,18 +25,28 @@ in full so you can tell which are under the control-API base and which are not.
25
25
 
26
26
  ## 0. Orient yourself first
27
27
 
28
- Before acting, confirm what is running and what is in flight:
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.
29
32
 
30
- ```bash
33
+ ```text
31
34
  # Which code is live (app version, urban version, git sha/branch, uptime):
32
- curl -sS __BASE__/version | jq
35
+ tool: getVersion # GET /version
33
36
 
34
37
  # Every PR currently in flight (not converged/abandoned), with its engine
35
38
  # process key, status, round, and any open escalation:
39
+ tool: listActivePrs # GET /status
40
+ ```
41
+
42
+ ```bash
43
+ # No-MCP fallback (agent without the projected tools):
44
+ curl -sS __BASE__/version | jq
36
45
  curl -sS __BASE__/status | jq
37
46
  ```
38
47
 
39
- `/status` is your primary situational-awareness endpoint. Each entry carries:
48
+ `listActivePrs` (the `/status` endpoint) is your primary situational-awareness surface.
49
+ Each entry carries:
40
50
  `prKey` (`owner/repo#123`), `status`, `round`, `processKey` (the **engine process
41
51
  instance key** — the bridge to the engine REST API, §5), `openEscalation`,
42
52
  `activeWorker`/`leaseUntil` (is an agent actually working the round, or is the job
@@ -152,24 +162,36 @@ A loop escalates only when an agent returns `needs_input`/`blocked`, or a safety
152
162
  fires (round cap, a review that never arrives, a merge conflict, an unfixable CI
153
163
  failure). The parked process waits for a human answer.
154
164
 
155
- Find the open escalations, then answer them:
165
+ Find the open escalations, then answer them. **Prefer the `listEscalations` tool**
166
+ (the projected read tool that lists every open escalation with the `userTaskKey` and form —
167
+ tracked by sibling #666; on a deployment that predates it, use the `/status` / task-inbox
168
+ curl fallback below —
169
+ you need to answer it); curl is the no-MCP fallback.
170
+
171
+ ```text
172
+ # Every open escalation waiting for a human, across all four kinds — each with its
173
+ # userTaskKey, prKey, kind (elementId), question/findings/task, and formVariables:
174
+ tool: listEscalations
175
+ ```
156
176
 
157
177
  ```bash
158
- # Which in-flight PRs have an open escalation waiting for a human?
178
+ # No-MCP fallback: read the open escalations off /status
159
179
  curl -sS __BASE__/status | jq '.prs[] | select(.openEscalation != null)
160
180
  | { prKey, status, round, openEscalation }'
161
181
  ```
162
182
 
163
183
  The four decision-required escalation kinds — **PR review-loop**, **implementation
164
184
  (feature) task**, **plan-review**, and **trial-merge** — are now native BPMN
165
- `userTask`s bearing a linked `.form`, all answered the same way through the **task
166
- inbox** surface. There is no bespoke per-kind webhook or answer page any more.
185
+ `userTask`s bearing a linked `.form`, all answered the same way through the
186
+ `completeUserTask` tool (a human uses the same **task inbox** surface underneath).
187
+ There is no bespoke per-kind webhook or answer page any more.
167
188
 
168
- **List the open escalation tasks.** Each task carries its context (e.g. `prKey` /
169
- `question` / `findings` / `task`) in its `variables`, and its kind in `elementId`:
189
+ **List the open escalation tasks.** `listEscalations` returns each open task with its
190
+ context (`prKey` / `question` / `findings` / `task`) and its kind in `elementId` /
191
+ `kind`, so you do **not** need to reach for the raw Tasks inbox. No-MCP fallback only:
170
192
 
171
193
  ```bash
172
- # Every parked escalation, across all kinds:
194
+ # No-MCP fallback the un-projected engine task inbox (prefer `listEscalations`):
173
195
  curl -sS __BASE__/../../tasks/api/tasks | jq '.[] | { userTaskKey, elementId, variables }'
174
196
 
175
197
  # Filter to one kind (e.g. plan-review decisions) by elementId:
@@ -187,34 +209,52 @@ answer (assignee/candidate-group and age surface on each task once assignment la
187
209
  > node then runs against the unmet dependency. See §9.2 before clearing one.
188
210
 
189
211
  **Answer a task** by completing it with the typed variables its form expects — the
190
- completion resumes the parked process:
212
+ completion resumes the parked process. **Prefer the `completeUserTask` tool** (or
213
+ `agentCompleteEscalation` when you answer as an agent assignee, ADR 0046); the
214
+ `curl … /actions/complete-user-task` app-owned door is the no-MCP fallback.
191
215
 
192
- ```bash
216
+ ```text
193
217
  # PR review-loop (elementId `wait-answer`, pr-escalation form):
194
- curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
195
- -d '{ "userTaskKey": "<key>", "variables": { "answer": "Cap retries at 5 and proceed." } }'
218
+ tool: completeUserTask { "userTaskKey": "<key>",
219
+ "variables": { "answer": "Cap retries at 5 and proceed." } }
196
220
 
197
221
  # Implementation (feature) task (elementId `feature-escalation`):
198
222
  # { "resolution": "answer", "answer": "…" } to resume, or { "resolution": "abandon" }
199
- curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
200
- -d '{ "userTaskKey": "<key>", "variables": { "resolution": "answer", "answer": "Use v2." } }'
223
+ tool: completeUserTask { "userTaskKey": "<key>",
224
+ "variables": { "resolution": "answer", "answer": "Use v2." } }
201
225
 
202
226
  # Plan-review (elementId `plan-review-decision`):
203
227
  # { "directive": "revise", "notes": "…" } (fresh review budget) or { "directive": "proceed" }
204
- curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
205
- -d '{ "userTaskKey": "<key>", "variables": { "directive": "revise", "notes": "Make issue-7 the seam." } }'
228
+ tool: completeUserTask { "userTaskKey": "<key>",
229
+ "variables": { "directive": "revise", "notes": "Make issue-7 the seam." } }
206
230
 
207
231
  # Trial-merge (elementId `trial-merge-decision`):
208
232
  # { "action": "proceed" | "rebase" | "abandon", "notes"?: "…" }
209
- curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
210
- -d '{ "userTaskKey": "<key>", "variables": { "action": "rebase", "notes": "Re-run after the fix." } }'
233
+ tool: completeUserTask { "userTaskKey": "<key>",
234
+ "variables": { "action": "rebase", "notes": "Re-run after the fix." } }
235
+ ```
236
+
237
+ ```bash
238
+ # No-MCP fallback — the app-owned complete-user-task door (prefer the tool).
239
+ # `variables` must carry the shape THIS task's escalation kind expects (see the per-kind
240
+ # tool forms above): `{ answer }` here is the PR review-loop form — a feature task wants
241
+ # `{ resolution, answer }`, plan-review `{ directive, notes }`, trial-merge `{ action, notes? }`.
242
+ curl -sS -X POST __BASE__/actions/complete-user-task -H 'content-type: application/json' \
243
+ -d '{ "userTaskKey": "<key>", "variables": { "answer": "Cap retries at 5 and proceed." } }'
211
244
  ```
212
245
 
213
246
  **Answer a PR escalation** (both the review-loop `wait-answer` and the merge-loop
214
247
  `wait-merge-answer` — both are now native user tasks answered the same way, #256).
215
- Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }`:
248
+ Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }`
249
+ through the `completeUserTask` tool; curl is the no-MCP fallback:
250
+
251
+ ```text
252
+ tool: completeUserTask { "userTaskKey": "<key>",
253
+ "variables": { "answer": "Yes — cap the retries at 5 and proceed." } }
254
+ ```
216
255
 
217
256
  ```bash
257
+ # No-MCP fallback:
218
258
  curl -sS -X POST __BASE__/actions/complete-user-task \
219
259
  -H 'content-type: application/json' \
220
260
  -d '{
@@ -223,9 +263,11 @@ curl -sS -X POST __BASE__/actions/complete-user-task \
223
263
  }'
224
264
  ```
225
265
 
226
- The `userTaskKey` comes from `GET /status` or the Tasks inbox. This is the ONE
227
- canonical answer door for every escalation kind; the merge loop no longer uses a
228
- durable `escalation-answered` message catch.
266
+ The `userTaskKey` comes from `listEscalations` (or `GET /status`/the Tasks inbox). This
267
+ is the ONE canonical answer door (`completeUserTask`) for every escalation kind with
268
+ `agentCompleteEscalation` the agent-assignee variant that records identity for
269
+ attribution (ADR 0046); the merge loop no longer uses a durable `escalation-answered`
270
+ message catch.
229
271
 
230
272
  If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
231
273
 
@@ -266,10 +308,35 @@ The app stores each PR/plan's engine **process instance key** in its `process_ke
266
308
  column and surfaces it as `processKey` in `/status`. That key is the join between the
267
309
  app's business view and the engine's execution view.
268
310
 
269
- **Find the instance for a PR:** take `processKey` from `/status`, then query the
270
- engine's Camunda-8 v2 REST API:
311
+ **Find the instance for a PR:** take `processKey` from `listActivePrs` (`/status`), then
312
+ query engine truth. **Prefer the framework `urban_debug_*` engine-debug tools** (owned by
313
+ the nano-ide urban runtime — see the crosswalk in §10); the `curl __ENGINE__/…` engine
314
+ REST calls are the no-MCP fallback:
315
+
316
+ ```text
317
+ PK = <processKey from listActivePrs>
318
+
319
+ # The instance itself (state, the BPMN process it is running, start time):
320
+ tool: urban_debug_search_process_instances { "filter": { "processInstanceKey": PK } }
321
+
322
+ # Where is it parked? — active jobs on the instance (a CREATED senior:pr-review job
323
+ # with a `worker` set means an agent has leased the round; none means it is queued).
324
+ # `urban_debug_search_jobs`/`_search_variables` are projected only where the framework
325
+ # offers them (sibling nano-ide epic); else drop to the curl door (see §10 crosswalk):
326
+ tool: urban_debug_search_jobs { "filter": { "processInstanceKey": PK, "state": "CREATED" } } # where projected
327
+
328
+ # Its variables (the aggregate/job payload the engine holds):
329
+ tool: urban_debug_search_variables { "filter": { "processInstanceKey": PK } } # where projected
330
+
331
+ # Is it dead-in-the-water on a technical fault? — active incidents:
332
+ tool: urban_debug_search_incidents { "filter": { "processInstanceKey": PK, "state": "ACTIVE" } }
333
+
334
+ # Which BPMN element/wait-state is it sitting on?
335
+ tool: urban_debug_search_element_instance_wait_states { "filter": { "processInstanceKey": PK } }
336
+ ```
271
337
 
272
338
  ```bash
339
+ # No-MCP fallback — the raw engine Camunda-8 v2 REST API:
273
340
  PK=<processKey-from-status>
274
341
 
275
342
  # The instance itself (state, the BPMN process it is running, start time):
@@ -277,8 +344,7 @@ curl -sS -X POST __ENGINE__/process-instances/search \
277
344
  -H 'content-type: application/json' \
278
345
  -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
279
346
 
280
- # Where is it parked? — active jobs on the instance (a CREATED senior:pr-review job
281
- # with a `worker` set means an agent has leased the round; none means it is queued):
347
+ # Where is it parked? — active jobs on the instance:
282
348
  curl -sS -X POST __ENGINE__/jobs/search \
283
349
  -H 'content-type: application/json' \
284
350
  -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"CREATED\" } }" | jq
@@ -294,15 +360,23 @@ curl -sS -X POST __ENGINE__/element-instances/search \
294
360
  -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
295
361
  ```
296
362
 
363
+ > The `urban_debug_search_jobs`, `urban_debug_search_variables` and
364
+ > `urban_debug_get_process_definition_xml` reads are owned by the sibling nano-ide urban
365
+ > epic; where a deployment's framework has not yet projected them, use the curl fallback
366
+ > for that row. The three always-present engine reads are
367
+ > `urban_debug_search_process_instances`, `urban_debug_search_element_instance_wait_states`
368
+ > and `urban_debug_search_incidents`.
369
+
297
370
  The app already mirrors an ACTIVE incident onto the PR row (`incident`/incident
298
371
  message), so a PR that shows an incident in the UI is parked on an engine fault —
299
- inspect it with `incidents/search` above. If the engine is not at the default, the
300
- deployment's engine base is `__ENGINE__` (set via `NANOBPMN_BASE_URL` or
301
- `CAMUNDA_REST_ADDRESS`).
372
+ inspect it with `urban_debug_search_incidents` (or `incidents/search`) above. If the
373
+ engine is not at the default, the deployment's engine base is `__ENGINE__` (set via
374
+ `NANOBPMN_BASE_URL` or `CAMUNDA_REST_ADDRESS`).
302
375
 
303
376
  **Relate an instance back to a PR:** if you have a `processKey` but not the PR, match
304
- it against `/status` (`.prs[] | select(.processKey == "<PK>")`). A terminal PR is no
305
- longer in `/status`; its instance has already completed or been cancelled.
377
+ it against `listActivePrs`/`/status` (`.prs[] | select(.processKey == "<PK>")`). A
378
+ terminal PR is no longer in `/status`; its instance has already completed or been
379
+ cancelled.
306
380
 
307
381
  ---
308
382
 
@@ -311,9 +385,15 @@ longer in `/status`; its instance has already completed or been cancelled.
311
385
  The behaviour is defined by durable BPMN processes and model-authored agent prompts —
312
386
  both live in the source repo, not in the job payload.
313
387
 
314
- - **Processes:** `resources/processes/*.bpmn` `convergence-loop.bpmn` (review),
315
- `merge-loop.bpmn` (merge/CI-fix/rebase), `plan-fanout.bpmn` (planning),
316
- `retro.bpmn`. These are the source of truth for routing. To understand *why* an
388
+ - **Processes:** to inspect the model an instance is **actually running**, prefer the
389
+ framework tool **`urban_debug_get_process_definition_xml`** (owned by the sibling
390
+ nano-ide urban epic see §10), which returns the deployed BPMN XML for a process
391
+ definition; that is engine truth and cannot drift from what is live. The repo copies
392
+ `resources/processes/*.bpmn` — `convergence-loop.bpmn` (review), `merge-loop.bpmn`
393
+ (merge/CI-fix/rebase), `plan-fanout.bpmn` (planning), `retro.bpmn` — are the
394
+ **source of truth in git** for routing, read them from the checkout when you want the
395
+ authored form (or have no MCP). They can lag a running deployment, so confirm against
396
+ the deployed XML when *why did this instance route here* matters. To understand *why* an
317
397
  instance went where it did, read the gateway conditions (FEEL expressions on the
318
398
  sequence flows) for the element it is parked on (§5).
319
399
  - **Prompts (agent base instructions):** `resources/prompts/*.md` — `review-round.md`,
@@ -369,11 +449,24 @@ Work through this order:
369
449
  `NANO_PR_REVIEW_WAIT_TIMEOUT` (default `PT20M`); the poller also re-nudges the
370
450
  reviewer periodically. If the reviewer bot is not provisioned on the repo, no
371
451
  review will ever land — that is a repo-config problem, not an app bug.
372
- 5. **Cancel + resubmit** as a last resort. Cancel the instance via the app (the UI's
373
- per-row Cancel, `POST /app/actions/cancel { "processInstanceKey": "<PK>" }`), which
374
- marks the PR `abandoned`, then re-submit the PR (§1) to start a fresh loop. Do not
375
- cancel a raw engine instance out from under the app — go through the app so its
376
- record state stays consistent.
452
+ 5. **Cancel + resubmit** as a last resort. Cancel through the **app-owned** door
453
+ **prefer the `cancelInstance` tool** (projected by sibling #667; the same
454
+ record-consistent path as the UI's per-row Cancel, `POST /app/actions/cancel
455
+ { "processInstanceKey": "<PK>" }`), which cancels
456
+ the engine instance **and** marks the PR `abandoned` so it drops out of
457
+ `listActivePrs`. Then re-submit the PR (§1) to start a fresh loop. **Do not** cancel a
458
+ raw engine instance out from under the app with the engine-level
459
+ `urban_debug_cancel_instance` tool (or a direct engine REST cancel) — that cancels the
460
+ token but leaves the PR row inconsistent. Always go through `cancelInstance` /
461
+ `POST /app/actions/cancel` so the app's record state stays consistent. Note the door
462
+ sits at `/app/actions/cancel`, **outside** the `__BASE__` control-API base (ADR 0059) —
463
+ until #667 projects `cancelInstance`, that curl door is the only cancel path. No-MCP
464
+ fallback:
465
+
466
+ ```bash
467
+ curl -sS -X POST __BASE__/../actions/cancel -H 'content-type: application/json' \
468
+ -d '{ "processInstanceKey": "<PK>" }'
469
+ ```
377
470
 
378
471
  ---
379
472
 
@@ -767,3 +860,66 @@ Semantics:
767
860
  `stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
768
861
  observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
769
862
  way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
863
+
864
+ ---
865
+
866
+ ## 10. Tool ↔ curl crosswalk (MCP-first, curl is the no-MCP fallback)
867
+
868
+ When you drive this instance over MCP, **call the projected tool** — every recipe above
869
+ leads with it. `curl __BASE__/…`/`curl __ENGINE__/…` is only the **no-MCP fallback** for an
870
+ agent that has no tools (see the runbook, `docs/mcp-runbook.md` §2/§3, and [§5
871
+ Fallback](mcp-runbook.md#5-fallback)). This table maps every guide action to its projected
872
+ tool name and its no-MCP fallback underneath (usually a curl door; a few rows fall back to reading a repo file instead).
873
+
874
+ App-owned operations (projected from this app's `openapi.yaml`; the `__BASE__` control API).
875
+ Rows tagged *(projected by sibling #NNN)* name a tool that lands with that sibling task —
876
+ until it does, only the curl door in the third column exists on the deployment:
877
+
878
+ | Guide action | Projected tool | No-MCP fallback |
879
+ |---|---|---|
880
+ | Which code is live (§0) | `getVersion` | `curl __BASE__/version` |
881
+ | Every PR in flight (§0/§5) | `listActivePrs` | `curl __BASE__/status` |
882
+ | 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) |
883
+ | Answer an escalation (§3) | `completeUserTask` (agent-assignee: `agentCompleteEscalation`) | `curl -X POST __BASE__/actions/complete-user-task` |
884
+ | 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__`)* |
885
+ | Publish a BPMN message (§7) | `postMessage` | `curl -X POST __BASE__/actions/message` |
886
+ | The operator guide itself | `getAgentInstructions` (full) / `getAgentGuide(section?)` (addressable) | `curl __BASE__/agent` / `curl __BASE__/agent/guide` |
887
+
888
+ The `listEscalations` no-MCP fallback that filters `/status` with `jq` lives here, not inside the
889
+ table cell above: a literal `|` can't be written in a GFM table cell without escaping it as `\|`,
890
+ and this guide is served **raw** (byte-for-byte, `__BASE__`/`__ENGINE__` aside) to no-MCP agents, so
891
+ the escaped form would be copied verbatim and the shell would treat `\|` as a literal argument
892
+ rather than a pipe. It's only a **quick indicator** — `/status` surfaces `openEscalation` as a
893
+ question string with **no `userTaskKey`**, so to actually *answer* one you still need the tasks
894
+ inbox (`curl __BASE__/../../tasks/api/tasks`) or the `listEscalations` tool for its `userTaskKey`
895
+ (§3). It selects the same `{ prKey, status, round, openEscalation }` fields as the §3 fallback for
896
+ consistency. Copy it from here:
897
+
898
+ ```sh
899
+ curl __BASE__/status | jq '.prs[] | select(.openEscalation != null)
900
+ | { prKey, status, round, openEscalation }'
901
+ ```
902
+
903
+
904
+ Framework engine-debug tools (owned by the nano-ide urban runtime, not this app; they front
905
+ the engine's Camunda-8 v2 REST API at `__ENGINE__`). The first three are always projected;
906
+ `urban_debug_search_jobs` / `urban_debug_search_variables` /
907
+ `urban_debug_get_process_definition_xml` are owned by the sibling nano-ide urban epic — use
908
+ them by name where the framework projects them, else drop to the curl door:
909
+
910
+ | Guide action | Projected tool | No-MCP fallback |
911
+ |---|---|---|
912
+ | Search process instances (§5) | `urban_debug_search_process_instances` | `curl -X POST __ENGINE__/process-instances/search` |
913
+ | Inspect element/wait states (§5) | `urban_debug_search_element_instance_wait_states` | `curl -X POST __ENGINE__/element-instances/search` |
914
+ | Active incidents (§5) | `urban_debug_search_incidents` | `curl -X POST __ENGINE__/incidents/search` |
915
+ | Jobs on an instance (§5) | `urban_debug_search_jobs` | `curl -X POST __ENGINE__/jobs/search` |
916
+ | Instance variables (§5) | `urban_debug_search_variables` | `curl -X POST __ENGINE__/variables/search` |
917
+ | Deployed BPMN model XML (§6) | `urban_debug_get_process_definition_xml` | read the source copy `resources/processes/*.bpmn` from the checkout |
918
+ | Engine-level cancel — **avoid** (§7) | `urban_debug_cancel_instance` | *(use the app-owned `cancelInstance` instead — engine cancel leaves the PR row inconsistent)* |
919
+
920
+ **Rule of thumb (agent-guide §5 fallback discipline):** if a row has a projected tool, use
921
+ it; reach for its curl door only when you are genuinely not on MCP (or the tool's sibling
922
+ task has not yet landed — see the *(projected by sibling #NNN)* tags above). The app-owned
923
+ cancel door (`cancelInstance`, #667) is always preferred over the engine-level
924
+ `urban_debug_cancel_instance`,
925
+ because only the app door transitions the PR/plan record to `abandoned`.
@@ -91,7 +91,14 @@ family). Ask:
91
91
 
92
92
  > *"Using workforce-local, show what's in flight and any open escalations."*
93
93
 
94
- The agent should call the status operation tool, not curl.
94
+ The agent should call the status operation tool (`listActivePrs`), not curl. The operator
95
+ guide's **tool↔curl crosswalk** (agent-guide §10, via `getAgentGuide(section="tool-crosswalk")`)
96
+ maps every guide action to its projected tool name and the curl door underneath — so a
97
+ tool-aware agent leads with the tool (`listActivePrs`, `getVersion`, `completeUserTask`,
98
+ the `urban_debug_*` engine reads — plus `listEscalations` and `cancelInstance` *where the
99
+ deployment projects them*; those two are sibling tasks #666/#667 and are not on a
100
+ deployment that predates them, so fall back to the curl door there) and treats curl as
101
+ the no-MCP fallback only.
95
102
 
96
103
  ## 3. Debug a wedged instance
97
104
 
@@ -99,10 +106,19 @@ The agent should call the status operation tool, not curl.
99
106
  > instance, compare engine truth against the app's projections, and tell me where
100
107
  > it's stuck."*
101
108
 
102
- The agent has: instance search, wait states, variables, incidents (engine truth) +
109
+ The agent has: instance search, wait states, incidents (engine truth) — plus
110
+ `variables` where the framework projects the `urban_debug_search_variables` read
111
+ (otherwise via the engine curl fallback) — plus
103
112
  the `urban_*` projection reads (app belief) + the operator guide (the
104
113
  `getAgentInstructions` tool) for the convergence-loop-specific meaning of each wedge
105
- shape. A wedge is frequently exactly a disagreement between the two planes.
114
+ shape. A wedge is frequently exactly a disagreement between the two planes. The guide's
115
+ **tool↔curl crosswalk** (agent-guide §10) names each engine-truth tool
116
+ (`urban_debug_search_process_instances` / `urban_debug_search_element_instance_wait_states`
117
+ / `urban_debug_search_incidents`, and where projected `urban_debug_search_jobs` /
118
+ `urban_debug_search_variables` / `urban_debug_get_process_definition_xml`) so the agent
119
+ reaches for the tool, not the raw `__ENGINE__` curl — and steers cancels to the app-owned
120
+ `cancelInstance` (projected by sibling #667; until it lands, the `/app/actions/cancel`
121
+ door the UI's Cancel uses), never the record-desyncing engine-level `urban_debug_cancel_instance`.
106
122
 
107
123
  ## 4. Guard posture
108
124
 
@@ -166,7 +182,8 @@ limit. Over MCP, prefer the **addressable** companion tool `getAgentGuide(sectio
166
182
 
167
183
  - **No argument** → a compact **table of contents**: every stable section id
168
184
  (`orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`, `debug`,
169
- `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`) with a one-line summary.
185
+ `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`, `tool-crosswalk`) with a
186
+ one-line summary.
170
187
  - **`section=<id>`** → **only** that section's markdown, small enough to fit a typical
171
188
  limit. An unknown id is rejected with `issues[{path,message}]` listing the valid ids.
172
189
 
@@ -28,6 +28,8 @@ import { dirname, join, resolve } from "node:path";
28
28
  import { after, before, describe, test } from "node:test";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
31
+ import { pollUserTasks } from "../app/service.ts";
32
+ import { asEngineClient } from "./support/engine-client.ts";
31
33
 
32
34
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
35
  const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u4-"));
@@ -49,7 +51,11 @@ interface InboxTask {
49
51
  }
50
52
 
51
53
  interface StatusBody {
52
- prs: Array<{ prKey: string; status: string; openEscalation: string | null }>;
54
+ prs: Array<{
55
+ prKey: string;
56
+ status: string;
57
+ openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null;
58
+ }>;
53
59
  }
54
60
 
55
61
  interface TakenFlow {
@@ -149,17 +155,20 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
149
155
  assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
150
156
  assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
151
157
 
152
- // The open escalation is DERIVED from the durable `escalations` audit row on the status
153
- // endpoint — no denormalised `open_escalation_*` pointer is written or read.
158
+ // The open escalation is DERIVED from the `user_tasks` read model on the status endpoint — no
159
+ // denormalised `open_escalation_*` pointer is written or read. That read model is projected by
160
+ // `pollUserTasks` (part of the imperative main.ts poll loop the testkit does not run), so drive it
161
+ // explicitly (mirrors feature-run.e2e.ts) to project the parked `wait-answer` task first.
162
+ await pollUserTasks(app.db, asEngineClient(app.engine));
154
163
  const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
155
164
  assert.equal(status.status, 200, "the status endpoint responds");
156
165
  const statusRow = status.body.prs.find((p) => p.prKey === prKey);
157
166
  assert.ok(statusRow, "the escalated PR is listed as active");
158
167
  assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
159
168
  assert.equal(
160
- statusRow?.openEscalation,
169
+ statusRow?.openEscalation?.summary,
161
170
  "Which retry cap?",
162
- "the open escalation question is derived from the open escalations row",
171
+ "the open escalation question is derived from the user_tasks read model",
163
172
  );
164
173
 
165
174
  // Complete the escalation through the taskInbox completion route with the typed `answer`.
@@ -185,8 +194,11 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
185
194
  assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
186
195
  assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
187
196
 
188
- // The escalations row was retired to `answered` (the single source of truth the status endpoint
189
- // derives from), so no open escalation lingers on the status endpoint once answered + resumed.
197
+ // The escalations row was retired to `answered` and the completed task's `user_tasks` row
198
+ // reconciles away, so no open escalation lingers on the status endpoint once answered + resumed.
199
+ // Re-run the projection (as the durable poller would) so the closed task's read-model row is
200
+ // deleted before re-reading /status.
201
+ await pollUserTasks(app.db, asEngineClient(app.engine));
190
202
  const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
191
203
  const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
192
204
  if (afterRow) {
@@ -25,6 +25,8 @@ import { dirname, join, resolve } from "node:path";
25
25
  import { after, before, describe, test } from "node:test";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
28
+ import { pollUserTasks } from "../app/service.ts";
29
+ import { asEngineClient } from "./support/engine-client.ts";
28
30
 
29
31
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
32
  const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u7-"));
@@ -46,7 +48,11 @@ interface InboxTask {
46
48
  }
47
49
 
48
50
  interface StatusBody {
49
- prs: Array<{ prKey: string; status: string; openEscalation: string | null }>;
51
+ prs: Array<{
52
+ prKey: string;
53
+ status: string;
54
+ openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null;
55
+ }>;
50
56
  }
51
57
 
52
58
  interface TableInfoRow {
@@ -227,14 +233,18 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
227
233
  assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
228
234
  assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
229
235
 
230
- // The open escalation is DERIVED from the durable audit row — no denormalised pointer is written.
236
+ // The open escalation is DERIVED from the `user_tasks` read model — no denormalised pointer is
237
+ // written. That read model is projected by `pollUserTasks` (part of the imperative main.ts poll
238
+ // loop the testkit does not run), so drive it explicitly (mirrors feature-run.e2e.ts) to project
239
+ // the parked `wait-answer` task before reading /status.
240
+ await pollUserTasks(app.db, asEngineClient(app.engine));
231
241
  const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
232
242
  const statusRow = status.body.prs.find((p) => p.prKey === prKey);
233
243
  assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
234
244
  assert.equal(
235
- statusRow?.openEscalation,
245
+ statusRow?.openEscalation?.summary,
236
246
  "Which retry cap?",
237
- "the open escalation question is derived from the escalations audit row",
247
+ "the open escalation question is derived from the user_tasks read model",
238
248
  );
239
249
 
240
250
  // Answer through the inbox completion route with the typed `answer` (the re-issued-new path).
@@ -252,7 +262,10 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
252
262
  assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
253
263
  assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
254
264
 
255
- // No addressed escalation lingers — the audit row is the single source of truth.
265
+ // No addressed escalation lingers — the completed task's `user_tasks` row reconciles away. Re-run
266
+ // the projection (as the durable poller would) so the closed task's read-model row is deleted
267
+ // before re-reading /status.
268
+ await pollUserTasks(app.db, asEngineClient(app.engine));
256
269
  const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
257
270
  const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
258
271
  if (afterRow) {