@nanobpm/nano-workforce 0.108.0 → 0.110.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.
@@ -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:
@@ -1221,6 +1253,250 @@ components:
1221
1253
  everyMs: { type: integer, description: Interval between poll attempts (ms). }
1222
1254
  timeoutMs: { type: integer, description: Bounded budget (ms) before the gate escalates. }
1223
1255
  backoff: { type: string, enum: [fixed, exponential], description: Backoff shape between attempts. }
1256
+ DeliveryGraph:
1257
+ description: >-
1258
+ An agent-authored delivery graph (ADR 0005) — the SINGLE agent-facing artifact for a
1259
+ heterogeneous, partly-human, cross-repo delivery runbook. It is DATA, never an executable
1260
+ artifact: a JSON DAG whose nodes each name a `kind` from a CLOSED allowlist
1261
+ (`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) and whose `edges`
1262
+ name DISCOVERED facts (Decision 3). Ingest validates the SHAPE here and the SEMANTICS
1263
+ (acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph`
1264
+ (`app/deliveryGraph.ts`). This slice (S0) defines the vocabulary + validation surface ONLY —
1265
+ no compiler, dispatch, or execution (those land in later slices).
1266
+ type: object
1267
+ additionalProperties: false
1268
+ required:
1269
+ - nodes
1270
+ properties:
1271
+ name:
1272
+ type: string
1273
+ maxLength: 255
1274
+ description: OPTIONAL human-readable label for the graph (shown in the rendered preview).
1275
+ nodes:
1276
+ type: array
1277
+ minItems: 1
1278
+ maxItems: 256
1279
+ items:
1280
+ $ref: "#/components/schemas/DeliveryNode"
1281
+ description: >-
1282
+ The graph's nodes. Each carries a unique `id` and a `kind` from the closed allowlist,
1283
+ plus its per-kind config and its typed `emits[]` declaration. Node ids must be unique
1284
+ across the graph (enforced by `validateDeliveryGraph`).
1285
+ edges:
1286
+ type: array
1287
+ maxItems: 1024
1288
+ items:
1289
+ $ref: "#/components/schemas/DeliveryEdge"
1290
+ description: >-
1291
+ The dependency edges — the graph's discovered-fact topology (Decision 3). Each edge means
1292
+ "`to` proceeds once fact `from` about the upstream node is observable". `from` is either a
1293
+ bare `<nodeId>` (the degenerate "wait for the upstream node's completion" fact) or a
1294
+ qualified `<nodeId>.<fact>` referencing one of that node's declared `emits`. Omit/`[]` for
1295
+ a set of independent (root) nodes. The edge set must be a DAG.
1296
+ DeliveryNode:
1297
+ description: >-
1298
+ One node in a delivery graph. A discriminated union on `kind` over the CLOSED allowlist; the
1299
+ matching per-kind config object (`agent`/`wait`/`connector`) is REQUIRED and names the
1300
+ engine-native body the node delegates to (Decision 2 — the graph schedules, it does not
1301
+ re-implement execution). The `human` config is the sole exception — it is OPTIONAL (a bare
1302
+ `human` node resolves to a generic emit-capturing form fallback in S3).
1303
+ oneOf:
1304
+ - $ref: "#/components/schemas/DeliveryNodeAgent"
1305
+ - $ref: "#/components/schemas/DeliveryNodeWait"
1306
+ - $ref: "#/components/schemas/DeliveryNodeHuman"
1307
+ - $ref: "#/components/schemas/DeliveryNodeConnector"
1308
+ discriminator:
1309
+ propertyName: kind
1310
+ mapping:
1311
+ agent: "#/components/schemas/DeliveryNodeAgent"
1312
+ wait: "#/components/schemas/DeliveryNodeWait"
1313
+ human: "#/components/schemas/DeliveryNodeHuman"
1314
+ connector: "#/components/schemas/DeliveryNodeConnector"
1315
+ DeliveryFact:
1316
+ description: >-
1317
+ A typed output a node declares it will EMIT (ADR 0005 Decision 3/4 — emitted-fact typing).
1318
+ A downstream edge references it as `from: "<nodeId>.<fact>"`, so a bind is validated against
1319
+ this declaration, not stringly. A "click done" human node or a pass-through node declares no
1320
+ facts (`emits` absent/empty) — the degenerate no-emit case.
1321
+ type: object
1322
+ additionalProperties: false
1323
+ required:
1324
+ - name
1325
+ - type
1326
+ properties:
1327
+ name:
1328
+ type: string
1329
+ minLength: 1
1330
+ maxLength: 128
1331
+ pattern: '^[A-Za-z_][A-Za-z0-9_]*$'
1332
+ description: The fact's identifier, referenced downstream as `<nodeId>.<name>`. Must be unique within the node.
1333
+ type:
1334
+ type: string
1335
+ enum: [string, number, boolean, artifact, version, url]
1336
+ description: >-
1337
+ The fact's declared type. `artifact` is a `pkg@version` handle, `version` a bare version
1338
+ string, `url` a location — mirrors the values `capability`/`pr` probes late-bind.
1339
+ description:
1340
+ type: string
1341
+ maxLength: 512
1342
+ description: OPTIONAL human note describing what the fact carries.
1343
+ DeliveryNodeCommon:
1344
+ type: object
1345
+ properties:
1346
+ id:
1347
+ type: string
1348
+ minLength: 1
1349
+ maxLength: 128
1350
+ pattern: '^[A-Za-z_][A-Za-z0-9_.-]*$'
1351
+ description: The node's identifier, unique within the graph and referenced by edges.
1352
+ emits:
1353
+ type: array
1354
+ maxItems: 32
1355
+ items:
1356
+ $ref: "#/components/schemas/DeliveryFact"
1357
+ description: >-
1358
+ The typed facts this node hands forward when it completes (Decision 3/4). Absent/empty for
1359
+ a node that emits nothing. Downstream edges bind these via `from: "<nodeId>.<fact>"`.
1360
+ DeliveryNodeAgent:
1361
+ description: >-
1362
+ An `agent` node — a worker executes an agent job type (the existing fan-out body). Bounded
1363
+ (timeout → escalate) and resumable like every node.
1364
+ allOf:
1365
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1366
+ - type: object
1367
+ additionalProperties: false
1368
+ required:
1369
+ - id
1370
+ - kind
1371
+ - agent
1372
+ properties:
1373
+ id: { type: string }
1374
+ kind: { type: string, enum: [agent] }
1375
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1376
+ agent:
1377
+ type: object
1378
+ additionalProperties: false
1379
+ required:
1380
+ - jobType
1381
+ properties:
1382
+ jobType:
1383
+ type: string
1384
+ minLength: 1
1385
+ description: The agent job type a worker executes for this node (e.g. `senior:feature`).
1386
+ prompt:
1387
+ type: string
1388
+ maxLength: 20000
1389
+ description: OPTIONAL steering prompt appended to the node's job brief.
1390
+ DeliveryNodeWait:
1391
+ description: >-
1392
+ A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
1393
+ existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
1394
+ merge-state kind is added to that shape by slice S2 and flows in here automatically.
1395
+ allOf:
1396
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1397
+ - type: object
1398
+ additionalProperties: false
1399
+ required:
1400
+ - id
1401
+ - kind
1402
+ - wait
1403
+ properties:
1404
+ id: { type: string }
1405
+ kind: { type: string, enum: [wait] }
1406
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1407
+ wait:
1408
+ $ref: "#/components/schemas/ReadinessProbe"
1409
+ DeliveryNodeHuman:
1410
+ description: >-
1411
+ A `human` node — a scheduled user task + form (ADR 0002 machinery promoted from exception to
1412
+ node, Decision 4). Surfaces "now do X" on the Tasks inbox, blocks dependents, is answerable by
1413
+ a human OR an agent, is SLA-bounded, and can EMIT a typed fact its form captures.
1414
+ allOf:
1415
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1416
+ - type: object
1417
+ additionalProperties: false
1418
+ required:
1419
+ - id
1420
+ - kind
1421
+ properties:
1422
+ id: { type: string }
1423
+ kind: { type: string, enum: [human] }
1424
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1425
+ human:
1426
+ type: object
1427
+ additionalProperties: false
1428
+ description: >-
1429
+ OPTIONAL human-node config. `formKey` explicitly attaches a form (else a form is
1430
+ selected by node category, else a generic emit-capturing fallback — resolved in S3).
1431
+ The node's typed output is declared via the node-level `emits[]`.
1432
+ properties:
1433
+ formKey:
1434
+ type: string
1435
+ minLength: 1
1436
+ description: OPTIONAL explicit form to attach at authoring time (specific-else-generic resolution, S3).
1437
+ prompt:
1438
+ type: string
1439
+ maxLength: 20000
1440
+ description: OPTIONAL instruction shown to the human/agent completing the task ("now do X").
1441
+ DeliveryNodeConnector:
1442
+ description: >-
1443
+ A `connector` node — an automated, side-effecting outbound action (the connector I/O surface).
1444
+ Side-effecting, so it carries a `dedupeKey` and tolerates at-least-once execution. The
1445
+ `payload` schema is a minimal forward-declared stub in this slice (ADR 0005 non-goal — the
1446
+ concrete connector I/O lands later).
1447
+ allOf:
1448
+ - $ref: "#/components/schemas/DeliveryNodeCommon"
1449
+ - type: object
1450
+ additionalProperties: false
1451
+ required:
1452
+ - id
1453
+ - kind
1454
+ - connector
1455
+ properties:
1456
+ id: { type: string }
1457
+ kind: { type: string, enum: [connector] }
1458
+ emits: { type: array, items: { $ref: "#/components/schemas/DeliveryFact" } }
1459
+ connector:
1460
+ type: object
1461
+ additionalProperties: false
1462
+ required:
1463
+ - target
1464
+ properties:
1465
+ target:
1466
+ type: string
1467
+ minLength: 1
1468
+ description: The connector action target (forward-declared — the concrete scheme lands in a later slice).
1469
+ dedupeKey:
1470
+ type: string
1471
+ minLength: 1
1472
+ description: >-
1473
+ OPTIONAL idempotency key so an at-least-once resume cannot double-fire this
1474
+ side-effecting node (ADR 0005 Decision 7). Author-supplied or graph-derived.
1475
+ payload:
1476
+ type: object
1477
+ additionalProperties: true
1478
+ description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
1479
+ DeliveryEdge:
1480
+ description: >-
1481
+ A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
1482
+ `from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
1483
+ qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
1484
+ must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
1485
+ set must be a DAG — all enforced by `validateDeliveryGraph`.
1486
+ type: object
1487
+ additionalProperties: false
1488
+ required:
1489
+ - from
1490
+ - to
1491
+ properties:
1492
+ from:
1493
+ type: string
1494
+ minLength: 1
1495
+ description: The upstream endpoint — `<nodeId>` (completion) or `<nodeId>.<fact>` (a declared emitted fact).
1496
+ to:
1497
+ type: string
1498
+ minLength: 1
1499
+ description: The dependent node's id — proceeds once `from` is observed.
1224
1500
  FeatureStart:
1225
1501
  description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
1226
1502
  by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
@@ -1861,6 +2137,28 @@ paths:
1861
2137
  application/json:
1862
2138
  schema:
1863
2139
  $ref: "#/components/schemas/ErrorBody"
2140
+ /agent/skill:
2141
+ get:
2142
+ operationId: getAgentSkill
2143
+ summary: The portable operator skill (markdown SKILL.md) an agent runtime loads on demand — a
2144
+ thin bootstrap that resolves which instance to drive, then fetches the live operator guide
2145
+ (GET /agent). Surfaced from the Overview tab's "Agent Instructions" prompt.
2146
+ security:
2147
+ - hookSecret: []
2148
+ - {}
2149
+ responses:
2150
+ "200":
2151
+ description: The operator skill, with any example keyed to this instance.
2152
+ content:
2153
+ application/json:
2154
+ schema:
2155
+ $ref: "#/components/schemas/AgentSkill"
2156
+ "401":
2157
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
2158
+ content:
2159
+ application/json:
2160
+ schema:
2161
+ $ref: "#/components/schemas/ErrorBody"
1864
2162
  /actions/start/convergence-loop:
1865
2163
  post:
1866
2164
  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
+ });