@nanobpm/nano-workforce 0.40.2 → 0.42.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 +14 -0
- package/README.md +20 -0
- package/app/agentGuide.ts +64 -0
- package/openapi.yaml +57 -0
- package/operations/getAgentInstructions.test.ts +100 -0
- package/operations/getAgentInstructions.ts +52 -0
- package/package.json +2 -2
- package/pages/home.page.json +2 -1
- package/resources/agent-guide.md +302 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.42.0](https://github.com/nanobpm/nano-workforce/compare/v0.41.0...v0.42.0) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **ui:** checkbox to run convergence without auto-merge ([#121](https://github.com/nanobpm/nano-workforce/issues/121)) ([8883026](https://github.com/nanobpm/nano-workforce/commit/8883026d552855c8a25c526d22ef2aa83ac9f298)), closes [#115](https://github.com/nanobpm/nano-workforce/issues/115)
|
|
7
|
+
|
|
8
|
+
# [0.41.0](https://github.com/nanobpm/nano-workforce/compare/v0.40.2...v0.41.0) (2026-08-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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))
|
|
14
|
+
|
|
1
15
|
## [0.40.2](https://github.com/nanobpm/nano-workforce/compare/v0.40.1...v0.40.2) (2026-08-11)
|
|
2
16
|
|
|
3
17
|
|
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.
|
|
3
|
+
"version": "0.42.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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"lint:fix": "biome check --write app operations workers pages components scripts main.ts"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@nanobpm/urban": "^0.
|
|
48
|
+
"@nanobpm/urban": "^0.42.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@biomejs/biome": "^2.4.11",
|
package/pages/home.page.json
CHANGED
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"action": { "path": "/app/api/actions/start/convergence-loop", "body": "{{form}}" },
|
|
37
37
|
"fields": [
|
|
38
38
|
{ "key": "pr", "label": "owner/repo#123 or a GitHub PR URL", "type": "text" },
|
|
39
|
-
{ "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" }
|
|
39
|
+
{ "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" },
|
|
40
|
+
{ "key": "convergeOnly", "label": "Converge only — stop at review consensus, do not merge", "type": "checkbox" }
|
|
40
41
|
]
|
|
41
42
|
}
|
|
42
43
|
},
|
|
@@ -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).
|