@nanobpm/nano-workforce 0.108.0 → 0.109.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.109.0](https://github.com/nanobpm/nano-workforce/compare/v0.108.0...v0.109.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **skill:** add nano-workforce operator bootstrap skill ([#382](https://github.com/nanobpm/nano-workforce/issues/382)) ([ef6701f](https://github.com/nanobpm/nano-workforce/commit/ef6701f4df76bcfc762ddec20d745bf5edd0ec1b))
7
+
1
8
  # [0.108.0](https://github.com/nanobpm/nano-workforce/compare/v0.107.2...v0.108.0) (2026-08-20)
2
9
 
3
10
 
@@ -0,0 +1,54 @@
1
+ // The Nano Workforce operator *skill* served by GET /app/api/agent/skill (operationId
2
+ // `getAgentSkill`). Companion to the agent guide (app/agentGuide.ts): where the guide is the full,
3
+ // instance-keyed playbook, the skill is a small, portable *bootstrap* an agent runtime (Copilot
4
+ // CLI, Claude) loads on demand — it resolves which instance to drive and then fetches the live
5
+ // guide. The user clicks "Agent Instructions" on the Overview tab, copies the prompt, and the agent
6
+ // loads THIS skill from THIS instance.
7
+ //
8
+ // The skill is authored as plain markdown in `skills/nano-workforce/SKILL.md` (kept OUT of
9
+ // `resources/` so the deploy-by-convention walk does NOT treat it as a deployable model, ADR 0062)
10
+ // and read from the checkout at module load — same approach as agentGuide.ts / version.ts. A
11
+ // `__BASE__` placeholder, if present, is substituted per request so any embedded example is
12
+ // copy-pasteable against THIS instance.
13
+ //
14
+ // Reading is best-effort: a missing file yields a short built-in fallback rather than throwing, so
15
+ // the endpoint never 500s just because the skill is absent from a stripped-down deploy.
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
21
+ const SKILL_PATH = join(REPO_ROOT, "skills", "nano-workforce", "SKILL.md");
22
+
23
+ // Read the raw skill once, at module load. Frozen for the life of the process.
24
+ const RAW_SKILL: string = (() => {
25
+ try {
26
+ return readFileSync(SKILL_PATH, "utf8");
27
+ } catch {
28
+ return [
29
+ "---",
30
+ "name: nano-workforce",
31
+ "description: Drive and debug a running Nano Workforce instance.",
32
+ "---",
33
+ "",
34
+ "# Nano Workforce operator skill",
35
+ "",
36
+ "This skill could not be read from this deployment. Fetch the live operator guide and follow",
37
+ "it instead — it is the authoritative, version-matched playbook:",
38
+ "",
39
+ " curl -sS __BASE__/agent",
40
+ "",
41
+ "If the instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), add its value as an",
42
+ "`x-hook-secret` header. Confirm which instance you are driving before any side-effecting call.",
43
+ "",
44
+ ].join("\n");
45
+ }
46
+ })();
47
+
48
+ /**
49
+ * Render the skill for a given app control-API base (e.g. "https://host/app/api"). Substitutes every
50
+ * `__BASE__` occurrence so any embedded example targets THIS instance.
51
+ */
52
+ export function renderAgentSkill(apiBase: string): string {
53
+ return RAW_SKILL.replaceAll("__BASE__", apiBase.replace(/\/+$/, ""));
54
+ }
@@ -0,0 +1,43 @@
1
+ // Tests for app/resolveApiBase.ts — the single canonical control-API base reconstruction shared by
2
+ // getAgentInstructions and getAgentSkill. Covers proxy-header handling, scheme restriction,
3
+ // host-absent fallback, and mount-suffix stripping for both mount depths.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { resolveApiBase } from "./resolveApiBase.ts";
7
+
8
+ function req(headers: Record<string, string>, path: string) {
9
+ return { path, headers: new Headers(headers) };
10
+ }
11
+
12
+ test("strips the single-segment mount suffix to recover the base", () => {
13
+ assertEquals(resolveApiBase(req({ host: "wf.example.com" }, "/app/api/agent"), "agent"), "http://wf.example.com/app/api");
14
+ });
15
+
16
+ test("strips the nested mount suffix to recover the base", () => {
17
+ assertEquals(
18
+ resolveApiBase(req({ host: "wf.example.com" }, "/app/api/agent/skill"), "agent/skill"),
19
+ "http://wf.example.com/app/api",
20
+ );
21
+ });
22
+
23
+ test("honours x-forwarded-proto and x-forwarded-host", () => {
24
+ const r = req({ host: "internal", "x-forwarded-host": "wf.example.com", "x-forwarded-proto": "https" }, "/app/api/agent");
25
+ assertEquals(resolveApiBase(r, "agent"), "https://wf.example.com/app/api");
26
+ });
27
+
28
+ test("restricts x-forwarded-proto to http/https", () => {
29
+ const r = req({ host: "wf.example.com", "x-forwarded-proto": "javascript" }, "/app/api/agent/skill");
30
+ assertEquals(resolveApiBase(r, "agent/skill"), "http://wf.example.com/app/api");
31
+ });
32
+
33
+ test("falls back to a localhost default when the Host header is absent", () => {
34
+ assertEquals(resolveApiBase(req({}, "/app/api/agent"), "agent"), "http://localhost:3000/app/api");
35
+ });
36
+
37
+ test("tolerates a leading slash on the mount suffix", () => {
38
+ assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent"), "/agent"), "http://h/app/api");
39
+ });
40
+
41
+ test("strips multiple trailing slashes after the mount suffix", () => {
42
+ assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
43
+ });
@@ -0,0 +1,28 @@
1
+ // Canonical reconstruction of the app control-API base a caller reached us on (e.g.
2
+ // "https://host/app/api"), so an operation can rewrite its embedded examples to THIS instance and
3
+ // keep them copy-pasteable. One implementation shared by every /app/api operation that keys output
4
+ // to the request base (getAgentInstructions, getAgentSkill, …) — per AGENTS.md "Derivation over
5
+ // duplication: no drift surfaces", proxy-header handling and base-path stripping must not fork.
6
+ //
7
+ // Honour reverse-proxy forwarding headers; fall back to a localhost default when the Host header is
8
+ // absent (e.g. a raw unit-test request).
9
+
10
+ /**
11
+ * Recover the control-API base from a request, stripping the operation's own mount suffix.
12
+ *
13
+ * @param req the request (path + headers)
14
+ * @param mountSuffix the operation's path suffix to strip to recover the base, e.g. "agent" or
15
+ * "agent/skill" (with or without a leading slash). The base defaults to
16
+ * "/app/api" when the path is nothing but the suffix.
17
+ */
18
+ export function resolveApiBase(req: { path: string; headers: Headers }, mountSuffix: string): string {
19
+ const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
20
+ // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
21
+ const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
22
+ const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
23
+ // The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
24
+ const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
25
+ const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
26
+ const basePath = req.path.replace(stripRe, "") || "/app/api";
27
+ return host ? `${proto}://${host}${basePath}` : `http://localhost:3000${basePath}`;
28
+ }
@@ -0,0 +1,221 @@
1
+ # ADR 0005 — Agent-authored delivery graphs (data-over-a-closed-vocabulary, human-in-the-loop)
2
+
3
+ Status: **Proposed.**
4
+ Date: 2026-08-20.
5
+
6
+ > **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app* runs
7
+ > heterogeneous cross-repo delivery work that mixes automated and human steps. Platform-wide ADRs live
8
+ > in `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo, e.g. "nano-bpm ADR 0051").
9
+ > nano-workforce's own series continues here after ADR 0004.
10
+
11
+ Relates to:
12
+ nano-workforce **ADR 0001** (cross-repo epics + the generic `ReadinessProbe` wait-gate — this ADR
13
+ generalizes §4's deferred "release DAG" and its §2 gate into an arbitrary graph),
14
+ nano-workforce **ADR 0002** (escalations are user tasks + forms — the human-node machinery this ADR
15
+ promotes from *exception* to *scheduled node*),
16
+ nano-workforce **ADR 0003** (epic base-branch admission — the guardrail an `agent`/merge node inherits),
17
+ nano-workforce **ADR 0004** (shared-contract coordination — the discipline a cross-repo edge rides on),
18
+ nano-bpm **ADR 0026** (Urban human surfaces + `taskInbox` — where human nodes render),
19
+ nano-bpm **ADR 0046** (agent-as-worker vs agent-in-the-node — why an agent can answer a human node's
20
+ form, and why a node's *body* can itself be an agent),
21
+ nano-bpm **ADR 0051** (nano-workforce — the crew orchestrator whose `plan-fanout` interpreter is the
22
+ prior art this ADR widens),
23
+ nano-bpm **ADR 0056** (the Nano agentic protocol — the live-steering plane, complementary to this
24
+ durable lane),
25
+ nano-bpm **ADR 0059** (the app-hosted OpenAPI hook surface these graphs are submitted and signalled
26
+ through),
27
+ and issues **#263** (capability edges / publish provenance — the emit-vs-poll dual this ADR resolves),
28
+ **#289** (capability edge wired into dispatch — the `readiness-gate` call-activity pattern reused here),
29
+ **#242** (the pre-plan classifier — the *derive-by-default* front-end deferred here).
30
+
31
+ ## Context
32
+
33
+ nano-workforce today runs exactly two shapes of work, each as a **static BPMN process interpreting a
34
+ data graph**: `convergence-loop.bpmn` (one PR) and `plan-fanout.bpmn` (an epic — a
35
+ `RecordPlanTask[] + dependsOn[]` DAG fanned out over multi-instance, with waves, trial-merges, and
36
+ escalations). Both are **specialised to one node shape**: "an agent implements a slice → opens a PR."
37
+
38
+ But real delivery in this ecosystem is a **heterogeneous, cross-repo, partly-human graph**. A concrete
39
+ case from one session:
40
+
41
+ 1. PR **#A** must merge in repo 1.
42
+ 2. *then* draft PR **#B** in repo 2 can be taken out of draft and merged.
43
+ 3. *then* a **human** must do a **manual OTP publish** and **set up OIDC trusted publishing** — no
44
+ automation can cross this step.
45
+ 4. *then* PR **#C** in repo 3 can consume the just-published version.
46
+
47
+ This is a dependency graph whose **nodes are a mix of automated tasks, in-flight-PR/merge watches, and
48
+ human actions**, and whose **edges span repos**. nwf has every *primitive* this needs, but no way to
49
+ *compose* them into one arbitrary graph:
50
+
51
+ - **Automated execution** — agent job types + the supervisor/worker fleet.
52
+ - **"Watch the world"** — the `ReadinessProbe` gate (ADR 0001 §2): durable, bounded (timeout →
53
+ escalate), resumable, with `http`/`command`/`npm`/`github-check`/`capability` kinds.
54
+ - **Cross-repo release edges** — capability edges + publish provenance (#263/#274), wired into dispatch
55
+ as a `readiness-gate` call activity (#289).
56
+ - **Human decision points** — user tasks + forms (ADR 0002), answerable by a human **or** an agent,
57
+ with SLA nudges.
58
+ - **Visible phase across a graph** — derived `epic_phase` (#261) + the cockpit/overview.
59
+
60
+ The gap is purely **composition**: a way to feed an *arbitrary* graph of these node kinds — including
61
+ human nodes as **scheduled stops that hand a value forward** — into one runner. ADR 0001 §4 sketched a
62
+ narrow "release DAG" and deferred it; the manual-publish-in-the-middle case is the general form that
63
+ finally justifies building it.
64
+
65
+ The obvious-but-wrong answer is "let an agent design a one-shot process definition per graph" — i.e.
66
+ generate BPMN (or process-builder code) with an LLM and deploy it. That fails on two axes at once:
67
+ **reliability** (an LLM emitting deployable BPMN/builder-code is authoring an artifact that must compile
68
+ and deploy before you learn it's wrong) and **trust** (agents deploying arbitrary executable process
69
+ definitions — or worse, arbitrary code — into your engine is an unbounded surface: any job type, any
70
+ service task, any script). Both objections point at the same fix: **the agent must never author the
71
+ executable artifact.**
72
+
73
+ ## Decision
74
+
75
+ ### 1. The contract is a validated JSON graph over a *closed* node vocabulary — never an agent-authored executable artifact
76
+
77
+ A delivery graph is submitted as **data**: a JSON DAG whose nodes each name a `kind` from a **fixed
78
+ allowlist** and whose edges name **facts** (below). This JSON — not BPMN, not builder-code — is the
79
+ durable, agent-facing contract, expressed as a **nano-app-schema** type and **published through the
80
+ agent guide** (`GET /app/api/agent`) so a co-designing agent reads exactly what it may build. Ingest
81
+ **validates** against the schema and rejects with **actionable** errors, closing the same
82
+ author→validate→fix loop the capability edges use.
83
+
84
+ This is decisive on both failing axes:
85
+
86
+ - **Reliability** — LLMs emit validated JSON reliably; deployable BPMN/builder-code they do not. The
87
+ whole "co-design → submit" UX depends on the artifact being cheap to validate and safe to produce.
88
+ - **Trust** — a graph can only compose allowlisted `kind`s; it **cannot** express an arbitrary service
89
+ task or run arbitrary code. Safe by construction, exactly as `plan-fanout`'s data model is today.
90
+
91
+ ### 2. Node vocabulary — four kinds, each delegating to an engine-native body
92
+
93
+ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
94
+
95
+ - **`agent`** — a worker executes an agent job type (the existing fan-out body).
96
+ - **`wait`** — a `ReadinessProbe` (ADR 0001 §2), watching an external fact: `github-check`, `npm`,
97
+ `capability` (#263), `http`, `command`, and a new **`pr`/merge-state** kind (draft→ready→merged,
98
+ required checks, mergeable) lifted out of `mergeProtocol.ts` into a first-class probe kind.
99
+ - **`human`** — a scheduled user task + form (§4).
100
+ - **`connector`** — an automated, side-effecting outbound action (the connector I/O surface).
101
+
102
+ Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
103
+ sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
104
+ invocation). The graph layer owns **scheduling** (which nodes' edges are satisfied → dispatch), not a
105
+ re-implementation of execution.
106
+
107
+ ### 3. Edges are *facts*, discovered — not declared values (extends ADR 0001 §4)
108
+
109
+ Every edge means *"B proceeds once fact X about A is observable."* The fact vocabulary is uniform —
110
+ *PR merged*, *check green*, *version on npm carrying capability C*, *human confirmed done*,
111
+ *connector action acknowledged* — and the satisfying state is **discovered**, never pre-declared
112
+ (ADR 0001 §4's discover-don't-declare, generalized from "capability published" to the whole graph). An
113
+ edge is therefore *always* a `wait`-shaped observation; a plain `dependsOn` between two internal nodes
114
+ is the degenerate "wait for the upstream node's completion fact."
115
+
116
+ ### 4. Human nodes are first-class scheduled user tasks that can *emit* a typed fact
117
+
118
+ A `human` node is ADR 0002's user-task+form machinery promoted from **exception** (something broke) to
119
+ **scheduled node** (a planned stop). It surfaces *"now do X"* on the app's own **Tasks** page/inbox, blocks its
120
+ dependents, is **answerable by a human or an agent** (ADR 0046), and is **SLA-bounded** so it nags and
121
+ cannot silently wedge the graph.
122
+
123
+ - **It can hand a value forward.** A human node's form captures a **typed output** that **late-binds
124
+ downstream** — e.g. the manual-publish node emits `resolvedArtifact` (`@nanobpm/urban@0.54.0`), which
125
+ a downstream `capability`/`npm` edge binds and pins. A "click done" gate is the degenerate case that
126
+ emits nothing. This is the **emit-side** of #263's emit-vs-poll dual — and a *human* emitter is the
127
+ same shape as an automated one, which is what unifies human and automated steps in one graph.
128
+ - **Form resolution is specific-else-generic:** (1) an explicit `formKey` on the node; else (2) a form
129
+ **selected** by node category; else (3) a **generic** fallback form that still captures a typed
130
+ emitted fact (so *every* human node can emit downstream even with no bespoke form). Forms are
131
+ preferably **attached at authoring time** (deterministic, visible in preview); a **runtime
132
+ agent-form-router is a gated exception** — it fires only when a node activates with no resolvable
133
+ form, never in every human node's critical path (same "deterministic default, agent judgment as the
134
+ escape hatch" grain as the capability probe's empirical verifier).
135
+
136
+ ### 5. A trusted, deterministic compiler turns the JSON into something the engine runs — exposed as an agent tool
137
+
138
+ The *only* thing that turns graph-data into an executable is a **deterministic, human-written, tested
139
+ compiler**. It is exposed to the co-designing agent as a **tool** — `validate/compile(JSON) →
140
+ { ok, diagram, errors }` — which doubles as the **preview/dry-run**: the agent iterates JSON → tool →
141
+ fix, and the rendered graph is what a human approves before anything runs. Because the compiler only
142
+ ever instantiates allowlisted node kinds, it inherits Decision 1's trust bound.
143
+
144
+ ### 6. Execution strategy is swappable behind the JSON contract; the axis is static-vs-dynamic topology
145
+
146
+ The compiler MAY target either execution strategy, and because the JSON is the contract, the choice is
147
+ an implementation detail the agent never sees:
148
+
149
+ - **Compile-to-native** — emit a **one-shot native BPMN definition** (native parallel/event gateways do
150
+ the scheduling; you get a real diagram for free) and deploy it. **Best when the graph is known at
151
+ authoring time.**
152
+ - **Interpret** — feed the JSON to one generic deployed process that evaluates the ready-set at
153
+ runtime. **Best when the graph is discovered or mutated at runtime**, and the strategy that makes
154
+ **mid-flight amendment** tractable (edit a variable, not migrate a deployed definition).
155
+
156
+ The discriminator is **author-time-static vs runtime-dynamic topology**. Delivery runbooks (the
157
+ motivating case — a *pre-known* release choreography) are static → **start with compile-to-native**.
158
+ `plan-fanout` stays **interpret** (its graph is agent-discovered and its waves adapt to results). The
159
+ shared JSON contract means either can be swapped in later without touching the agent UX.
160
+
161
+ ### 7. Submission is propose → preview → approve → dispatch, idempotent, over the self-describing endpoint
162
+
163
+ Graphs are submitted exactly as epics are today — via a **new (proposed)** `POST
164
+ /actions/start/delivery-graph` endpoint (paths are relative to the agent guide's `__BASE__` prefix,
165
+ matching the guide's style) with the JSON body, discovered via the agent guide (which already
166
+ documents `POST /actions/start/plan-fanout` and `POST /actions/complete-user-task`). This endpoint
167
+ does not yet exist in `openapi.yaml` — it is introduced by this Proposed ADR. Three ways in, **one validated contract**: agent-ergonomic
168
+ (co-design → POST), raw REST, and a **UI JSON-paste fallback**. Because these graphs *merge PRs and
169
+ publish packages*, submission **defaults to propose-preview-approve** — the resolved graph (what it will
170
+ do, where it stops for humans) is rendered and a human approves before dispatch; that approval is itself
171
+ just the first human node. Submission carries an **idempotency key** so a re-POST cannot double-launch,
172
+ and every **side-effecting node (`connector`, merge, publish) carries a dedupe key** and tolerates
173
+ at-least-once execution (mirroring the release workflow's `npx semantic-release`
174
+ "skip already-published" discipline — `.github/workflows/release.yml`) so a
175
+ resume cannot double-fire.
176
+
177
+ ## Consequences
178
+
179
+ - nwf gains a **generic delivery-graph runner** that composes its existing primitives; the motivating
180
+ human-in-the-middle cross-repo case (merge → un-draft → manual publish+OIDC → consume) becomes a
181
+ single submitted graph rather than hand-carried human coordination.
182
+ - **#263's deferred "publish node emits"** is subsumed: an emitting node (human *or* automated) that
183
+ hands a version to a downstream edge is Decision 4 + Decision 3.
184
+ - The **connector I/O surface** finds its orchestration home: a connector is just an automated emitting
185
+ node kind (Decision 2).
186
+ - New surface to own: the JSON graph schema, the deterministic compiler, and the `pr`/merge-state probe
187
+ kind. All bounded — the schema is validated at ingest, the compiler is deterministic and tested, and
188
+ every node inherits the ADR 0001 §2 timeout+escalation bound, so a malformed or hanging graph cannot
189
+ stall silently.
190
+ - The **agent never deploys executable artifacts**; the trust boundary is the closed vocabulary + the
191
+ human-written compiler, not agent output.
192
+ - Amendment is cheap **only** under the interpret strategy; compile-to-native graphs are amended by
193
+ cancel + resubmit (an accepted cost for pre-known runbooks).
194
+
195
+ ## Non-goals / deferred
196
+
197
+ - **Derive-by-default graph authoring.** Populating edges automatically from intake (the #242
198
+ classifier facet, or PR-dependency inference) — this ADR ships the **declared** graph + the runner;
199
+ derivation is sugar layered on later.
200
+ - **Runtime-dynamic delivery graphs.** The first cut is compile-to-native for **static** runbooks; the
201
+ interpret strategy for graphs whose shape changes at runtime is kept behind the same contract but not
202
+ built now.
203
+ - **Mid-flight amendment of a running graph** beyond cancel+resubmit.
204
+ - **A second workflow engine.** Scheduling composes the nano engine's native constructs (or a thin
205
+ ready-set loop); execution is always engine-native sub-processes. Do not re-implement durability,
206
+ timers, or receive-tasks.
207
+ - **Non-npm emit facts** (OCI/github-release) and behavioural edges beyond the `command` escape hatch —
208
+ added when a real case lands.
209
+
210
+ ## Open questions
211
+
212
+ - **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
213
+ minimal interpreter reusing `plan-fanout` patterns; the ADR leans native for static runbooks.
214
+ - **`pr`/merge-state probe kind boundary** — how much of `mergeProtocol.ts` (required checks, mergeable,
215
+ base guards) becomes probe-matcher config vs stays in the merge-loop node body.
216
+ - **Emitted-fact typing** — the schema for a node's typed output (version string, URL, artifact id) and
217
+ how downstream edges reference it (`from: <nodeId>.<fact>`), so binds are validated not stringly.
218
+ - **Definition lifecycle under compile-to-native** — naming, versioning, and GC of one-shot deployed
219
+ process definitions so the engine doesn't accumulate them.
220
+ - **Approval granularity** — one approval of the whole graph at submit, vs re-approval when a human node
221
+ amends the un-started tail (if amendment is ever allowed).
package/openapi.yaml CHANGED
@@ -821,6 +821,38 @@ components:
821
821
  instructions:
822
822
  type: string
823
823
  description: The full operator guide as markdown.
824
+ AgentSkill:
825
+ type: object
826
+ description: The portable Nano Workforce operator skill (SKILL.md) — a thin bootstrap an agent
827
+ runtime loads on demand. It resolves which instance to drive and then fetches the live
828
+ operator guide. The `skill` markdown has any embedded example keyed to this instance's
829
+ `baseUrl`.
830
+ additionalProperties: false
831
+ required:
832
+ - format
833
+ - appVersion
834
+ - generatedAt
835
+ - baseUrl
836
+ - skill
837
+ properties:
838
+ format:
839
+ type: string
840
+ description: The `skill` media format. Always "markdown".
841
+ enum:
842
+ - markdown
843
+ appVersion:
844
+ type: string
845
+ nullable: true
846
+ description: The running app version this skill was served from (null when unreadable).
847
+ generatedAt:
848
+ type: string
849
+ description: When this response was rendered (ISO-8601).
850
+ baseUrl:
851
+ type: string
852
+ description: The app control-API base the skill was fetched from (e.g. https://host/app/api).
853
+ skill:
854
+ type: string
855
+ description: The full operator skill (SKILL.md) as markdown, including its YAML frontmatter.
824
856
  SubmitResult:
825
857
  type: object
826
858
  required:
@@ -1861,6 +1893,28 @@ paths:
1861
1893
  application/json:
1862
1894
  schema:
1863
1895
  $ref: "#/components/schemas/ErrorBody"
1896
+ /agent/skill:
1897
+ get:
1898
+ operationId: getAgentSkill
1899
+ summary: The portable operator skill (markdown SKILL.md) an agent runtime loads on demand — a
1900
+ thin bootstrap that resolves which instance to drive, then fetches the live operator guide
1901
+ (GET /agent). Surfaced from the Overview tab's "Agent Instructions" prompt.
1902
+ security:
1903
+ - hookSecret: []
1904
+ - {}
1905
+ responses:
1906
+ "200":
1907
+ description: The operator skill, with any example keyed to this instance.
1908
+ content:
1909
+ application/json:
1910
+ schema:
1911
+ $ref: "#/components/schemas/AgentSkill"
1912
+ "401":
1913
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
1914
+ content:
1915
+ application/json:
1916
+ schema:
1917
+ $ref: "#/components/schemas/ErrorBody"
1864
1918
  /actions/start/convergence-loop:
1865
1919
  post:
1866
1920
  operationId: startConvergenceLoop
@@ -13,32 +13,18 @@
13
13
  // NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
14
14
 
15
15
  import { renderAgentGuide, resolveEngineBase } from "../app/agentGuide.ts";
16
+ import { resolveApiBase } from "../app/resolveApiBase.ts";
16
17
  import { buildVersionInfo, envVar } from "../app/version.ts";
17
18
  import { defineOperation } from "../nano-generated/operations.ts";
18
19
 
19
20
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
20
21
 
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
22
  export default defineOperation("getAgentInstructions", ({ req }, app) => {
37
23
  if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
38
24
  app.log.warn("getAgentInstructions rejected: missing/invalid shared secret");
39
25
  return { status: 401, body: { error: "unauthorized" } };
40
26
  }
41
- const baseUrl = resolveApiBase(req);
27
+ const baseUrl = resolveApiBase(req, "agent");
42
28
  return {
43
29
  status: 200,
44
30
  body: {
@@ -0,0 +1,72 @@
1
+ // Tests for GET /app/api/agent/skill → operation `getAgentSkill` (ADR 0058 OpenAPI surface).
2
+ // The SKILL.md markdown is served as the `skill` field with any example keyed to the request's
3
+ // control-API base. Mirrors the getAgentInstructions test's request shape and shared-secret guard.
4
+ import { test } from "node:test";
5
+ import { assert, assertEquals } from "#test-assert";
6
+ import type { AppApi } from "@nanobpm/urban";
7
+ import { noopLog } from "../test/log.ts";
8
+ import handler from "./getAgentSkill.ts";
9
+
10
+ const app = { log: noopLog() } as any as AppApi;
11
+
12
+ function input(headers: Record<string, string> = {}, path = "/app/api/agent/skill") {
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 SKILL.md markdown 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.skill === "string" && r.body.skill.length > 200);
35
+ });
36
+
37
+ test("the skill is the portable bootstrap: frontmatter + fetch-the-live-guide", async () => {
38
+ const md = ((await handler(input(), app)) as any).body.skill as string;
39
+ assert(md.includes("name: nano-workforce"), "carries the skill frontmatter");
40
+ assert(md.includes("/agent"), "bootstraps by fetching the live operator guide");
41
+ assert(md.includes("Confirm which instance") || md.includes("confirm"), "tells the agent to confirm the target instance");
42
+ });
43
+
44
+ test("baseUrl is keyed to the request's control-API base; no placeholder leaks", async () => {
45
+ const forwarded = input({ host: "wf.example.com", "x-forwarded-proto": "https" });
46
+ const body = (await handler(forwarded, app)) as any;
47
+ assertEquals(body.body.baseUrl, "https://wf.example.com/app/api");
48
+ assert(!body.body.skill.includes("__BASE__"), "no unsubstituted __BASE__ placeholder");
49
+ });
50
+
51
+ test("x-forwarded-proto is restricted to http/https", async () => {
52
+ const spoofed = input({ host: "wf.example.com", "x-forwarded-proto": "javascript" });
53
+ const body = (await handler(spoofed, app)) as any;
54
+ assertEquals(body.body.baseUrl, "http://wf.example.com/app/api", "unsafe scheme falls back to http");
55
+ });
56
+
57
+ test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
58
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
59
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
60
+ try {
61
+ // SECRET is bound at import time, so import a cache-busted copy to observe the guard.
62
+ const mod = await import(`./getAgentSkill.ts?guard=${Date.now()}`);
63
+ const guarded = mod.default as typeof handler;
64
+ const bad = (await guarded(input(), app)) as any;
65
+ assertEquals(bad.status, 401);
66
+ const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
67
+ assertEquals(ok.status, 200);
68
+ } finally {
69
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
70
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
71
+ }
72
+ });
@@ -0,0 +1,36 @@
1
+ // GET /app/api/agent/skill → operationId `getAgentSkill` (ADR 0058/0059 OpenAPI surface, base
2
+ // /app/api). Serves the portable operator *skill* (SKILL.md) an agent runtime loads on demand: a
3
+ // thin bootstrap that resolves which instance to drive and then fetches the live operator guide
4
+ // (GET /app/api/agent). Companion to getAgentInstructions.
5
+ //
6
+ // The runtime serializes an operation body as JSON, so the markdown skill is returned as the
7
+ // `skill` string field (alongside the app version + the base URL it was fetched from). Any
8
+ // `__BASE__` example is rewritten to THIS instance's control-API base (derived from the request).
9
+ //
10
+ // Read-only. The optional shared-secret guard mirrors /version and /agent: enforced HERE only when
11
+ // NANO_PR_WEBHOOK_SECRET is set (the runtime does not enforce OpenAPI `security`).
12
+
13
+ import { renderAgentSkill } from "../app/agentSkill.ts";
14
+ import { resolveApiBase } from "../app/resolveApiBase.ts";
15
+ import { buildVersionInfo, envVar } from "../app/version.ts";
16
+ import { defineOperation } from "../nano-generated/operations.ts";
17
+
18
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
19
+
20
+ export default defineOperation("getAgentSkill", ({ req }, app) => {
21
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
22
+ app.log.warn("getAgentSkill rejected: missing/invalid shared secret");
23
+ return { status: 401, body: { error: "unauthorized" } };
24
+ }
25
+ const baseUrl = resolveApiBase(req, "agent/skill");
26
+ return {
27
+ status: 200,
28
+ body: {
29
+ format: "markdown",
30
+ appVersion: buildVersionInfo().version,
31
+ generatedAt: new Date().toISOString(),
32
+ baseUrl,
33
+ skill: renderAgentSkill(baseUrl),
34
+ },
35
+ };
36
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.108.0",
3
+ "version": "0.109.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",
@@ -73,20 +73,6 @@
73
73
  "variant": "sub"
74
74
  }
75
75
  },
76
- {
77
- "type": "button",
78
- "id": "agent-instructions",
79
- "props": {
80
- "label": "\ud83e\udd16 Agent Instructions",
81
- "variant": "ghost",
82
- "modal": {
83
- "title": "Point your agent at Nano Workforce",
84
- "description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It tells the agent to read this workforce's live operator guide, then help you drive and debug it.",
85
- "copyLabel": "Copy prompt",
86
- "copyText": "You are helping me operate a running Nano Workforce instance \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nFirst, fetch and read its live operator guide. It tells you how to submit PRs and epics for convergence (including whether to go all the way to merge or stop at review consensus), how to find engine instances and relate them to PRs via the Nano/Camunda-8 REST API, how to inspect the models and prompts, and how to help me untangle escalations:\n\n curl -sS {{appBase}}app/api/agent\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), add its value as an x-hook-secret header, otherwise the request returns 401:\n\n curl -sS -H \"x-hook-secret: <secret>\" {{appBase}}app/api/agent\n\nThen follow that guide to help me drive and debug this workforce. If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
87
- }
88
- }
89
- },
90
76
  {
91
77
  "type": "actionForm",
92
78
  "id": "submit",
@@ -70,6 +70,20 @@
70
70
  "variant": "sub"
71
71
  }
72
72
  },
73
+ {
74
+ "type": "button",
75
+ "id": "agent-instructions",
76
+ "props": {
77
+ "label": "\ud83e\udd16 Agent Instructions",
78
+ "variant": "ghost",
79
+ "modal": {
80
+ "title": "Point your agent at Nano Workforce",
81
+ "description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It tells the agent to load this workforce's operator skill from this instance, then help you drive and debug it.",
82
+ "copyLabel": "Copy prompt",
83
+ "copyText": "Load the Nano Workforce operator skill from this running instance and then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nThis is the instance to operate; its control-API base is {{appBase}}app/api. Fetch the skill and follow it (the response is JSON with a `skill` markdown field):\n\n curl -sS {{appBase}}app/api/agent/skill\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), add its value as an x-hook-secret header, otherwise the request returns 401:\n\n curl -sS -H \"x-hook-secret: <secret>\" {{appBase}}app/api/agent/skill\n\nThe skill is a thin bootstrap: it has you fetch this instance's live operator guide (at {{appBase}}app/api/agent) and then drive the workforce \u2014 submit PRs and epics for convergence, answer escalations, and debug stuck processes. If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
84
+ }
85
+ }
86
+ },
73
87
  {
74
88
  "type": "dataGrid",
75
89
  "id": "overview-prs",
@@ -0,0 +1,50 @@
1
+ # Agent skills
2
+
3
+ Portable agent skills that ship with Nano Workforce. A skill is a `SKILL.md` with
4
+ YAML frontmatter (`name`, `description`) that an agent runtime (Copilot CLI, Claude)
5
+ loads on demand when its `description` matches the task.
6
+
7
+ ## `nano-workforce`
8
+
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.
13
+
14
+ ### Install
15
+
16
+ Copy or symlink the skill into your agent's skills directory. For Copilot CLI:
17
+
18
+ ```bash
19
+ # symlink so it tracks this repo
20
+ ln -s "$(pwd)/skills/nano-workforce" ~/.copilot/skills/nano-workforce
21
+ # …or copy it
22
+ cp -r skills/nano-workforce ~/.copilot/skills/nano-workforce
23
+ ```
24
+
25
+ Then, from an agent session against your instance:
26
+
27
+ ```
28
+ Load the nano-workforce skill and drive my workforce.
29
+ ```
30
+
31
+ Set `NANO_WORKFORCE_URL` (and `NANO_PR_WEBHOOK_SECRET`, if your instance guards the
32
+ agent endpoints) so the bootstrap can reach your instance without prompting.
33
+
34
+ ### Multiple instances
35
+
36
+ If you run more than one instance (a local copy, one on the LAN, a tunnel when
37
+ you're off-LAN), register them by name so the skill can offer a choice and probe
38
+ which is live. Set `NANO_WORKFORCE_INSTANCES`, or write
39
+ `~/.config/nano-workforce/instances.json`:
40
+
41
+ ```json
42
+ {
43
+ "local": "http://localhost:3000/app/api",
44
+ "merlin": "http://merlin.local:3000/app/api",
45
+ "remote": "https://<subdomain>.ngrok.app/app/api"
46
+ }
47
+ ```
48
+
49
+ Then just say *"drive merlin"* — or let the skill probe reachability and ask which
50
+ to use (off the LAN, `merlin.local` won't resolve, so it steers you to `remote`).
@@ -0,0 +1,126 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Nano Workforce operator skill
7
+
8
+ Nano Workforce (nwf) is a durable orchestration app that drives pull requests to
9
+ **review convergence** and merges them, takes whole issues and **plans →
10
+ implements → converges** them across a fleet of coding agents, and runs
11
+ **agent-authored delivery graphs** (heterogeneous cross-repo, human-in-the-loop
12
+ DAGs — ADR 0005).
13
+
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.
19
+
20
+ ## 1. Confirm which instance you are driving — always
21
+
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.**
28
+
29
+ ### Sources of candidate instances
30
+
31
+ Gather candidates from, in order:
32
+
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:
36
+
37
+ ```json
38
+ { "local": "http://localhost:3000/app/api",
39
+ "merlin": "http://merlin.local:3000/app/api",
40
+ "remote": "https://<subdomain>.ngrok.app/app/api" }
41
+ ```
42
+
43
+ 2. `$NANO_WORKFORCE_URL`, if set (a single default; accept as-is, append `/app/api`
44
+ only if it is a bare origin).
45
+ 3. Any URL the user names in the conversation.
46
+ 4. Local fallback: `http://localhost:3000/app/api` (port `PR_REVIEW_PORT`, default `3000`).
47
+
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:
55
+
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.
65
+
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.
68
+
69
+ ## 2. Fetch the live guide — this is your real playbook
70
+
71
+ ```bash
72
+ curl -sS ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"} \
73
+ "$BASE/agent" | jq -r '.instructions'
74
+ ```
75
+
76
+ `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
87
+
88
+ The guide's first steps confirm what is live and what is in flight:
89
+
90
+ ```bash
91
+ curl -sS "$BASE/version" | jq # app/urban version, git sha, uptime
92
+ curl -sS "$BASE/status" | jq # every PR/instance in flight + open escalations
93
+ ```
94
+
95
+ `/status` is the primary situational-awareness endpoint — check it before you
96
+ submit or unstick anything.
97
+
98
+ ## 4. What you can drive (all detailed in the live guide)
99
+
100
+ - **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.
112
+
113
+ ## Principles
114
+
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, ask — which 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.