@nanobpm/nano-workforce 0.40.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.41.0](https://github.com/nanobpm/nano-workforce/compare/v0.40.2...v0.41.0) (2026-08-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **api:** serve an agent operator guide at GET /app/api/agent ([#118](https://github.com/nanobpm/nano-workforce/issues/118)) ([6768407](https://github.com/nanobpm/nano-workforce/commit/67684075cfe4706db7dfc63f8c536990254a7480))
7
+
1
8
  ## [0.40.2](https://github.com/nanobpm/nano-workforce/compare/v0.40.1...v0.40.2) (2026-08-11)
2
9
 
3
10
 
package/README.md CHANGED
@@ -279,6 +279,26 @@ per-instance context (e.g. a human's escalation answer) is appended by the harne
279
279
 
280
280
  ---
281
281
 
282
+ ## Point an agent at it (self-serve guide)
283
+
284
+ The running app serves a live **agent operator guide** at
285
+ `GET /app/api/agent` — how to submit PRs (review-only vs. merge), hand over an epic,
286
+ answer escalations, and **debug** the system (find engine instances, relate them to
287
+ PRs, inspect the models/prompts, unstick stuck processes, and raise issues/PRs). Its
288
+ examples are keyed to the instance you fetched it from, so a coding agent can drive
289
+ **and** debug the workforce with no extra context:
290
+
291
+ ```bash
292
+ curl -sS http://localhost:3000/app/api/agent | jq -r .instructions
293
+ ```
294
+
295
+ Like `/version` and `/status`, this endpoint honours the optional
296
+ `NANO_PR_WEBHOOK_SECRET` guard (`X-Hook-Secret` header): when that secret is set it
297
+ returns `401` without the matching header; unset = open. The source lives in
298
+ `resources/agent-guide.md`.
299
+
300
+ ---
301
+
282
302
  ## Architecture & contributing
283
303
 
284
304
  - **[SPEC.md](SPEC.md)** — the behavioural source of truth for the processes.
@@ -0,0 +1,64 @@
1
+ // The agent operator guide served by GET /app/api/agent (operationId `getAgentInstructions`).
2
+ //
3
+ // The guide itself is authored as plain markdown in `resources/agent-guide.md` (kept out of
4
+ // `prompts/` so it is NOT treated as a deployable agent template) and read from the checkout at
5
+ // module load — same "run the .ts sources directly, inspect the working tree at runtime" approach
6
+ // as version.ts. Two placeholders are substituted per request/deployment so the embedded examples
7
+ // are copy-pasteable against THIS instance:
8
+ // • __BASE__ → the app control-API base the caller reached us on (e.g. https://host/app/api)
9
+ // • __ENGINE__ → the engine's Camunda-8 v2 REST base this app is configured to talk to
10
+ //
11
+ // Reading is best-effort: a missing file yields a short built-in fallback rather than throwing, so
12
+ // the endpoint never 500s just because the doc is absent from a stripped-down deploy.
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const GUIDE_PATH = join(REPO_ROOT, "resources", "agent-guide.md");
19
+
20
+ // Read the raw guide once, at module load. Frozen for the life of the process.
21
+ const RAW_GUIDE: string = (() => {
22
+ try {
23
+ return readFileSync(GUIDE_PATH, "utf8");
24
+ } catch {
25
+ return [
26
+ "# Nano Workforce — agent operator guide",
27
+ "",
28
+ "The full guide document could not be read from this deployment.",
29
+ "",
30
+ "Key endpoints (under the app control-API base `__BASE__`):",
31
+ "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
32
+ "- `GET /version` — which code is live.",
33
+ "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
+ "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue }`).",
35
+ "- `POST /actions/message` — answer an escalation (`escalation-answered`, correlate by PR key).",
36
+ "",
37
+ "Engine (Camunda-8 v2 REST) base for debugging: `__ENGINE__`.",
38
+ "Source repository: `nanobpm/nano-workforce`.",
39
+ "",
40
+ ].join("\n");
41
+ }
42
+ })();
43
+
44
+ /**
45
+ * The engine's Camunda-8 v2 REST base this app talks to, resolved exactly as `main.ts` does:
46
+ * an explicit `CAMUNDA_REST_ADDRESS` wins, else `${NANOBPMN_BASE_URL}/v2` (default localhost:8080).
47
+ * Trailing slashes are trimmed so the guide's `__ENGINE__/jobs/search` examples are well-formed.
48
+ */
49
+ export function resolveEngineBase(): string {
50
+ const explicit = process.env.CAMUNDA_REST_ADDRESS;
51
+ const base = explicit?.trim()
52
+ ? explicit.trim()
53
+ : `${(process.env.NANOBPMN_BASE_URL ?? "http://localhost:8080").replace(/\/+$/, "")}/v2`;
54
+ return base.replace(/\/+$/, "");
55
+ }
56
+
57
+ /**
58
+ * Render the guide for a given app control-API base (e.g. "https://host/app/api"). The engine base
59
+ * is resolved from the environment. Substitutes every `__BASE__`/`__ENGINE__` occurrence.
60
+ */
61
+ export function renderAgentGuide(apiBase: string): string {
62
+ const base = apiBase.replace(/\/+$/, "");
63
+ return RAW_GUIDE.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
64
+ }
package/openapi.yaml CHANGED
@@ -137,6 +137,42 @@ components:
137
137
  type: string
138
138
  uptimeSeconds:
139
139
  type: integer
140
+ AgentInstructions:
141
+ type: object
142
+ description: The agent operator guide — how to drive (submit PRs/epics, answer escalations)
143
+ and debug (find engine instances, relate them to PRs, inspect models/prompts, unstick stuck
144
+ processes, raise issues/PRs) this Nano Workforce instance. The `instructions` markdown has
145
+ its example commands keyed to this instance's `baseUrl`/`engineBase`.
146
+ additionalProperties: false
147
+ required:
148
+ - format
149
+ - appVersion
150
+ - generatedAt
151
+ - baseUrl
152
+ - engineBase
153
+ - instructions
154
+ properties:
155
+ format:
156
+ type: string
157
+ description: The `instructions` media format. Always "markdown".
158
+ enum:
159
+ - markdown
160
+ appVersion:
161
+ type: string
162
+ nullable: true
163
+ description: The running app version this guide matches (null when unreadable).
164
+ generatedAt:
165
+ type: string
166
+ description: When this response was rendered (ISO-8601).
167
+ baseUrl:
168
+ type: string
169
+ description: The app control-API base the examples target (e.g. https://host/app/api).
170
+ engineBase:
171
+ type: string
172
+ description: The engine's Camunda-8 v2 REST base this app talks to, for debugging queries.
173
+ instructions:
174
+ type: string
175
+ description: The full operator guide as markdown.
140
176
  SubmitResult:
141
177
  type: object
142
178
  required:
@@ -387,6 +423,27 @@ paths:
387
423
  application/json:
388
424
  schema:
389
425
  $ref: "#/components/schemas/ErrorBody"
426
+ /agent:
427
+ get:
428
+ operationId: getAgentInstructions
429
+ summary: The agent operator guide (markdown) — how to submit PRs/epics, answer escalations, and
430
+ debug this instance. Point a coding agent at this URL to drive and debug the workforce.
431
+ security:
432
+ - hookSecret: []
433
+ - {}
434
+ responses:
435
+ "200":
436
+ description: The operator guide, with examples keyed to this instance.
437
+ content:
438
+ application/json:
439
+ schema:
440
+ $ref: "#/components/schemas/AgentInstructions"
441
+ "401":
442
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
443
+ content:
444
+ application/json:
445
+ schema:
446
+ $ref: "#/components/schemas/ErrorBody"
390
447
  /actions/start/convergence-loop:
391
448
  post:
392
449
  operationId: startConvergenceLoop
@@ -0,0 +1,100 @@
1
+ // Tests for GET /app/api/agent → operation `getAgentInstructions` (ADR 0058 OpenAPI surface).
2
+ // The guide markdown is served as the `instructions` field with its examples keyed to the request's
3
+ // control-API base + the configured engine base. Mirrors the getVersion test's request shape and
4
+ // shared-secret guard pattern.
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import type { AppApi } from "@nanobpm/urban";
8
+ import handler from "./getAgentInstructions.ts";
9
+
10
+ const app = {} as any as AppApi;
11
+
12
+ function input(headers: Record<string, string> = {}, path = "/app/api/agent") {
13
+ return {
14
+ req: {
15
+ method: "GET",
16
+ path,
17
+ query: new URLSearchParams(),
18
+ headers: new Headers(headers),
19
+ text: async () => "",
20
+ } as any,
21
+ params: {},
22
+ query: {},
23
+ body: undefined,
24
+ };
25
+ }
26
+
27
+ test("returns 200 with the markdown guide and metadata", async () => {
28
+ const r = (await handler(input(), app)) as any;
29
+ assertEquals(r.status, 200);
30
+ assertEquals(r.body.format, "markdown");
31
+ assert("appVersion" in r.body); // nullable, but always present
32
+ assert(typeof r.body.generatedAt === "string" && r.body.generatedAt.length > 0);
33
+ assert(typeof r.body.baseUrl === "string" && r.body.baseUrl.length > 0);
34
+ assert(typeof r.body.engineBase === "string" && r.body.engineBase.length > 0);
35
+ assert(typeof r.body.instructions === "string" && r.body.instructions.length > 200);
36
+ });
37
+
38
+ test("the guide covers every capability the endpoint promises", async () => {
39
+ const md = ((await handler(input(), app)) as any).body.instructions as string;
40
+ // Submit a PR (converge vs. merge), submit an epic, answer escalations…
41
+ assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
42
+ assert(md.includes("convergeOnly"), "documents review-only vs. merge");
43
+ assert(md.includes("start/plan-fanout"), "covers submitting an epic");
44
+ assert(md.includes("escalation-answered"), "covers answering escalations");
45
+ // …debug the system.
46
+ assert(md.includes("/jobs/search") && md.includes("/incidents/search"), "covers engine REST debugging");
47
+ assert(md.includes("processKey") || md.includes("process_key"), "relates instances to PRs");
48
+ assert(md.includes("resources/processes") && md.includes("prompts/"), "covers debugging models + prompts");
49
+ assert(md.includes("nanobpm/nano-workforce"), "covers raising issues/PRs against the repo");
50
+ });
51
+
52
+ test("examples are keyed to the request's control-API base and leave no placeholders", async () => {
53
+ const forwarded = input({ host: "wf.example.com", "x-forwarded-proto": "https" });
54
+ const md = ((await handler(forwarded, app)) as any).body.instructions as string;
55
+ const body = (await handler(forwarded, app)) as any;
56
+ assertEquals(body.body.baseUrl, "https://wf.example.com/app/api");
57
+ assert(md.includes("https://wf.example.com/app/api/version"), "base URL substituted into examples");
58
+ assert(!md.includes("__BASE__"), "no unsubstituted __BASE__ placeholder");
59
+ assert(!md.includes("__ENGINE__"), "no unsubstituted __ENGINE__ placeholder");
60
+ });
61
+
62
+ test("x-forwarded-proto is restricted to http/https", async () => {
63
+ const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
64
+ const body = (await handler(spoofed, app)) as any;
65
+ assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "unsafe scheme falls back to http");
66
+ });
67
+
68
+ test("engine base follows CAMUNDA_REST_ADDRESS / NANOBPMN_BASE_URL", async () => {
69
+ const prevCamunda = process.env["CAMUNDA_REST_ADDRESS"];
70
+ const prevBase = process.env["NANOBPMN_BASE_URL"];
71
+ try {
72
+ delete process.env["CAMUNDA_REST_ADDRESS"];
73
+ process.env["NANOBPMN_BASE_URL"] = "http://engine.internal:8080";
74
+ const r = (await handler(input(), app)) as any;
75
+ assertEquals(r.body.engineBase, "http://engine.internal:8080/v2");
76
+ assert(r.body.instructions.includes("http://engine.internal:8080/v2/jobs/search"));
77
+ } finally {
78
+ if (prevCamunda === undefined) delete process.env["CAMUNDA_REST_ADDRESS"];
79
+ else process.env["CAMUNDA_REST_ADDRESS"] = prevCamunda;
80
+ if (prevBase === undefined) delete process.env["NANOBPMN_BASE_URL"];
81
+ else process.env["NANOBPMN_BASE_URL"] = prevBase;
82
+ }
83
+ });
84
+
85
+ test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
86
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
87
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
88
+ try {
89
+ // SECRET is bound at import time, so import a cache-busted copy to observe the guard.
90
+ const mod = await import(`./getAgentInstructions.ts?guard=${Date.now()}`);
91
+ const guarded = mod.default as typeof handler;
92
+ const bad = (await guarded(input(), app)) as any;
93
+ assertEquals(bad.status, 401);
94
+ const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
95
+ assertEquals(ok.status, 200);
96
+ } finally {
97
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
98
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
99
+ }
100
+ });
@@ -0,0 +1,52 @@
1
+ // GET /app/api/agent → operationId `getAgentInstructions` (ADR 0058/0059 OpenAPI surface, base
2
+ // /app/api). Serves the agent operator guide: how to submit a PR for convergence (review-only vs.
3
+ // merge), submit an epic, answer escalations, and debug the system (find engine instances, relate
4
+ // them to PRs, inspect the models/prompts, unstick stuck processes, and raise issues/PRs). A user
5
+ // can point their coding agent at this URL and it can drive AND debug the workforce.
6
+ //
7
+ // The runtime serializes an operation body as JSON, so the markdown guide is returned as the
8
+ // `instructions` string field (alongside the app version + the base URLs the examples are keyed
9
+ // to), rather than as a raw text/markdown body. The embedded examples are rewritten to THIS
10
+ // instance's control-API base (derived from the request) and engine base (from the environment).
11
+ //
12
+ // Read-only. The optional shared-secret guard mirrors /version: enforced HERE only when
13
+ // NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
14
+
15
+ import { renderAgentGuide, resolveEngineBase } from "../app/agentGuide.ts";
16
+ import { buildVersionInfo, envVar } from "../app/version.ts";
17
+ import { defineOperation } from "../nano-generated/operations.ts";
18
+
19
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
20
+
21
+ /**
22
+ * Reconstruct the app control-API base the caller reached us on (e.g. "https://host/app/api"), so
23
+ * the guide's example commands are copy-pasteable. Honour reverse-proxy forwarding headers; fall
24
+ * back to a localhost default when the Host header is absent (e.g. a raw unit-test request).
25
+ */
26
+ function resolveApiBase(req: { path: string; headers: Headers }): string {
27
+ const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
28
+ // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
29
+ const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
30
+ const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
31
+ // The op is mounted at "<base>/agent"; strip the trailing segment to recover the base path.
32
+ const basePath = req.path.replace(/\/agent\/?$/, "") || "/app/api";
33
+ return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
34
+ }
35
+
36
+ export default defineOperation("getAgentInstructions", ({ req }) => {
37
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
38
+ return { status: 401, body: { error: "unauthorized" } };
39
+ }
40
+ const baseUrl = resolveApiBase(req);
41
+ return {
42
+ status: 200,
43
+ body: {
44
+ format: "markdown",
45
+ appVersion: buildVersionInfo().version,
46
+ generatedAt: new Date().toISOString(),
47
+ baseUrl,
48
+ engineBase: resolveEngineBase(),
49
+ instructions: renderAgentGuide(baseUrl),
50
+ },
51
+ };
52
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.40.2",
3
+ "version": "0.41.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -0,0 +1,302 @@
1
+ # Nano Workforce — agent operator guide
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).
13
+
14
+ - **App control API base:** `__BASE__`
15
+ - **Engine (Nano/Camunda-8 v2 REST) base:** `__ENGINE__`
16
+ - **Source repository:** `nanobpm/nano-workforce`
17
+
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.
23
+
24
+ ---
25
+
26
+ ## 0. Orient yourself first
27
+
28
+ Before acting, confirm what is running and what is in flight:
29
+
30
+ ```bash
31
+ # Which code is live (app version, urban version, git sha/branch, uptime):
32
+ curl -sS __BASE__/version | jq
33
+
34
+ # Every PR currently in flight (not converged/abandoned), with its engine
35
+ # process key, status, round, and any open escalation:
36
+ curl -sS __BASE__/status | jq
37
+ ```
38
+
39
+ `/status` is your primary situational-awareness endpoint. Each entry carries:
40
+ `prKey` (`owner/repo#123`), `status`, `round`, `processKey` (the **engine process
41
+ instance key** — the bridge to the engine REST API, §5), `openEscalation`,
42
+ `activeWorker`/`leaseUntil` (is an agent actually working the round, or is the job
43
+ just queued), and `updatedAt`.
44
+
45
+ ---
46
+
47
+ ## 1. Submit a PR for review convergence
48
+
49
+ One PR → one durable `convergence-loop`. Each round dispatches a `senior:pr-review`
50
+ agent; between rounds the process parks on a durable message-catch, so review latency
51
+ never holds an agent slot. The app's poller watches GitHub and republishes
52
+ `review-ready` when a new review lands.
53
+
54
+ ```bash
55
+ # Minimal: converge, then merge (if NANO_PR_AUTO_MERGE is on — the default).
56
+ curl -sS -X POST __BASE__/actions/start/convergence-loop \
57
+ -H 'content-type: application/json' \
58
+ -d '{ "pr": "owner/repo#123" }'
59
+ ```
60
+
61
+ The body is flat. Fields:
62
+
63
+ | field | type | meaning |
64
+ |---|---|---|
65
+ | `pr` (or `url`) | string | the PR — `owner/repo#123` or a full PR URL. Required. |
66
+ | `convergeOnly` | boolean | **`true` = review only.** The PR stops at `converged` and is **never** handed to the merge loop, even when `NANO_PR_AUTO_MERGE` is on. Omit / `false` = converge **then merge**. |
67
+ | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
68
+ | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
69
+
70
+ **Converge-only vs. converge-and-merge — choose deliberately:**
71
+
72
+ ```bash
73
+ # Review only — do NOT merge (use when the human wants to merge by hand,
74
+ # or is only after a review pass):
75
+ curl -sS -X POST __BASE__/actions/start/convergence-loop \
76
+ -H 'content-type: application/json' \
77
+ -d '{ "pr": "owner/repo#123", "convergeOnly": true }'
78
+
79
+ # Converge then merge, with a dependency barrier and a tighter round cap:
80
+ curl -sS -X POST __BASE__/actions/start/convergence-loop \
81
+ -H 'content-type: application/json' \
82
+ -d '{ "pr": "owner/repo#42", "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
83
+ ```
84
+
85
+ Submitting is **idempotent on the PR key** — re-POSTing the same PR refreshes the
86
+ aggregate rather than starting a duplicate loop. The response (202) echoes the
87
+ `prKey` and the engine `processKey`.
88
+
89
+ ---
90
+
91
+ ## 2. Submit an epic (plan → implement → converge)
92
+
93
+ Hand the fleet a whole issue. A planning agent decomposes it into levelized tasks; a
94
+ parallel fan-out drives one implementation agent per task (one PR each); every opened
95
+ PR is then enrolled into its own convergence loop (§1).
96
+
97
+ ```bash
98
+ curl -sS -X POST __BASE__/actions/start/plan-fanout \
99
+ -H 'content-type: application/json' \
100
+ -d '{ "issue": "owner/repo#123" }'
101
+ ```
102
+
103
+ The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL. Starting a
104
+ plan is idempotent on the plan key; an already-running plan short-circuits. The
105
+ response (202) echoes the `planKey` and engine `processKey`.
106
+
107
+ Track a plan the same way you track PRs — its `process_key` is an engine instance you
108
+ can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
109
+ loops.
110
+
111
+ ---
112
+
113
+ ## 3. Answer escalations (unblock a human-in-the-loop wait)
114
+
115
+ A loop escalates only when an agent returns `needs_input`/`blocked`, or a safety net
116
+ fires (round cap, a review that never arrives, a merge conflict, an unfixable CI
117
+ failure). The parked process waits for a human answer.
118
+
119
+ Find the open escalations, then answer them:
120
+
121
+ ```bash
122
+ # Which in-flight PRs have an open escalation waiting for a human?
123
+ curl -sS __BASE__/status | jq '.prs[] | select(.openEscalation != null)
124
+ | { prKey, status, round, openEscalation }'
125
+ ```
126
+
127
+ **Answer a PR/merge escalation** (convergence-loop or merge-loop). Use the message
128
+ name `escalation-answered`; correlate by the PR key:
129
+
130
+ ```bash
131
+ curl -sS -X POST __BASE__/actions/message \
132
+ -H 'content-type: application/json' \
133
+ -d '{
134
+ "name": "escalation-answered",
135
+ "correlationKey": "owner/repo#123",
136
+ "variables": { "answer": "Yes — cap the retries at 5 and proceed." }
137
+ }'
138
+ ```
139
+
140
+ The answer is delivered to the agent as the `answer` variable on its next round, and
141
+ the loop resumes.
142
+
143
+ **Answer an implementation-phase (feature) task escalation** raised during a
144
+ plan fan-out — a dedicated webhook operation:
145
+
146
+ ```bash
147
+ curl -sS -X POST __BASE__/hooks/feature-answer \
148
+ -H 'content-type: application/json' \
149
+ -d '{ "correlationKey": "<task-or-pr-key>", "answer": "…" }'
150
+ ```
151
+
152
+ If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
153
+
154
+ Guidance for the human you assist: read the escalation `question` first (it is the
155
+ exact blocker text the agent surfaced), decide the smallest unblocking answer, and
156
+ answer it precisely — the answer becomes the agent's next-round context.
157
+
158
+ ---
159
+
160
+ ## 4. The lifecycle & statuses (so you can reason about state)
161
+
162
+ ```
163
+ submit ──► convergence-loop
164
+ round (senior:pr-review) ──► addressed ──► wait review-ready ─┐
165
+ ├─ converged ──► finalize ──► merge-loop (unless convergeOnly)
166
+ └─ needs_input/blocked ──► escalate ──► wait escalation-answered
167
+ merge-loop: wait deps ─► arm merge ─► (queue-aware) merge / land
168
+ blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
169
+ ```
170
+
171
+ PR `status` values you will see in `/status`:
172
+ `converging` (a review round is live), `waiting_review` (parked for a fresh review),
173
+ `escalated` (waiting on a human answer), `converged`, `waiting_deps` / `waiting_merge`
174
+ / `queued` / `merging` (merge stage), `merged`, `abandoned`. A separate
175
+ `incident` signal (§5) can overlay any live status when the engine parks the token on
176
+ a technical fault.
177
+
178
+ ---
179
+
180
+ ## 5. Debug: find engine instances and relate them to PRs
181
+
182
+ The app stores each PR/plan's engine **process instance key** in its `process_key`
183
+ column and surfaces it as `processKey` in `/status`. That key is the join between the
184
+ app's business view and the engine's execution view.
185
+
186
+ **Find the instance for a PR:** take `processKey` from `/status`, then query the
187
+ engine's Camunda-8 v2 REST API:
188
+
189
+ ```bash
190
+ PK=<processKey-from-status>
191
+
192
+ # The instance itself (state, the BPMN process it is running, start time):
193
+ curl -sS -X POST __ENGINE__/process-instances/search \
194
+ -H 'content-type: application/json' \
195
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
196
+
197
+ # Where is it parked? — active jobs on the instance (a CREATED senior:pr-review job
198
+ # with a `worker` set means an agent has leased the round; none means it is queued):
199
+ curl -sS -X POST __ENGINE__/jobs/search \
200
+ -H 'content-type: application/json' \
201
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"CREATED\" } }" | jq
202
+
203
+ # Is it dead-in-the-water on a technical fault? — active incidents:
204
+ curl -sS -X POST __ENGINE__/incidents/search \
205
+ -H 'content-type: application/json' \
206
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"ACTIVE\" } }" | jq
207
+
208
+ # What are the element/flow-node instances (which BPMN element is it sitting on)?
209
+ curl -sS -X POST __ENGINE__/element-instances/search \
210
+ -H 'content-type: application/json' \
211
+ -d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
212
+ ```
213
+
214
+ The app already mirrors an ACTIVE incident onto the PR row (`incident`/incident
215
+ message), so a PR that shows an incident in the UI is parked on an engine fault —
216
+ inspect it with `incidents/search` above. If the engine is not at the default, the
217
+ deployment's engine base is `__ENGINE__` (set via `NANOBPMN_BASE_URL` or
218
+ `CAMUNDA_REST_ADDRESS`).
219
+
220
+ **Relate an instance back to a PR:** if you have a `processKey` but not the PR, match
221
+ it against `/status` (`.prs[] | select(.processKey == "<PK>")`). A terminal PR is no
222
+ longer in `/status`; its instance has already completed or been cancelled.
223
+
224
+ ---
225
+
226
+ ## 6. Debug the models and the prompts
227
+
228
+ The behaviour is defined by durable BPMN processes and model-authored agent prompts —
229
+ both live in the source repo, not in the job payload.
230
+
231
+ - **Processes:** `resources/processes/*.bpmn` — `convergence-loop.bpmn` (review),
232
+ `merge-loop.bpmn` (merge/CI-fix/rebase), `plan-fanout.bpmn` (planning),
233
+ `retro.bpmn`. These are the source of truth for routing. To understand *why* an
234
+ instance went where it did, read the gateway conditions (FEEL expressions on the
235
+ sequence flows) for the element it is parked on (§5).
236
+ - **Prompts (agent base instructions):** `prompts/*.md` — `review-round.md`,
237
+ `plan.md`, `feature.md`, `fix-ci.md`, `rebase.md`, `trial-merge.md`, etc. An
238
+ agent's base prompt is **not** a job variable: it is delivered as a model
239
+ **template header** (`{{review-round}}`, `{{plan}}`, …) substituted from these files
240
+ at deploy time. If an agent misbehaves systematically, the prompt is the first thing
241
+ to inspect/fix.
242
+ - **Job contract:** `senior:pr-review` receives `{ prUrl, repo, prNumber, round,
243
+ answer? }` and must return a flat result `{ status, summary, question? }` with
244
+ `status ∈ { converged, addressed, waiting, needs_input, blocked }`. A round that
245
+ pushes anything (including a rebase/force-push) is `addressed`; a round with an
246
+ unknown/empty result is treated as a safe `addressed` and re-enters the review wait
247
+ rather than escalating.
248
+
249
+ To validate a model/prompt change locally: `npm run layout:check` (BPMN diagram
250
+ freshness), `npm run check:prompts` (every template resolves), `npm run check`
251
+ (manifest), `npm run typecheck`, `npm run lint`, `npm test`.
252
+
253
+ ---
254
+
255
+ ## 7. Unstick a stuck process
256
+
257
+ Work through this order:
258
+
259
+ 1. **Confirm it is actually stuck.** From `/status`, a PR `converging` with an
260
+ `activeWorker` set is *working*, not stuck — an agent holds the round. No worker
261
+ for a long time means the job is queued: is a fleet `c8ctl nano work` daemon
262
+ running and subscribed to the `senior:*` task types?
263
+ 2. **Check for an incident** (§5). An ACTIVE incident parks the token; the underlying
264
+ fault must be resolved (or the instance cancelled and the work resubmitted). The
265
+ app surfaces the incident message on the PR row.
266
+ 3. **Check for an open escalation** (§3) — the process may simply be waiting for a
267
+ human answer. Answer it.
268
+ 4. **A review that never arrives** escalates on its own after
269
+ `NANO_PR_REVIEW_WAIT_TIMEOUT` (default `PT20M`); the poller also re-nudges the
270
+ reviewer periodically. If the reviewer bot is not provisioned on the repo, no
271
+ review will ever land — that is a repo-config problem, not an app bug.
272
+ 5. **Cancel + resubmit** as a last resort. Cancel the instance via the app (the UI's
273
+ per-row Cancel, `POST /app/actions/cancel { "processInstanceKey": "<PK>" }`), which
274
+ marks the PR `abandoned`, then re-submit the PR (§1) to start a fresh loop. Do not
275
+ cancel a raw engine instance out from under the app — go through the app so its
276
+ record state stays consistent.
277
+
278
+ ---
279
+
280
+ ## 8. Raise an issue or a PR against nano-workforce
281
+
282
+ When you find a genuine bug or a missing capability in the orchestration itself
283
+ (not a transient repo/agent problem), help the human file it against
284
+ `nanobpm/nano-workforce`:
285
+
286
+ - **Every change needs a tracked issue or PR** — no silent fixes. Open an issue
287
+ first if one does not exist.
288
+ - **DCO is enforced:** every commit needs a `Signed-off-by` trailer — commit with
289
+ `git commit -s` (or `git rebase --signoff`).
290
+ - **Work in a git worktree** off `origin/main`, on a `feat/*` or `fix/*` branch.
291
+ - **Author the BPMN semantics, generate the diagram.** Never hand-edit the
292
+ `bpmndi:BPMNDiagram`; run `npm run layout <file.bpmn>` and commit the result. CI
293
+ fails on stale DI.
294
+ - **Match the CI gates locally before pushing:** `npm run lint`, `npm run typecheck`,
295
+ `npm run check`, `npm run layout:check`, `npm run check:prompts`, `npm test`.
296
+ - **Copilot code review is provisioned** — drive the PR to convergence against it.
297
+ - Read `AGENTS.md` (engineering principles + gates) and `SPEC.md` (behavioural source
298
+ of truth) before proposing a change to a process.
299
+
300
+ When describing the bug, include the concrete evidence you gathered here: the
301
+ `prKey`, the engine `processKey`, the parked element / incident message (§5), and the
302
+ BPMN/prompt file you believe is responsible (§6).