@nanobpm/nano-workforce 0.149.0 → 0.150.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.150.1](https://github.com/nanobpm/nano-workforce/compare/v0.150.0...v0.150.1) (2026-08-28)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **startFeature:** thread probePollEvery through the readiness gate ([#579](https://github.com/nanobpm/nano-workforce/issues/579)) ([#582](https://github.com/nanobpm/nano-workforce/issues/582)) ([d8f7739](https://github.com/nanobpm/nano-workforce/commit/d8f773907e1c66292fa6799f70e8d957f6f5f626)), closes [#295](https://github.com/nanobpm/nano-workforce/issues/295)
6
+
7
+ ## [0.150.0](https://github.com/nanobpm/nano-workforce/compare/v0.149.0...v0.150.0) (2026-08-28)
8
+
9
+ ### Features
10
+
11
+ * adopt Urban MCP surface — x-mcp exclusions, runbook, MCP-first skill ([#575](https://github.com/nanobpm/nano-workforce/issues/575)) ([44e89e4](https://github.com/nanobpm/nano-workforce/commit/44e89e40b6cbff0e4c7de32dad76d35f6d534cc6)), closes [nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488) [nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488) [#567](https://github.com/nanobpm/nano-workforce/issues/567) [#567](https://github.com/nanobpm/nano-workforce/issues/567) [nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488)
12
+
1
13
  ## [0.149.0](https://github.com/nanobpm/nano-workforce/compare/v0.148.2...v0.149.0) (2026-08-27)
2
14
 
3
15
  ### Features
package/README.md CHANGED
@@ -282,7 +282,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
282
282
  | `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
283
283
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
284
284
  | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
285
- | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`X-Hook-Secret`) for guarded operations (e.g. `POST /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
285
+ | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `GET /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
286
286
  | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
287
287
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
288
288
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
@@ -452,9 +452,42 @@ curl -sS http://localhost:3000/app/api/agent | jq -r .instructions
452
452
  ```
453
453
 
454
454
  Like `/version` and `/status`, this endpoint honours the optional
455
- `NANO_PR_WEBHOOK_SECRET` guard (`X-Hook-Secret` header): when that secret is set it
455
+ `NANO_PR_WEBHOOK_SECRET` guard (`x-hook-secret` header): when that secret is set it
456
456
  returns `401` without the matching header; unset = open. The source lives in
457
- `resources/agent-guide.md`.
457
+ `docs/agent-guide.md`.
458
+
459
+ ### Configure an agent over MCP
460
+
461
+ Where your agent supports **MCP**, prefer it over the curl path above. The Urban
462
+ runtime serves a Streamable-HTTP MCP endpoint at **`/app/mcp`** for every instance and
463
+ projects this app's `openapi.yaml` into tools with **zero MCP code in nwf** — the app
464
+ operations (including the operator guide, projected from `GET /app/api/agent` as the
465
+ `getAgentInstructions` read tool), a framework-owned engine-debug tool family (process
466
+ instances, wait states, variables, incidents), the `urban_*` projection reads, and the
467
+ runtime's derived **system brief** as an MCP resource plus an orientation prompt
468
+ (ADR 0067, nano-ide#488). Register one server entry per instance and name
469
+ it when you drive — tool calls are namespaced per entry, so the wrong-instance mistake
470
+ becomes impossible:
471
+
472
+ ```bash
473
+ copilot mcp add --transport http workforce-local http://localhost:3000/app/mcp
474
+ # guarded instance: add the app secret as a header (never in chat)
475
+ copilot mcp add --transport http workforce-merlin http://merlin.local:3000/app/mcp \
476
+ --header "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"
477
+ ```
478
+
479
+ When `NANO_PR_WEBHOOK_SECRET` is unset, reads and mutations both work from loopback with
480
+ no credential; when it is set, that secret is required as an `x-hook-secret` header on
481
+ **both** — read endpoints like `GET /app/api/agent` and `GET /app/api/version` also
482
+ return `401` without it. **Operator-only doors stay operator-only** —
483
+ delivery-graph **dispatch** (and the stage/dismiss lifecycle) is `x-mcp`-excluded, so the
484
+ human clicking Dispatch in the cockpit remains the approval (ADR 0005). MCP is a **third
485
+ door**: `GET /app/api/agent` and `GET /app/api/agent/skill` are unchanged for agents
486
+ without it.
487
+
488
+ The full recipe — multiple instances, Basic-Auth-fronted instances, LAN exposure,
489
+ verification and wedged-instance debugging prompts — is the **agent-configuration
490
+ runbook**: [`docs/mcp-runbook.md`](docs/mcp-runbook.md).
458
491
 
459
492
  ---
460
493
 
package/SPEC.md CHANGED
@@ -492,7 +492,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
492
492
  | `GITHUB_TOKEN` | — | GitHub API (poller + agent) |
493
493
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
494
494
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
495
- | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`X-Hook-Secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
495
+ | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
496
496
  | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
497
497
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
498
498
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
@@ -0,0 +1,83 @@
1
+ // Authoring guard for the `x-mcp` MCP tool-exclusion switch (ADR 0067 §2, nano-ide#488 slice 3;
2
+ // adopted here as slice 4, issue #567).
3
+ //
4
+ // The runtime-served MCP surface (`/app/mcp`) projects every non-excluded `openapi.yaml` operation
5
+ // into an MCP tool. An operation opts OUT with the `x-mcp` extension — `x-mcp: false` or
6
+ // `x-mcp: { exclude: true }`; any other value (or its absence) leaves it exposed. That switch is
7
+ // security-relevant: it is the one authoring control that keeps an operator-only door off the
8
+ // agent-facing tool surface, so this test pins the intended exclusion set at the spec level.
9
+ //
10
+ // Derivation over duplication (AGENTS.md): we do NOT re-implement the exclusion rule or the
11
+ // path×method walk. We read the projection from the SAME `@nanobpm/urban` walker the runtime MCP
12
+ // module builds its live tool list from — `parseSpec` + `collectOperations`, whose `mcpExcluded`
13
+ // flag IS `isMcpExcluded` applied at parse time (openapi/spec.ts). This app-side test is therefore
14
+ // the authoring assertion that OUR operator doors carry the switch and that no agent-facing
15
+ // operation was excluded by accident, checked against the exact rule the runtime honours — no
16
+ // second source of truth to drift from it.
17
+ import { readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { test } from "node:test";
21
+ import { collectOperations, parseSpec } from "@nanobpm/urban/toolkit";
22
+ import { assert, assertEquals } from "#test-assert";
23
+
24
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
25
+ const SPEC_PATH = join(REPO_ROOT, "openapi.yaml");
26
+
27
+ // Every operation the runtime projection enumerates, with its `x-mcp` exclusion flag — read from
28
+ // the framework walker so this test and the live tool surface can never diverge.
29
+ function projectedOperations() {
30
+ return collectOperations(parseSpec(readFileSync(SPEC_PATH, "utf8")));
31
+ }
32
+
33
+ // The operator-only cockpit doors: the staged delivery-graph lifecycle (stage -> dispatch ->
34
+ // dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7). These — and ONLY
35
+ // these — must be excluded from the projected MCP tool surface.
36
+ const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"];
37
+
38
+ // A representative set of agent-facing operations that MUST stay exposed as tools — the drive doors
39
+ // (submit PR / epic / delivery-graph set), the pure compile/preview doors, and the read/orientation
40
+ // surface an agent debugging a wedged instance relies on.
41
+ const EXPECTED_EXPOSED = [
42
+ "startConvergenceLoop",
43
+ "startPlanFanout",
44
+ "startEpicSet",
45
+ "compileDeliveryGraph",
46
+ "previewDeliveryGraph",
47
+ "listStagedProposals",
48
+ "listActivePrs",
49
+ "getAgentInstructions",
50
+ "getVersion",
51
+ ];
52
+
53
+ test("x-mcp excludes exactly the operator-only delivery-graph lifecycle doors", () => {
54
+ const excluded = projectedOperations()
55
+ .filter((op) => op.mcpExcluded)
56
+ .map((op) => op.operationId)
57
+ .sort();
58
+ assertEquals(excluded, [...EXPECTED_EXCLUDED].sort());
59
+ });
60
+
61
+ test("x-mcp leaves the agent-facing drive/preview/read operations exposed", () => {
62
+ const byId = new Map(projectedOperations().map((op) => [op.operationId, op]));
63
+ for (const id of EXPECTED_EXPOSED) {
64
+ const op = byId.get(id);
65
+ assert(op, `expected operation ${id} to exist in openapi.yaml`);
66
+ assertEquals(op.mcpExcluded, false, `operation ${id} must NOT be x-mcp excluded`);
67
+ }
68
+ });
69
+
70
+ // The operator guide (workflow knowledge) is discoverable over MCP as the projected
71
+ // `getAgentInstructions` READ tool — a safe GET, so the runtime projects it as a read tool
72
+ // (not `x-mcp`-excluded) on loopback, instance-keyed by the same `resolveApiBase` rewriting
73
+ // as its HTTP route. ("Read tool" describes the projected verb, not an auth posture: the
74
+ // route still honours the app's optional `x-hook-secret` guard when `NANO_PR_WEBHOOK_SECRET`
75
+ // is set.) This is how the guide's prose reaches an MCP agent; the framework separately
76
+ // serves its derived system brief as a resource. Pin the projection facts so an accidental
77
+ // exclusion (or a verb change that would flip it into a mutation) fails CI.
78
+ test("the operator guide is projected as a read tool (GET, not x-mcp excluded) over MCP", () => {
79
+ const op = projectedOperations().find((o) => o.operationId === "getAgentInstructions");
80
+ assert(op, "getAgentInstructions must exist in openapi.yaml");
81
+ assertEquals(op.mcpExcluded, false, "the operator guide must stay exposed over MCP");
82
+ assertEquals(op.method, "get", "the operator guide must be a safe GET so it projects as a read tool");
83
+ });
@@ -0,0 +1,133 @@
1
+ # Configure an agent to drive/debug this workforce over MCP
2
+
3
+ > Adoption of the Urban runtime-served MCP surface ([ADR 0067](https://github.com/nanobpm/nano-ide/blob/main/docs/adr/0067-runtime-served-mcp-surface.md),
4
+ > nano-ide#488) — first consumer, nano-workforce#567. Written against the
5
+ > [Copilot CLI](https://github.com/github/copilot-cli) (the harness nwf's fleet
6
+ > uses). Claude/Cursor equivalents use the same server entries.
7
+
8
+ The Urban runtime serves a Streamable-HTTP MCP endpoint at **`/app/mcp`** for every
9
+ hosted app and projects this app's `openapi.yaml` into tools — **zero MCP server code
10
+ in nwf**. An MCP-capable agent gets nwf's operations (submit work, answer escalations,
11
+ read status, and the operator **guide** itself — `GET /app/api/agent`, projected as the
12
+ `getAgentInstructions` read tool), the framework-owned engine-debug tool family (process
13
+ instances, wait states, variables, incidents), the `urban_*` projection reads, and the
14
+ runtime's derived **system brief** as an MCP resource plus an orientation prompt — all
15
+ namespaced per server entry.
16
+
17
+ This replaces the SKILL.md instance-probing dance for MCP clients: naming the
18
+ instance (`"drive workforce-merlin"`) makes the wrong-instance mistake structurally
19
+ impossible. The named-instance registry (`NANO_WORKFORCE_INSTANCES` /
20
+ `~/.config/nano-workforce/instances.json`) remains the source for the fallback path
21
+ and a handy list of the entries to register here.
22
+
23
+ MCP is a **third door**, not a replacement: `GET /app/api/agent` (the live guide) and
24
+ `GET /app/api/agent/skill` are unchanged for agents without MCP — see
25
+ [§5 Fallback](#5-fallback).
26
+
27
+ ## 1. One MCP server entry per instance
28
+
29
+ In `~/.copilot/mcp-config.json` (user-wide) or `.mcp.json` (repo-scoped):
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "workforce-local": {
35
+ "type": "http",
36
+ "url": "http://localhost:3000/app/mcp",
37
+ "tools": ["*"]
38
+ },
39
+ "workforce-merlin": {
40
+ "type": "http",
41
+ "url": "http://merlin.local:3000/app/mcp",
42
+ "headers": { "x-hook-secret": "$NANO_PR_WEBHOOK_SECRET" },
43
+ "tools": ["*"]
44
+ },
45
+ "workforce-remote": {
46
+ "type": "http",
47
+ "url": "https://<subdomain>.ngrok.app/app/mcp",
48
+ "headers": { "x-hook-secret": "$NANO_PR_WEBHOOK_SECRET" },
49
+ "tools": ["*"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Or from the terminal:
56
+
57
+ ```bash
58
+ copilot mcp add --transport http workforce-local http://localhost:3000/app/mcp
59
+ # add --header for a guarded instance:
60
+ copilot mcp add --transport http workforce-merlin http://merlin.local:3000/app/mcp \
61
+ --header "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"
62
+ ```
63
+
64
+ Tool calls are namespaced per server entry, so the instance you name is the instance
65
+ you drive.
66
+
67
+ ### Instance behind Basic Auth? You need *both* headers
68
+
69
+ Two different layers. `x-hook-secret` is the **app's own** guard (checked by nwf in
70
+ the operation handler, only when `NANO_PR_WEBHOOK_SECRET` is set). **Basic Auth** is
71
+ enforced by whatever **fronts** the instance (ngrok edge, console proxy) and 401s
72
+ *before* the request ever reaches nwf:
73
+
74
+ ```json
75
+ "headers": {
76
+ "Authorization": "Basic <base64(user:pass)>",
77
+ "x-hook-secret": "..."
78
+ }
79
+ ```
80
+
81
+ Generate the blob with `printf '%s' 'user:pass' | base64` — `echo | base64` appends a
82
+ newline and yields the wrong value. The proxy must forward custom headers for
83
+ `x-hook-secret` to survive (most do by default). Base64 is encoding, not encryption:
84
+ only use Basic Auth over HTTPS. The fallback curl path needs both too:
85
+ `curl -u user:pass -H "x-hook-secret: …"`.
86
+
87
+ ## 2. Verify discovery
88
+
89
+ New agent session → the `workforce-*` tools appear (app operations + the engine-debug
90
+ family). Ask:
91
+
92
+ > *"Using workforce-local, show what's in flight and any open escalations."*
93
+
94
+ The agent should call the status operation tool, not curl.
95
+
96
+ ## 3. Debug a wedged instance
97
+
98
+ > *"workforce-local: PR nanobpm/nano-workforce#123 looks wedged — find its process
99
+ > instance, compare engine truth against the app's projections, and tell me where
100
+ > it's stuck."*
101
+
102
+ The agent has: instance search, wait states, variables, incidents (engine truth) +
103
+ the `urban_*` projection reads (app belief) + the operator guide (the
104
+ `getAgentInstructions` tool) for the convergence-loop-specific meaning of each wedge
105
+ shape. A wedge is frequently exactly a disagreement between the two planes.
106
+
107
+ ## 4. Guard posture
108
+
109
+ When `NANO_PR_WEBHOOK_SECRET` is **unset**, both reads (status, instances, incidents,
110
+ projections, the operator guide) and mutations (cancel/retry/resolve, `start/*`
111
+ operations, answering escalations) work from loopback with no credential. When it **is
112
+ set**, the guard is not mutation-only: that secret is required as an `x-hook-secret`
113
+ header on **both reads and mutations** — read endpoints like `GET /app/api/agent` and
114
+ `GET /app/api/version` also return `401` without it. Put it in the server entry's
115
+ `headers`, never in chat. For a remote fleet,
116
+ `NANO_WORKFORCE_BASE_URL` reachability rules apply unchanged, and LAN exposure of
117
+ `/app/mcp` follows the same `network.bind` manifest setting as the rest of the app's
118
+ HTTP surface.
119
+
120
+ **Operator-only doors stay operator-only.** The staged delivery-graph lifecycle —
121
+ `stageDeliveryGraph`, `dispatchDeliveryGraph`, `dismissProposal` — is `x-mcp`-excluded
122
+ from the projected tool surface (ADR 0067 §2): the human clicking **Dispatch** in the
123
+ cockpit *is* the approval (ADR 0005 Decision 7), so an agent cannot dispatch a delivery
124
+ graph through MCP. Agents author graphs through the pure `compileDeliveryGraph` /
125
+ `previewDeliveryGraph` doors, which stay exposed.
126
+
127
+ ## 5. Fallback
128
+
129
+ Agents without MCP are unchanged — resolve the instance, then
130
+ `curl -sS $BASE/agent | jq -r .instructions`, or load the
131
+ [`nano-workforce` skill](../skills/nano-workforce/SKILL.md), which fetches the same
132
+ live guide. `GET /app/api/agent` and `GET /app/api/agent/skill` keep working exactly as
133
+ before.
package/openapi.yaml CHANGED
@@ -3134,6 +3134,12 @@ paths:
3134
3134
  /actions/delivery-graph/stage:
3135
3135
  post:
3136
3136
  operationId: stageDeliveryGraph
3137
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): operator-only cockpit door, kept OFF the
3138
+ # runtime-projected MCP tool surface. The staged-proposal lifecycle (stage -> dispatch ->
3139
+ # dismiss) is the human approval path (ADR 0005 Decision 7 — the operator's click IS the
3140
+ # approval); agents author graphs through the pure compile/preview doors only, never stage.
3141
+ x-mcp:
3142
+ exclude: true
3137
3143
  summary: UI JSON-paste STAGE — parse a pasted delivery-graph JSON string, compile it and stage it for operator dispatch. (ADR 0005 Decision 7 / #460 / #516)
3138
3144
  description: >-
3139
3145
  The human-facing UI JSON-paste STAGE ingress — the deliberate commit half of the preview/stage
@@ -3167,6 +3173,11 @@ paths:
3167
3173
  /actions/delivery-graph/dispatch:
3168
3174
  post:
3169
3175
  operationId: dispatchDeliveryGraph
3176
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): the canonical operator-only door. Dispatch
3177
+ # approval IS a human clicking Dispatch in the cockpit (ADR 0005 Decision 7) — there is no
3178
+ # replayable token and no agent-facing dispatch handle, so it is never a projected MCP tool.
3179
+ x-mcp:
3180
+ exclude: true
3170
3181
  summary: OPERATOR DISPATCH — launch a staged delivery-graph proposal by its digest (idempotent). (ADR 0005 Decision 7 / #460)
3171
3182
  description: >-
3172
3183
  The OPERATOR-ONLY dispatch door (ADR 0005 Decision 7, issue #460). The cockpit's staged-proposals
@@ -3212,6 +3223,11 @@ paths:
3212
3223
  /actions/delivery-graph/dismiss:
3213
3224
  post:
3214
3225
  operationId: dismissProposal
3226
+ # x-mcp exclusion (ADR 0067 §2 / nano-ide#488): operator-only cockpit door — discarding a
3227
+ # staged proposal as noise is a human decision in the same approval class as dispatch, so it
3228
+ # stays off the projected MCP tool surface.
3229
+ x-mcp:
3230
+ exclude: true
3215
3231
  summary: OPERATOR DISMISS — discard a staged delivery-graph proposal by its digest as noise (idempotent). (#520)
3216
3232
  description: >-
3217
3233
  The OPERATOR-ONLY dismiss door (#520). The cockpit's staged-proposals grid posts the `digest` of
@@ -0,0 +1,195 @@
1
+ // Integration coverage for the intake READINESS gate (issue #295) driven through the operation EDGE —
2
+ // `startFeature` → `parseFeatureReadiness` → the started run's variables. The unit tests in
3
+ // app/featureReadiness.test.ts already prove the parser derives `probes`/`probeTimeout`/`probePollEvery`
4
+ // correctly in isolation, and app/feature.test.ts proves `startFeature` seeds them onto the run. What
5
+ // nothing asserted — and what regressed in issue #579 — is that the OPERATION threads the parser's
6
+ // output through to `startFeature` intact: a too-narrow local dropped `probePollEvery` on the floor, so
7
+ // every gated start (`blockedOn`/`readiness`) 500'd on startFeature's invariant. This file locks the
8
+ // composed door behaviour: a gated start returns 202 and the run it fans out carries non-blank bounds.
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { AppApi } from "@nanobpm/urban";
12
+ import { resetDefaultBranchCache } from "../app/github.ts";
13
+ import { noopLog } from "../test/log.ts";
14
+ import { withTrackingViews } from "../test/trackingViews.ts";
15
+ import startFeature from "./startFeature.ts";
16
+
17
+ // ── in-memory github model (default branch = main, so `confirmDefaultBase` is required) ───────────
18
+ function githubFetch(repo: string) {
19
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
20
+ const u = new URL(String(url));
21
+ const method = (init?.method ?? "GET").toUpperCase();
22
+ const path = u.pathname;
23
+ const json = (obj: unknown, status = 200) =>
24
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
25
+ if (method === "GET" && path === `/repos/${repo}`) return Promise.resolve(json({ default_branch: "main" }));
26
+ const refPrefix = `/repos/${repo}/git/ref/heads/`;
27
+ if (method === "GET" && path.startsWith(refPrefix)) {
28
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
29
+ if (branch !== "main") return Promise.resolve(new Response("Not Found", { status: 404 }));
30
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
31
+ }
32
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
33
+ };
34
+ }
35
+
36
+ async function withGithub<T>(repo: string, fn: () => Promise<T>): Promise<T> {
37
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
38
+ const prevTok = process.env["GITHUB_TOKEN"];
39
+ const prevFetch = globalThis.fetch;
40
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
41
+ process.env["GITHUB_TOKEN"] = "tok";
42
+ resetDefaultBranchCache();
43
+ globalThis.fetch = githubFetch(repo) as typeof fetch;
44
+ try {
45
+ return await fn();
46
+ } finally {
47
+ resetDefaultBranchCache();
48
+ globalThis.fetch = prevFetch;
49
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
50
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
51
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
52
+ else process.env["GITHUB_TOKEN"] = prevTok;
53
+ }
54
+ }
55
+
56
+ // ── in-memory app (data + engine) ────────────────────────────────────────────
57
+ // `started` records each engine.createInstance call so a test can assert the run's seeded variables.
58
+ function makeApp() {
59
+ const tables = new Map<string, Record<string, unknown>[]>();
60
+ const started: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
61
+ const table = (name: string, key: string) => {
62
+ const rows = tables.get(name) ?? (() => {
63
+ const fresh: Record<string, unknown>[] = [];
64
+ tables.set(name, fresh);
65
+ return fresh;
66
+ })();
67
+ return {
68
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
69
+ find: (q: Record<string, unknown>) =>
70
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
71
+ findOne: (q: Record<string, unknown>) =>
72
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
73
+ insert: (r: Record<string, unknown>) => {
74
+ rows.push(r);
75
+ return Promise.resolve(r);
76
+ },
77
+ update: (k: unknown, patch: Record<string, unknown>) => {
78
+ const row = rows.find((r) => r[key] === k);
79
+ if (row) Object.assign(row, patch);
80
+ return Promise.resolve(row);
81
+ },
82
+ delete: (k: unknown) => {
83
+ const i = rows.findIndex((r) => r[key] === k);
84
+ if (i >= 0) rows.splice(i, 1);
85
+ return Promise.resolve();
86
+ },
87
+ };
88
+ };
89
+ const app = {
90
+ data: { table: withTrackingViews(table) },
91
+ engine: {
92
+ createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
93
+ started.push(req);
94
+ return Promise.resolve({ processInstanceKey: "PI-F1" });
95
+ },
96
+ },
97
+ log: noopLog(),
98
+ } as any as AppApi;
99
+ return { app, started };
100
+ }
101
+
102
+ function input(body: unknown) {
103
+ return {
104
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
105
+ params: {},
106
+ query: {},
107
+ body,
108
+ };
109
+ }
110
+
111
+ const REPO = "owner/repo";
112
+ const GATED_BASE = { baseBranch: "main", confirmDefaultBase: true } as const;
113
+
114
+ // ── the #579 regression: a gated start must reach 202 AND thread the bounds through ───────────────
115
+
116
+ test("blockedOn gate → 202 and the started run carries non-blank probeTimeout + probePollEvery", async () => {
117
+ await withGithub(REPO, async () => {
118
+ const { app, started } = makeApp();
119
+ const res = (await startFeature(
120
+ input({ issue: `${REPO}#577`, ...GATED_BASE, blockedOn: [`${REPO}#578`] }),
121
+ app,
122
+ )) as any;
123
+ assertEquals(res.status, 202);
124
+ assertEquals(started.length, 1);
125
+ const v = started[0].variables as Record<string, unknown>;
126
+ // The regressed field: it was dropped by a too-narrow local, so the run seeded a blank cadence and
127
+ // startFeature's invariant threw → 500. Both bounds must arrive non-blank.
128
+ assertEquals((v.probeTimeout as string).trim().length > 0, true);
129
+ assertEquals((v.probePollEvery as string).trim().length > 0, true);
130
+ assertEquals(Array.isArray(v.readinessProbes) && (v.readinessProbes as unknown[]).length === 1, true);
131
+ });
132
+ });
133
+
134
+ test("explicit readiness descriptor list → 202 with both bounds threaded to the run", async () => {
135
+ await withGithub(REPO, async () => {
136
+ const { app, started } = makeApp();
137
+ const res = (await startFeature(
138
+ input({
139
+ issue: `${REPO}#577`,
140
+ ...GATED_BASE,
141
+ readiness: [{ kind: "command", target: "gh api repos/owner/repo/issues/578 --jq .state", match: { stdoutIncludes: "closed" } }],
142
+ }),
143
+ app,
144
+ )) as any;
145
+ assertEquals(res.status, 202);
146
+ const v = started[0].variables as Record<string, unknown>;
147
+ assertEquals((v.probeTimeout as string).trim().length > 0, true);
148
+ assertEquals((v.probePollEvery as string).trim().length > 0, true);
149
+ });
150
+ });
151
+
152
+ test("blockedOn + consumerPackage (capability edge) → 202 with both bounds threaded", async () => {
153
+ await withGithub(REPO, async () => {
154
+ const { app, started } = makeApp();
155
+ const res = (await startFeature(
156
+ input({ issue: `${REPO}#577`, ...GATED_BASE, blockedOn: [`${REPO}#578`], consumerPackage: "@nanobpm/engine-wasm" }),
157
+ app,
158
+ )) as any;
159
+ assertEquals(res.status, 202);
160
+ const v = started[0].variables as Record<string, unknown>;
161
+ assertEquals((v.probeTimeout as string).trim().length > 0, true);
162
+ assertEquals((v.probePollEvery as string).trim().length > 0, true);
163
+ const probes = v.readinessProbes as { kind?: string }[];
164
+ assertEquals(probes[0]?.kind, "capability");
165
+ });
166
+ });
167
+
168
+ // ── regression: an UNGATED start still passes null/absent for both bounds (gate skipped) ──────────
169
+
170
+ test("no readiness ⇒ 202 and the run seeds null probeTimeout + probePollEvery (gate skipped)", async () => {
171
+ await withGithub(REPO, async () => {
172
+ const { app, started } = makeApp();
173
+ const res = (await startFeature(input({ issue: `${REPO}#577`, ...GATED_BASE }), app)) as any;
174
+ assertEquals(res.status, 202);
175
+ const v = started[0].variables as Record<string, unknown>;
176
+ assertEquals(v.probeTimeout, null);
177
+ assertEquals(v.probePollEvery, null);
178
+ assertEquals(v.readinessProbes, null);
179
+ });
180
+ });
181
+
182
+ // ── a malformed gate is a caller-meaningful 400, never a 500 ──────────────────────────────────────
183
+
184
+ test("malformed readiness descriptor → 400 at the edge (never a 500)", async () => {
185
+ await withGithub(REPO, async () => {
186
+ const { app, started } = makeApp();
187
+ const res = (await startFeature(
188
+ input({ issue: `${REPO}#577`, ...GATED_BASE, blockedOn: [""] }),
189
+ app,
190
+ )) as any;
191
+ assertEquals(res.status, 400);
192
+ assertEquals(typeof res.body.error, "string");
193
+ assertEquals(started.length, 0);
194
+ });
195
+ });
@@ -13,7 +13,7 @@
13
13
  // confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
14
14
 
15
15
  import { startFeature } from "../app/feature.ts";
16
- import { parseFeatureReadiness } from "../app/featureReadiness.ts";
16
+ import { type FeatureReadiness, parseFeatureReadiness } from "../app/featureReadiness.ts";
17
17
  import { BaseBranchMustExistError } from "../app/github.ts";
18
18
  import {
19
19
  admitPlan,
@@ -23,7 +23,6 @@ import {
23
23
  parseIssue,
24
24
  SharedBaseError,
25
25
  } from "../app/plan.ts";
26
- import type { ReadinessProbe } from "../app/readiness.ts";
27
26
  import { defineOperation } from "../nano-generated/operations.ts";
28
27
 
29
28
  export default defineOperation("startFeature", async ({ body }, app) => {
@@ -126,7 +125,12 @@ export default defineOperation("startFeature", async ({ body }, app) => {
126
125
  // `blockedOn` shorthand (resolved against `consumerPackage`) into the probes + bound the run parks
127
126
  // on before implementing. A malformed gate (bad descriptor, unparseable handle, blank package) is a
128
127
  // 400 at the edge — it must never wait forever at runtime.
129
- let readiness: { probes: ReadinessProbe[]; probeTimeout: string | null };
128
+ // Type the local as the parser's OWN return type (not a hand-written subset): `parseFeatureReadiness`
129
+ // derives `probes`, `probeTimeout` AND `probePollEvery` together, and all three must be threaded to
130
+ // the run. A narrower local silently drops a field the parser produced (issue #579: `probePollEvery`
131
+ // was dropped, so every gated start 500'd on startFeature's invariant) without TypeScript flagging it,
132
+ // because the narrower shape is structurally assignable from the wider return.
133
+ let readiness: FeatureReadiness;
130
134
  try {
131
135
  readiness = parseFeatureReadiness({
132
136
  readiness: "readiness" in body ? body.readiness : undefined,
@@ -138,6 +142,31 @@ export default defineOperation("startFeature", async ({ body }, app) => {
138
142
  app.log.warn("start-feature rejected: invalid readiness gate", { message });
139
143
  return { status: 400, body: { error: message } };
140
144
  }
145
+ // Validate the gate's timing bounds at the EDGE, before dispatch: a non-empty probe set is
146
+ // load-bearing together with a non-blank `probeTimeout` (preflight escalation timers + pr.readiness-probe)
147
+ // and `probePollEvery` (preflight retry cadence). `parseFeatureReadiness` always derives all three
148
+ // together, so this only fires for a mis-derived/hand-seeded gate — but validating here turns that
149
+ // into a caller-meaningful 400 rather than a bare-Error 500 from startFeature's internal invariant.
150
+ if (readiness.probes.length > 0) {
151
+ const missingBounds: string[] = [];
152
+ if ((readiness.probeTimeout ?? "").trim() === "") missingBounds.push("a timeout");
153
+ if ((readiness.probePollEvery ?? "").trim() === "") missingBounds.push("a poll cadence");
154
+ if (missingBounds.length > 0) {
155
+ app.log.warn("start-feature rejected: readiness gate missing timing bound", {
156
+ missing: missingBounds,
157
+ probes: readiness.probes.length,
158
+ });
159
+ return {
160
+ status: 400,
161
+ body: {
162
+ error:
163
+ `readiness gate is malformed: ${readiness.probes.length} probe(s) but the request did not ` +
164
+ `resolve to ${missingBounds.join(" and ")}. A gated start (readiness/blockedOn) must resolve ` +
165
+ `to a non-blank timeout and poll cadence`,
166
+ },
167
+ };
168
+ }
169
+ }
141
170
  const result = await startFeature(
142
171
  app.data,
143
172
  app.engine,
@@ -146,7 +175,7 @@ export default defineOperation("startFeature", async ({ body }, app) => {
146
175
  converge,
147
176
  autoMerge,
148
177
  customInstructions,
149
- { probes: readiness.probes, probeTimeout: readiness.probeTimeout },
178
+ { probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
150
179
  );
151
180
  app.log.info("feature run started", {
152
181
  featureKey: parsed.planKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.149.0",
3
+ "version": "0.150.1",
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",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.4.0",
62
- "@nanobpm/urban": "^0.83.0",
62
+ "@nanobpm/urban": "^0.86.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {
package/skills/README.md CHANGED
@@ -7,9 +7,13 @@ loads on demand when its `description` matches the task.
7
7
  ## `nano-workforce`
8
8
 
9
9
  A **thin bootstrap** that teaches any agent to operate a running Nano Workforce
10
- instance: it resolves the instance base URL and fetches the instance's *live*
11
- operator guide (`GET /app/api/agent`), then follows it. It deliberately holds no
12
- endpoint detail of its own the live, version-matched guide is the source of truth.
10
+ instance. Where the client supports **MCP**, it registers the instance's `/app/mcp`
11
+ server (its tools appear automatically, including the operator guide as the
12
+ `getAgentInstructions` toolADR 0067); where
13
+ it does not, it resolves the instance base URL and fetches the instance's *live*
14
+ operator guide (`GET /app/api/agent`), then follows it. Either way it deliberately holds
15
+ no endpoint detail of its own — the live, version-matched surface is the source of truth.
16
+ See [`docs/mcp-runbook.md`](../docs/mcp-runbook.md) for the MCP server-entry recipes.
13
17
 
14
18
  ### Install
15
19
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nano-workforce
3
- description: Drive and debug a running Nano Workforce instance — submit PRs for review convergence, submit issues/epics for plan→implement→converge, submit agent-authored delivery graphs (ADR 0005), answer escalations, and unstick stuck instances. Use when the user asks to operate, drive, submit work to, or debug their Nano Workforce.
3
+ description: Drive and debug a running Nano Workforce instance — submit PRs for review convergence, submit issues/epics for plan→implement→converge, submit agent-authored delivery graphs (ADR 0005), answer escalations, and unstick stuck instances. Prefer the instance's MCP server (add it → its tools appear); fall back to the live operator guide. Use when the user asks to operate, drive, submit work to, or debug their Nano Workforce.
4
4
  ---
5
5
 
6
6
  # Nano Workforce operator skill
@@ -12,27 +12,88 @@ implements → converges** them across a fleet of coding agents, and runs
12
12
  DAGs — ADR 0005).
13
13
 
14
14
  **This skill is a thin bootstrap by design.** It does not describe the endpoints.
15
- Every running nwf instance serves its own operator guide, *live*, keyed to that
16
- instance's URLs and matched to its deployed version. Your job is to fetch that
17
- guide and follow it never to work from a cached copy, which drifts across
18
- versions and instances.
15
+ Every running nwf instance is self-describing over **MCP** where your client
16
+ supports it, and over its **live operator guide** everywhere else. Your job is to
17
+ reach that live surface and follow it, never to work from a cached copy that drifts
18
+ across versions and instances.
19
+
20
+ There are two paths. **Prefer MCP (§A).** If your client has no MCP support, use the
21
+ fetch-the-live-guide fallback (§B). Both talk to the same app; MCP is a projection of
22
+ the same OpenAPI contract, not a different system (ADR 0067).
23
+
24
+ ## A. Preferred — drive over MCP
25
+
26
+ The Urban runtime serves a Streamable-HTTP MCP endpoint at **`/app/mcp`** for every
27
+ instance, with **zero app-side MCP code**: the app's operations are projected into
28
+ tools from its OpenAPI spec, alongside a framework-owned engine-debug tool family
29
+ (process instances, wait states, variables, incidents) and the app's projection
30
+ reads. The operator **guide** (the same guide as §B) is itself one of those projected
31
+ tools — `GET /app/api/agent` becomes the `getAgentInstructions` read tool — so the
32
+ workflow knowledge (orient first, preview before dispatch, escalations are for humans)
33
+ is discoverable over the same channel as the drive tools; the runtime additionally
34
+ serves its derived **system brief** as an MCP resource plus an orientation prompt.
35
+
36
+ **Register one MCP server entry per instance.** Naming the instance
37
+ (`"drive workforce-merlin"`) makes the wrong-instance mistake structurally
38
+ impossible — tool calls are namespaced per server entry. For the Copilot CLI, in
39
+ `~/.copilot/mcp-config.json` (user-wide) or `.mcp.json` (repo-scoped):
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "workforce-local": {
45
+ "type": "http",
46
+ "url": "http://localhost:3000/app/mcp",
47
+ "tools": ["*"]
48
+ },
49
+ "workforce-merlin": {
50
+ "type": "http",
51
+ "url": "http://merlin.local:3000/app/mcp",
52
+ "headers": { "x-hook-secret": "$NANO_PR_WEBHOOK_SECRET" },
53
+ "tools": ["*"]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Or from the terminal:
60
+ `copilot mcp add --transport http workforce-local http://localhost:3000/app/mcp`
61
+ (add `--header` for a guarded instance). Claude/Cursor use the same server entries.
62
+
63
+ Then: start a session, confirm the `workforce-*` tools appear, and ask the agent to
64
+ use a **named** instance (`"Using workforce-local, show what's in flight and any open
65
+ escalations"`). It should call the status operation tool, not curl.
19
66
 
20
- ## 1. Confirm which instance you are driving — always
67
+ **Guard posture.** When `NANO_PR_WEBHOOK_SECRET` is **unset**, both reads (status,
68
+ instances, incidents, projections, the operator guide) and mutations (submit work,
69
+ answer escalations, cancel/retry/resolve) work from loopback with no credential. When it
70
+ **is set**, that secret is required as an `x-hook-secret` header on **both reads and
71
+ mutations** — read endpoints like `getAgentInstructions`/`getVersion` also return `401`
72
+ without it — put it in the server entry's `headers`, never in chat. **Operator-only doors stay operator-only:** the delivery-graph
73
+ dispatch/dismiss lifecycle (the human clicking Dispatch *is* the approval, ADR 0005)
74
+ is `x-mcp`-excluded and is **not** a tool — dispatch stays a human action in the
75
+ cockpit.
21
76
 
22
- A user typically runs **several** Nano Workforce instances — e.g. a local dev copy,
23
- one on the LAN (`http://merlin.local:3000/app/api`), and a public tunnel
24
- (an ngrok URL) when off the LAN. Every action here is **side-effecting** —
25
- submitting work, answering escalations, merging PRs — so targeting the wrong
26
- instance is a real mistake, not a harmless one. **Never silently default to a
27
- base URL.**
77
+ The full server-entry recipes (multiple instances, Basic-Auth-fronted instances,
78
+ LAN exposure) live in the **agent-configuration runbook** see the repo README
79
+ ("Configure an agent over MCP") and [`docs/mcp-runbook.md`](../../docs/mcp-runbook.md).
28
80
 
29
- ### Sources of candidate instances
81
+ ## B. Fallback no MCP client? Fetch the live guide
30
82
 
31
- Gather candidates from, in order:
83
+ Agents without MCP are unchanged: resolve the instance, then fetch and follow its
84
+ live guide.
32
85
 
33
- 1. **A named-instance registry** the user maintains first of these that exists:
34
- `$NANO_WORKFORCE_INSTANCES` (JSON object of `name → base URL`), or
35
- `~/.config/nano-workforce/instances.json` (same shape). Example:
86
+ ### B.1 Confirm which instance you are drivingalways
87
+
88
+ A user typically runs **several** instances a local dev copy, one on the LAN
89
+ (`http://merlin.local:3000/app/api`), and a public tunnel (ngrok) when off the LAN.
90
+ Every action is **side-effecting**, so targeting the wrong instance is a real
91
+ mistake. **Never silently default to a base URL.** Gather candidates, in order:
92
+
93
+ 1. A named-instance registry the user maintains — first that exists:
94
+ `$NANO_WORKFORCE_INSTANCES` (JSON `name → base URL`) or
95
+ `~/.config/nano-workforce/instances.json` (same shape). This is also the source
96
+ for the per-instance MCP server names in §A. Example:
36
97
 
37
98
  ```json
38
99
  { "local": "http://localhost:3000/app/api",
@@ -40,33 +101,25 @@ Gather candidates from, in order:
40
101
  "remote": "https://<subdomain>.ngrok.app/app/api" }
41
102
  ```
42
103
 
43
- 2. `$NANO_WORKFORCE_URL`, if set (a single default; accept as-is, append `/app/api`
44
- only if it is a bare origin).
104
+ 2. `$NANO_WORKFORCE_URL`, if set (a single default; append `/app/api` only if it is
105
+ a bare origin).
45
106
  3. Any URL the user names in the conversation.
46
107
  4. Local fallback: `http://localhost:3000/app/api` (port `PR_REVIEW_PORT`, default `3000`).
47
108
 
48
- ### Choosing
49
-
50
- - If the user **named an instance** (by name from the registry, or by URL), use it.
51
- - Otherwise, **probe the candidates for reachability** and ask the user which to
52
- use, offering the candidates as choices and marking which are live. Reachability
53
- disambiguates the common case — off the LAN, `merlin.local` won't resolve, so the
54
- tunnel instance is the live one:
109
+ If the user named an instance, use it. Otherwise probe candidates for reachability
110
+ and ask which to use, marking which are live:
55
111
 
56
- ```bash
57
- # For each candidate base, a fast liveness + identity check:
58
- curl -sS --max-time 3 \
59
- ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"} \
60
- "$BASE/version" | jq '{appVersion, gitSha, uptimeSeconds}'
61
- ```
62
-
63
- - Only skip the question when exactly **one** candidate exists and is reachable —
64
- and even then, **name the instance you're about to drive** before acting.
112
+ ```bash
113
+ curl -sS --max-time 3 \
114
+ ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"} \
115
+ "$BASE/version" | jq '{appVersion, gitSha, uptimeSeconds}'
116
+ ```
65
117
 
66
- Some instances guard the agent endpoints with a shared secret. If the user has
67
- `$NANO_PR_WEBHOOK_SECRET` set, send it as `x-hook-secret` on every request below.
118
+ Only skip the question when exactly **one** candidate exists and is reachable — and
119
+ even then, name the instance you're about to drive before acting. If the user has
120
+ `$NANO_PR_WEBHOOK_SECRET` set, send it as `x-hook-secret` on every request.
68
121
 
69
- ## 2. Fetch the live guide — this is your real playbook
122
+ ### B.2 Fetch the live guide — your real playbook
70
123
 
71
124
  ```bash
72
125
  curl -sS ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"} \
@@ -74,53 +127,47 @@ curl -sS ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"}
74
127
  ```
75
128
 
76
129
  `GET /app/api/agent` (`getAgentInstructions`) returns
77
- `{ format, appVersion, generatedAt, baseUrl, engineBase, instructions }`. The
78
- `instructions` markdown is the authoritative, version-matched operator guide, with
79
- every example already keyed to this instance's `baseUrl`/`engineBase`. Read it in
80
- full and follow it for everything that follows orientation, submitting work,
81
- answering escalations, and debugging.
82
-
83
- **Always re-fetch the guide at the start of a session.** It is the source of truth;
84
- this skill only tells you how to find it.
85
-
86
- ## 3. Orient before acting
130
+ `{ format, appVersion, generatedAt, baseUrl, engineBase, instructions }` — the
131
+ authoritative, version-matched operator guide, every example keyed to this
132
+ instance. Read it in full and follow it for orientation, submitting work, answering
133
+ escalations, and debugging. **Re-fetch it at the start of every session.** (This is
134
+ the same prose the `getAgentInstructions` MCP tool serves in §A.)
87
135
 
88
- The guide's first steps confirm what is live and what is in flight:
136
+ ### B.3 Orient before acting
89
137
 
90
138
  ```bash
91
139
  curl -sS "$BASE/version" | jq # app/urban version, git sha, uptime
92
140
  curl -sS "$BASE/status" | jq # every PR/instance in flight + open escalations
93
141
  ```
94
142
 
95
- `/status` is the primary situational-awareness endpoint — check it before you
96
- submit or unstick anything.
143
+ `/status` is the primary situational-awareness endpoint — check it before you submit
144
+ or unstick anything.
97
145
 
98
- ## 4. What you can drive (all detailed in the live guide)
146
+ ### B.4 What you can drive (all detailed in the live guide)
99
147
 
100
148
  - **Submit a PR** for review convergence — `POST $BASE/actions/start/convergence-loop`.
101
- - **Submit an issue/epic** for plan → implement → converge across the fleet —
102
- `POST $BASE/actions/start/plan-fanout`.
103
- - **Submit a delivery graph** (ADR 0005) propose preview → approve → dispatch.
104
- Compile/preview is a pure, side-effect-free tool; only the start door dispatches.
105
- The live guide documents the exact operations once the instance exposes them.
106
- - **Answer an escalation** (a durable user task the workforce parked on)
107
- `POST $BASE/actions/complete-user-task`, or the agent hook
108
- `POST $BASE/hooks/agent-complete` (`agentCompleteEscalation`).
109
- - **Debug**: relate an in-flight PR to its engine process instance via `processKey`
110
- from `/status`, then use the engine REST base (`engineBase` from the guide) to
111
- inspect and unstick it.
149
+ - **Submit an issue/epic** for plan → implement → converge `POST $BASE/actions/start/plan-fanout`.
150
+ - **Submit a delivery graph** (ADR 0005) — propose → preview → approve → **dispatch**.
151
+ Compile/preview is a pure, side-effect-free path; **dispatch is an operator action
152
+ in the cockpit**, not an agent door.
153
+ - **Answer an escalation** `POST $BASE/actions/complete-user-task`, or the agent
154
+ hook `POST $BASE/hooks/agent-complete` (`agentCompleteEscalation`).
155
+ - **Debug** — relate an in-flight PR to its engine process instance via `processKey`
156
+ from `/status`, then inspect and unstick it against the engine REST base
157
+ (`engineBase` from the guide).
112
158
 
113
159
  ## Principles
114
160
 
115
- - **Discover, don't declare.** Prefer the live guide and live `/status` over any
116
- assumption baked into this file. If this skill and the guide disagree, the guide
117
- wins.
118
- - **Confirm the target instance.** Never run a side-effecting call against an
119
- assumed base URL. Know and when ambiguous, askwhich instance you're driving.
120
- - **Preview before dispatch.** For delivery graphs and any bulk action, use the
121
- pure preview/validate path first and show the user the plan before the
122
- side-effecting start call.
123
- - **Idempotency.** Submissions carry dedupe keys; re-submitting the same work must
124
- not double-dispatch. The guide documents the keys honour them.
125
- - **Escalations are for humans.** When the workforce parks on a human node, surface
126
- it to the user with options; don't silently auto-answer design/product decisions.
161
+ - **Discover, don't declare.** Prefer the live surface (MCP tools including the
162
+ operator-guide tool, or the live guide and `/status`) over any assumption baked into
163
+ this file. If this skill and the live surface disagree, the live surface wins.
164
+ - **Confirm the target instance.** Never run a side-effecting call against an assumed
165
+ base URL. With MCP, name the server entry; with the fallback, knowand when
166
+ ambiguous, ask which instance you're driving.
167
+ - **Preview before dispatch.** For delivery graphs and any bulk action, use the pure
168
+ preview/validate path first and show the user the plan before the side-effecting
169
+ start. Dispatch itself is the operator's call.
170
+ - **Idempotency.** Submissions carry dedupe keys; re-submitting the same work must not
171
+ double-dispatch. Honour the keys the guide documents.
172
+ - **Escalations are for humans.** When the workforce parks on a human node, surface it
173
+ with options; don't silently auto-answer design/product decisions.