@nanobpm/nano-workforce 0.60.0 → 0.61.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.61.0](https://github.com/nanobpm/nano-workforce/compare/v0.60.0...v0.61.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * prompts as linked resources (bindingType: latest) — live mid-epic updates ([#198](https://github.com/nanobpm/nano-workforce/issues/198)) ([#203](https://github.com/nanobpm/nano-workforce/issues/203)) ([24a83e0](https://github.com/nanobpm/nano-workforce/commit/24a83e01f7e7b4f6e5296ce90c63fa44105aa058)), closes [#169](https://github.com/nanobpm/nano-workforce/issues/169) [#169](https://github.com/nanobpm/nano-workforce/issues/169)
7
+
1
8
  # [0.60.0](https://github.com/nanobpm/nano-workforce/compare/v0.59.0...v0.60.0) (2026-08-13)
2
9
 
3
10
 
package/README.md CHANGED
@@ -156,7 +156,8 @@ capability):
156
156
 
157
157
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
158
158
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
159
- `job.variables`) to stdin; the relevant `prompts/*.md` template tells the agent how
159
+ `job.variables`) to stdin; the relevant `prompts/*.md` resource (linked into the task
160
+ and fetched by the harness at activation) tells the agent how
160
161
  to read it and where to write its result.
161
162
  - **`--allow-all-tools` is essential** for an unattended worker — without it Copilot
162
163
  pauses for permission before each tool call and the job stalls.
@@ -280,10 +281,18 @@ The `senior:*` tasks are serviced by **external** workers — they are **not** i
280
281
  manifest `workers[]`. Point a `c8ctl nano work` daemon (or any Zeebe-style worker) at
281
282
  the task types; each job carries its variables (e.g. `senior:pr-review` gets `{prUrl,
282
283
  repo, prNumber, round, answer?}` and returns `{status, summary, question?}`). An
283
- agent's base prompt is **not** in the job payload — it is delivered via a model
284
- **template header** (`{{review-round}}`, `{{plan}}`, `{{feature}}`, `{{fix-ci}}`, …)
285
- substituted at deploy time from `prompts/*.md` (`models.templates` in `nano.app.json`);
286
- per-instance context (e.g. a human's escalation answer) is appended by the harness.
284
+ agent's base prompt is **not** in the job payload — it is delivered as a
285
+ **linked resource** (likewise `plan.md`, `feature.md`, `fix-ci.md`, …):
286
+
287
+ ```xml
288
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
289
+ ```
290
+
291
+ that the engine resolves to the latest deployed `prompts/*.md` at job
292
+ activation (the prompts deploy as generic resources via a `models` glob in
293
+ `nano.app.json`); per-instance context (e.g. a human's escalation answer) is appended
294
+ by the harness. Because the binding is `latest`, redeploying one `prompts/*.md` updates
295
+ the prompt for the next activation in a **running** epic — see SPEC §9.
287
296
 
288
297
  ---
289
298
 
package/SPEC.md CHANGED
@@ -86,11 +86,11 @@ known at submit time, carried as a process variable and stored on the DB row.
86
86
 
87
87
 
88
88
  [Register PR] (script/handler) → insert DB row; round = 1
89
- │ (base prompt delivered via the {{review-round}} model
90
- template header, not a process variable)
89
+ │ (base prompt delivered via the review-round.md
90
+ linked resource, not a process variable)
91
91
 
92
92
  ┌──▶ [Review round] (service task, taskType: senior:pr-review)
93
- │ in : prUrl, repo, prNumber, round, answer? (prompt via task header)
93
+ │ in : prUrl, repo, prNumber, round, answer? (prompt via linked resource)
94
94
  │ out: status, summary, question?
95
95
  │ │
96
96
  │ ▼
@@ -153,10 +153,11 @@ Notes:
153
153
  | `round` | int | 1-based round counter |
154
154
  | `answer` | string? | present only when resuming from an escalation |
155
155
 
156
- The base instructions are **not** a job variable: they are delivered as a model
157
- **template header** on the `senior:pr-review` task — header key
158
- `io.nanobpm.agentTask.task.prompt` with value `{{review-round}}`, substituted with
159
- `prompts/review-round.md` at deploy time.
156
+ The base instructions are **not** a job variable: they are delivered as a
157
+ **linked resource** on the `senior:pr-review` task —
158
+ `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`,
159
+ which the engine resolves to the latest deployed `prompts/review-round.md` at job
160
+ activation.
160
161
 
161
162
  **Output** (job result variables):
162
163
  | var | type | notes |
@@ -292,24 +293,54 @@ tracked row to `abandoned` via the `instanceTracking` `onTerminated.set` patch.
292
293
  `deno task purge` wipes and re-migrates the app db (used
293
294
  when the engine data is purged, to keep app state and engine state consistent).
294
295
 
295
- ## 9. Prompt delivery — model-authored template headers
296
+ ## 9. Prompt delivery — linked resources (`bindingType: latest`)
296
297
 
297
- Each agent task's base prompt lives **only** in its `prompts/*.md` side-car and is
298
- authored **into the model** as a deploy-time `{{stem}}` template. `nano.app.json`
299
- declares `models.templates: ["prompts/*.md"]`, and each agent service task carries a
300
- `io.nanobpm.agentTask.task.prompt = {{stem}}` task header (`{{review-round}}`,
301
- `{{plan}}`, `{{plan-review}}`, `{{feature}}`, `{{fix-ci}}`). At deploy the template
302
- substitutes the file content into the header, so the host no longer reads prompt
303
- assets or carries them as process variables.
298
+ Each agent task's base prompt lives **only** in its `prompts/*.md` side-car, deployed
299
+ as a **generic resource** and **linked** not baked into the model (issue #169).
300
+ `nano.app.json` lists `prompts/*.md` in a `models` deploy glob, so `@nanobpm/urban`
301
+ deploys each file as an `application/octet-stream` resource whose deployed **name is
302
+ the file's basename** (`prompts/review-round.md` → resource `review-round.md`). Each
303
+ agent service task links it:
304
304
 
305
- Per-instance dynamic context rides **`appendPrompt`**: an ioMapping sets a job-local
306
- `appendPrompt` string (a plan's rejection findings, a feature task's brief, the
307
- failing-check list) which the agent harness concatenates **verbatim** onto the header
308
- base — the model owns any separator, and a null/empty append leaves the base
305
+ ```xml
306
+ <zeebe:linkedResources>
307
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
308
+ </zeebe:linkedResources>
309
+ ```
310
+
311
+ At **job activation** the engine resolves the *latest deployed* key for that
312
+ `resourceId` and hands the content to the harness in the `linkedResources` activation
313
+ header; the harness fetches by key and uses it as the base prompt. Because the binding
314
+ is `latest`, **redeploying a single `prompts/*.md` changes the prompt for the next task
315
+ activation in a running epic** — no process redeploy, no in-flight epic restart. This
316
+ is the live-prompt debugging loop: edit one Markdown file, `urban deploy` (or restart
317
+ the app, which deploys on boot), and the next agent job of that type picks it up.
318
+
319
+ > **Latest-for-now, audited.** The engine currently keeps only the latest version per
320
+ > `resourceId` (`deployment`/`versionTag` bindings degrade to latest — no pinning yet).
321
+ > We accept `latest` (ideal for active debugging) and rely on the harness recording the
322
+ > resolved `resourceKey` per job for "which prompt did this run use?". True
323
+ > `deployment`-binding pinning for reproducible production epics is an engine follow-up,
324
+ > not part of #169.
325
+
326
+ > **The engine silently omits an unresolvable link.** A typo'd or undeployed
327
+ > `resourceId` is dropped from the activation header (no incident) — the agent would
328
+ > then run prompt-less. `scripts/check-agent-prompts.ts` (CI gate `check:prompts`)
329
+ > guards against this: every `linkName="prompt"` link's `resourceId` must match a
330
+ > prompt file the app actually deploys (a file in a `models` deploy glob), each linked
331
+ > prompt must be non-blank and teach the agent to emit a machine-readable result
332
+ > (`$AGENT_RESULT_FILE` / `::nano:result::`), and no task may still carry the retired
333
+ > baked `io.nanobpm.agentTask.task.prompt` header.
334
+
335
+ Per-instance dynamic context still rides **`appendPrompt`** (unchanged): an ioMapping
336
+ sets a job-local `appendPrompt` string (a plan's rejection findings, a feature task's
337
+ brief, the failing-check list) which the agent harness concatenates **verbatim** onto
338
+ the linked base — the model owns any separator, and a null/empty append leaves the base
309
339
  untouched. Base prompts can't be composed in FEEL (they are quote-heavy, and XML
310
340
  attribute escaping would corrupt a FEEL string literal), so composition happens via
311
- this append seam rather than inline in FEEL. Requires `@nanobpm/urban` with
312
- deploy-time template substitution.
341
+ this append seam rather than inline in FEEL. Requires an `@nanobpm/urban` deploy that
342
+ emits generic-resource deployments for `prompts/*.md` and a harness that consumes
343
+ `linkedResources` and fetches the resource by key.
313
344
 
314
345
  ## 10. Poller
315
346
 
@@ -365,7 +396,7 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
365
396
 
366
397
  - **CI auto-fix** — a `blocked` verdict means a **required check failed**
367
398
  (`classifyMergeability`). Rather than escalate immediately, the stage dispatches a
368
- `senior:fix-ci` agent (base prompt via the `{{fix-ci}}` template header; the failing
399
+ `senior:fix-ci` agent (base prompt via the `fix-ci.md` linked resource; the failing
369
400
  check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
370
401
  poller. It repeats while `ciFixRound < ciFixMax`
371
402
  (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
@@ -432,7 +463,7 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
432
463
  ```
433
464
 
434
465
  - **`plan`** — service task, job type `senior:plan`. Its base prompt is delivered
435
- via the `{{plan}}` model template header (`prompts/plan.md`); when a prior review
466
+ via the `plan.md` linked resource (`bindingType: latest`); when a prior review
436
467
  rejected the plan, the rejection findings ride `appendPrompt` (an ioMapping over
437
468
  `planFindings`) rather than being concatenated in FEEL. The agent reads the issue
438
469
  via `gh` and emits `tasks: [{ id, title, prompt }]`.
@@ -442,8 +473,8 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
442
473
  iterates the canonical list.
443
474
  - **`implement`** — service task, job type `senior:feature`, **parallel
444
475
  multi-instance** over `=tasks` (`inputElement="task"`,
445
- `outputCollection="results"`). Its base prompt is delivered via the `{{feature}}`
446
- model template header (`prompts/feature.md`); each child's per-task brief
476
+ `outputCollection="results"`). Its base prompt is delivered via the `feature.md`
477
+ linked resource (`bindingType: latest`); each child's per-task brief
447
478
  (`"\n\n---\n\n" + task.prompt`) rides `appendPrompt` — an input mapping evaluated
448
479
  **per child** (Zeebe parity: the inner activity keeps its own `zeebe:ioMapping`,
449
480
  applied on each inner-instance activation with `task`/`loopCounter` bound). Each
@@ -62,9 +62,9 @@ test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate",
62
62
  assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
63
63
  assertStringIncludes(flat, "rebaseRound &lt; rebaseMax");
64
64
 
65
- // The agent is the senior:rebase fleet task, carrying its base prompt via the {{rebase}} header.
65
+ // The agent is the senior:rebase fleet task, carrying its base prompt via the rebase.md linked resource.
66
66
  assertStringIncludes(flat, 'type="senior:rebase"');
67
- assertStringIncludes(flat, 'value="{{rebase}}"');
67
+ assertStringIncludes(flat, 'resourceId="rebase.md"');
68
68
 
69
69
  // Agent → result gate; the round counter advances so the budget can actually be exhausted.
70
70
  assert(hasFlow("rebase", "gw-rebase-result"), "rebase → gw-rebase-result missing");
package/app/plan.ts CHANGED
@@ -31,11 +31,12 @@ export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
31
31
  const now = () => new Date().toISOString();
32
32
 
33
33
  // Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
34
- // `senior:feature` prompts are authored in the model as `{{plan}}` / `{{plan-review}}` /
35
- // `{{feature}}` deploy-time templates (see `models.templates` in nano.app.json) substituted into
36
- // each task's `io.nanobpm.agentTask.task.prompt` header. Per-instance dynamic context (a plan's
37
- // rejection findings, a task's brief) rides `appendPrompt`, which the harness concatenates onto
38
- // the header base. The host only carries runtime identity + `planFindings`.
34
+ // `senior:feature` prompts are generic resources (`prompts/plan.md` / `prompts/plan-review.md` /
35
+ // `prompts/feature.md`, deployed via a `models` glob in nano.app.json) linked into each task as
36
+ // `<zeebe:linkedResource bindingType="latest" linkName="prompt"/>` and resolved by the engine at
37
+ // job activation. Per-instance dynamic context (a plan's rejection findings, a task's brief) rides
38
+ // `appendPrompt`, which the harness concatenates onto the linked base. The host only carries
39
+ // runtime identity + `planFindings`.
39
40
 
40
41
  export interface Plan {
41
42
  plan_key: string;
package/app/retro.ts CHANGED
@@ -161,7 +161,7 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
161
161
  }
162
162
 
163
163
  /** Render the digest as the compact markdown brief handed to the retro agent (rides `appendPrompt`,
164
- * concatenated after the base `{{retro}}` prompt — so it owns its own leading separator). */
164
+ * concatenated after the base `retro.md` linked-resource prompt — so it owns its own leading separator). */
165
165
  export function renderRetroBrief(d: RetroDigest): string {
166
166
  const lines: string[] = [
167
167
  "",
package/app/service.ts CHANGED
@@ -85,10 +85,11 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
85
85
  (process.env.NANO_PR_MERGE_ADMIN ?? "0").trim().toLowerCase(),
86
86
  );
87
87
 
88
- // The `senior:pr-review` agent prompt is no longer read by the host: it is authored in the
89
- // model as a `{{review-round}}` deploy-time template (see `models.templates` in nano.app.json)
90
- // substituted into the task's `io.nanobpm.agentTask.task.prompt` header. The host only carries
91
- // runtime PR identity + the round counter now.
88
+ // The `senior:pr-review` agent prompt is no longer read by the host: it is a generic resource
89
+ // (`prompts/review-round.md`, deployed via a `models` glob in nano.app.json) linked into the task
90
+ // as `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`
91
+ // and resolved by the engine at job activation. The host only carries runtime PR identity + the
92
+ // round counter now.
92
93
 
93
94
  const now = () => new Date().toISOString();
94
95
 
package/nano.app.json CHANGED
@@ -6,13 +6,11 @@
6
6
  "codename": "nano-workforce",
7
7
  "models": {
8
8
  "processes": [
9
- "resources/processes/*.bpmn"
9
+ "resources/processes/*.bpmn",
10
+ "prompts/*.md"
10
11
  ],
11
12
  "forms": [
12
13
  "resources/forms/*.form"
13
- ],
14
- "templates": [
15
- "prompts/*.md"
16
14
  ]
17
15
  },
18
16
  "data": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.60.0",
3
+ "version": "0.61.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",
@@ -309,9 +309,17 @@ both live in the source repo, not in the job payload.
309
309
  sequence flows) for the element it is parked on (§5).
310
310
  - **Prompts (agent base instructions):** `prompts/*.md` — `review-round.md`,
311
311
  `plan.md`, `feature.md`, `fix-ci.md`, `rebase.md`, `trial-merge.md`, etc. An
312
- agent's base prompt is **not** a job variable: it is delivered as a model
313
- **template header** (`{{review-round}}`, `{{plan}}`, …) substituted from these files
314
- at deploy time. If an agent misbehaves systematically, the prompt is the first thing
312
+ agent's base prompt is **not** a job variable: it is delivered as a
313
+ **linked resource** (likewise `plan.md`, …):
314
+
315
+ ```xml
316
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
317
+ ```
318
+
319
+ that the engine
320
+ resolves to the latest deployed prompt at job activation. Because the binding is
321
+ `latest`, redeploying one of these files updates the prompt mid-epic without a process
322
+ redeploy. If an agent misbehaves systematically, the prompt is the first thing
315
323
  to inspect/fix.
316
324
  - **Job contract:** `senior:pr-review` receives `{ prUrl, repo, prNumber, round,
317
325
  answer? }` and must return a flat result `{ status, summary, question? }` with
@@ -67,9 +67,9 @@
67
67
  <bpmn:serviceTask id="review-round" name="Review round (agent)">
68
68
  <bpmn:extensionElements>
69
69
  <zeebe:taskDefinition type="senior:pr-review" />
70
- <zeebe:taskHeaders>
71
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review-round}}" />
72
- </zeebe:taskHeaders>
70
+ <zeebe:linkedResources>
71
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
72
+ </zeebe:linkedResources>
73
73
  <zeebe:properties>
74
74
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrReviewRoundIn" />
75
75
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrReviewRoundOut" />
@@ -18,9 +18,9 @@
18
18
  <bpmn:serviceTask id="implement-task" name="Implement (agent)">
19
19
  <bpmn:extensionElements>
20
20
  <zeebe:taskDefinition type="senior:feature" />
21
- <zeebe:taskHeaders>
22
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{feature}}" />
23
- </zeebe:taskHeaders>
21
+ <zeebe:linkedResources>
22
+ <zeebe:linkedResource resourceId="feature.md" bindingType="latest" linkName="prompt" />
23
+ </zeebe:linkedResources>
24
24
  <zeebe:ioMapping>
25
25
  <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief)" target="appendPrompt" />
26
26
  <zeebe:output source="=status" target="status" />
@@ -216,9 +216,9 @@
216
216
  <bpmn:serviceTask id="fix-ci" name="Fix CI (agent)">
217
217
  <bpmn:extensionElements>
218
218
  <zeebe:taskDefinition type="senior:fix-ci" />
219
- <zeebe:taskHeaders>
220
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{fix-ci}}" />
221
- </zeebe:taskHeaders>
219
+ <zeebe:linkedResources>
220
+ <zeebe:linkedResource resourceId="fix-ci.md" bindingType="latest" linkName="prompt" />
221
+ </zeebe:linkedResources>
222
222
  <zeebe:properties>
223
223
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="FixCiIn" />
224
224
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="FixCiOut" />
@@ -246,9 +246,9 @@
246
246
  <bpmn:serviceTask id="rebase" name="Rebase (agent)">
247
247
  <bpmn:extensionElements>
248
248
  <zeebe:taskDefinition type="senior:rebase" />
249
- <zeebe:taskHeaders>
250
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{rebase}}" />
251
- </zeebe:taskHeaders>
249
+ <zeebe:linkedResources>
250
+ <zeebe:linkedResource resourceId="rebase.md" bindingType="latest" linkName="prompt" />
251
+ </zeebe:linkedResources>
252
252
  <zeebe:properties>
253
253
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RebaseIn" />
254
254
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="RebaseOut" />
@@ -23,9 +23,9 @@
23
23
  <bpmn:serviceTask id="plan" name="Plan (agent)">
24
24
  <bpmn:extensionElements>
25
25
  <zeebe:taskDefinition type="senior:plan" />
26
- <zeebe:taskHeaders>
27
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{plan}}" />
28
- </zeebe:taskHeaders>
26
+ <zeebe:linkedResources>
27
+ <zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt" />
28
+ </zeebe:linkedResources>
29
29
  <zeebe:ioMapping>
30
30
  <zeebe:input source="=(if (planFindings = null or planFindings = &#34;&#34;) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;A prior review REJECTED your last plan. Address every point, then re-emit the full plan:&#10;&#10;&#34; + planFindings)" target="appendPrompt" />
31
31
  </zeebe:ioMapping>
@@ -46,9 +46,9 @@
46
46
  <bpmn:serviceTask id="review-plan" name="Review plan (agent)">
47
47
  <bpmn:extensionElements>
48
48
  <zeebe:taskDefinition type="senior:plan-review" />
49
- <zeebe:taskHeaders>
50
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{plan-review}}" />
51
- </zeebe:taskHeaders>
49
+ <zeebe:linkedResources>
50
+ <zeebe:linkedResource resourceId="plan-review.md" bindingType="latest" linkName="prompt" />
51
+ </zeebe:linkedResources>
52
52
  </bpmn:extensionElements>
53
53
  <bpmn:incoming>f_toReviewPlan</bpmn:incoming>
54
54
  <bpmn:outgoing>f_toRecordPlanReview</bpmn:outgoing>
@@ -121,9 +121,9 @@
121
121
  <bpmn:serviceTask id="implement-task" name="Implement (agent)">
122
122
  <bpmn:extensionElements>
123
123
  <zeebe:taskDefinition type="senior:feature" />
124
- <zeebe:taskHeaders>
125
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{feature}}" />
126
- </zeebe:taskHeaders>
124
+ <zeebe:linkedResources>
125
+ <zeebe:linkedResource resourceId="feature.md" bindingType="latest" linkName="prompt" />
126
+ </zeebe:linkedResources>
127
127
  <zeebe:ioMapping>
128
128
  <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (blackboardBrief = null) then &#34;&#34; else blackboardBrief) + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief)" target="appendPrompt" />
129
129
  <zeebe:output source="=status" target="status" />
@@ -192,9 +192,9 @@
192
192
  <bpmn:serviceTask id="trial-merge" name="Trial merge (agent)">
193
193
  <bpmn:extensionElements>
194
194
  <zeebe:taskDefinition type="senior:trial-merge" />
195
- <zeebe:taskHeaders>
196
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{trial-merge}}" />
197
- </zeebe:taskHeaders>
195
+ <zeebe:linkedResources>
196
+ <zeebe:linkedResource resourceId="trial-merge.md" bindingType="latest" linkName="prompt" />
197
+ </zeebe:linkedResources>
198
198
  <zeebe:ioMapping>
199
199
  <zeebe:input source="=null" target="result" />
200
200
  <zeebe:input source="=null" target="conflicts" />
@@ -14,9 +14,9 @@
14
14
  <bpmn:serviceTask id="synthesize" name="Synthesize &#38; promote (agent)">
15
15
  <bpmn:extensionElements>
16
16
  <zeebe:taskDefinition type="senior:retro" />
17
- <zeebe:taskHeaders>
18
- <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{retro}}" />
19
- </zeebe:taskHeaders>
17
+ <zeebe:linkedResources>
18
+ <zeebe:linkedResource resourceId="retro.md" bindingType="latest" linkName="prompt" />
19
+ </zeebe:linkedResources>
20
20
  <zeebe:ioMapping>
21
21
  <zeebe:input source="=retroDigest" target="appendPrompt" />
22
22
  </zeebe:ioMapping>
@@ -1,9 +1,12 @@
1
1
  // Red/green coverage for the agent-prompt deploy guard (scripts/check-agent-prompts.ts).
2
2
  //
3
- // The guard exists because a `{{token}}` header that resolves to a missing/blank template or a
4
- // blank agent-prompt header ships an effectively prompt-less agent (the root of the empty
5
- // "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). These cases assert it
6
- // fails on each of those shapes and passes on a well-formed app.
3
+ // Since #169 each agent's base prompt is a linked *resource*: `prompts/<token>.md` is deployed as a
4
+ // generic resource (a `models` deploy glob) and each service task links it with
5
+ // `<zeebe:linkedResource resourceId="<token>.md" bindingType="latest" linkName="prompt"/>`. The
6
+ // engine silently OMITS an unresolvable link a typo'd or undeployed `resourceId` yields a blank
7
+ // base prompt at runtime (the prompt-less-agent root of the empty "(no question provided)"
8
+ // escalations, Magikcraft/nano-bpm #597/#599). These cases assert the guard fails on each broken
9
+ // shape and passes on a well-formed app.
7
10
  import { test } from "node:test";
8
11
  import { assert, assertEquals } from "#test-assert";
9
12
  import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
@@ -11,12 +14,18 @@ import { tmpdir } from "node:os";
11
14
  import { dirname, join } from "node:path";
12
15
  import { checkAgentPrompts } from "./check-agent-prompts.ts";
13
16
 
17
+ // A manifest that deploys BPMN and the prompt resources (the migrated shape: prompts are a deploy
18
+ // glob, not a `templates` substitution source).
14
19
  const MANIFEST = JSON.stringify({
15
- models: { processes: ["resources/processes/*.bpmn"], templates: ["prompts/*.md"] },
20
+ models: { processes: ["resources/processes/*.bpmn", "prompts/*.md"] },
16
21
  });
17
22
 
18
- function header(value: string): string {
19
- return `<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="${value}" />`;
23
+ function link(resourceId: string, bindingType = "latest"): string {
24
+ return `<zeebe:linkedResource resourceId="${resourceId}" bindingType="${bindingType}" linkName="prompt" />`;
25
+ }
26
+
27
+ function serviceTask(inner: string): string {
28
+ return `<bpmn:serviceTask id="t"><bpmn:extensionElements>${inner}</bpmn:extensionElements></bpmn:serviceTask>`;
20
29
  }
21
30
 
22
31
  // Build a throwaway app tree and return its root. Each entry maps a repo-relative path to content.
@@ -30,10 +39,10 @@ function fixture(files: Record<string, string>): string {
30
39
  return root;
31
40
  }
32
41
 
33
- test("passes when every {{token}} resolves to a non-blank template that emits a result", async () => {
34
- const root = await fixture({
42
+ test("passes when every prompt link resolves to a deployed, non-blank, result-emitting resource", () => {
43
+ const root = fixture({
35
44
  "nano.app.json": MANIFEST,
36
- "resources/processes/loop.bpmn": header("{{review-round}}"),
45
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
37
46
  "prompts/review-round.md": "# Round\nDo the thing, then write your result to `$AGENT_RESULT_FILE`.",
38
47
  });
39
48
  const res = checkAgentPrompts(root);
@@ -42,72 +51,169 @@ test("passes when every {{token}} resolves to a non-blank template that emits a
42
51
  assertEquals(res.resolved, ["review-round"]);
43
52
  });
44
53
 
45
- test("fails when a header references an undeclared template", async () => {
46
- const root = await fixture({
54
+ test("fails when a prompt link references a resourceId with no deployed file", () => {
55
+ const root = fixture({
47
56
  "nano.app.json": MANIFEST,
48
- "resources/processes/loop.bpmn": header("{{does-not-exist}}"),
49
- "prompts/review-round.md": "# Round",
57
+ "resources/processes/loop.bpmn": serviceTask(link("does-not-exist.md")),
58
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
50
59
  });
51
60
  const res = checkAgentPrompts(root);
52
61
  assert(!res.ok);
53
- assert(res.errors.some((e) => e.includes("{{does-not-exist}}") && e.includes("no such template")));
62
+ assert(res.errors.some((e) => e.includes("does-not-exist.md") && e.includes("no deployed resource")));
54
63
  });
55
64
 
56
- test("fails when the referenced template file is blank (would substitute to nothing)", async () => {
57
- const root = await fixture({
65
+ test("fails when the prompt exists on disk but is not wired into a deploy glob", () => {
66
+ // The classic migration mistake: the prompt is left in the retired `models.templates` (which is
67
+ // substituted, never deployed) instead of a deploy glob, so the engine never receives it.
68
+ const root = fixture({
69
+ "nano.app.json": JSON.stringify({
70
+ models: { processes: ["resources/processes/*.bpmn"], templates: ["prompts/*.md"] },
71
+ }),
72
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
73
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
74
+ });
75
+ const res = checkAgentPrompts(root);
76
+ assert(!res.ok);
77
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("no deployed resource")));
78
+ });
79
+
80
+ test("fails when the linked prompt resource is blank (would run prompt-less)", () => {
81
+ const root = fixture({
58
82
  "nano.app.json": MANIFEST,
59
- "resources/processes/loop.bpmn": header("{{review-round}}"),
83
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
60
84
  "prompts/review-round.md": " \n \n",
61
85
  });
62
86
  const res = checkAgentPrompts(root);
63
87
  assert(!res.ok);
64
- assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round")));
88
+ assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round.md")));
65
89
  });
66
90
 
67
- test("fails when a reserved agent-prompt header is blank", async () => {
68
- const root = await fixture({
91
+ test("fails when a linkName=prompt link has an empty resourceId", () => {
92
+ const root = fixture({
69
93
  "nano.app.json": MANIFEST,
70
- "resources/processes/loop.bpmn": header(""),
71
- "prompts/review-round.md": "# Round",
94
+ "resources/processes/loop.bpmn": serviceTask(link("")),
95
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
72
96
  });
73
97
  const res = checkAgentPrompts(root);
74
98
  assert(!res.ok);
75
- assert(res.errors.some((e) => e.includes("is empty")));
99
+ assert(res.errors.some((e) => e.includes("empty resourceId")));
76
100
  });
77
101
 
78
- test("fails when an agent-prompt template omits the machine-readable result mechanism", async () => {
102
+ test("fails when the retired baked prompt header is still present", () => {
103
+ // The deploy no longer substitutes {{token}} templates, so a surviving baked header ships a
104
+ // literal placeholder as the prompt.
105
+ const root = fixture({
106
+ "nano.app.json": MANIFEST,
107
+ "resources/processes/loop.bpmn": serviceTask(
108
+ '<zeebe:taskHeaders><zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review-round}}" /></zeebe:taskHeaders>',
109
+ ),
110
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
111
+ });
112
+ const res = checkAgentPrompts(root);
113
+ assert(!res.ok);
114
+ assert(res.errors.some((e) => e.includes("retired") && e.includes("linkedResource")));
115
+ });
116
+
117
+ test("fails when the retired baked prompt header survives with reordered attributes", () => {
118
+ // XML attribute order is not significant: a retired header with `value` before `key` must still be
119
+ // caught. The guard used to anchor `key` immediately after `<zeebe:header`, so a reordered header
120
+ // would slip through and ship a literal placeholder as the prompt.
121
+ const root = fixture({
122
+ "nano.app.json": MANIFEST,
123
+ "resources/processes/loop.bpmn": serviceTask(
124
+ '<zeebe:taskHeaders><zeebe:header value="{{review-round}}" key="io.nanobpm.agentTask.task.prompt" /></zeebe:taskHeaders>',
125
+ ),
126
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
127
+ });
128
+ const res = checkAgentPrompts(root);
129
+ assert(!res.ok);
130
+ assert(res.errors.some((e) => e.includes("retired") && e.includes("linkedResource")));
131
+ });
132
+
133
+ test("fails when a linked prompt resource omits the machine-readable result mechanism", () => {
79
134
  // A prompt wired as an agent's base prompt must tell it to write $AGENT_RESULT_FILE (or use the
80
135
  // ::nano:result:: fallback). Without it the agent finishes with prose only, `status` comes back
81
136
  // blank, and the status gateway escalates/stalls — the fix-ci/rebase gap behind #746's stuck merge.
82
- const root = await fixture({
137
+ const root = fixture({
83
138
  "nano.app.json": MANIFEST,
84
- "resources/processes/loop.bpmn": header("{{review-round}}"),
139
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
85
140
  "prompts/review-round.md": "# Round\nReturn status: converged. (but never says how to emit it)",
86
141
  });
87
142
  const res = checkAgentPrompts(root);
88
143
  assert(!res.ok);
89
- assert(res.errors.some((e) => e.includes("{{review-round}}") && e.includes("AGENT_RESULT_FILE")));
144
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("AGENT_RESULT_FILE")));
90
145
  });
91
146
 
92
- test("passes when an agent-prompt template emits via the ::nano:result:: fallback", async () => {
93
- const root = await fixture({
147
+ test("passes when a linked prompt resource emits via the ::nano:result:: fallback", () => {
148
+ const root = fixture({
94
149
  "nano.app.json": MANIFEST,
95
- "resources/processes/loop.bpmn": header("{{review-round}}"),
96
- "prompts/review-round.md": "# Round\nEmit `::nano:result:: {\"status\":\"converged\"}` at the end.",
150
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
151
+ "prompts/review-round.md": '# Round\nEmit `::nano:result:: {"status":"converged"}` at the end.',
97
152
  });
98
153
  const res = checkAgentPrompts(root);
99
154
  assertEquals(res.errors, []);
100
155
  assert(res.ok);
101
156
  });
102
157
 
103
- test("checks the real repo: all committed agent prompts resolve", () => {
158
+ test("fails when a prompt link uses a bindingType other than latest", () => {
159
+ const root = fixture({
160
+ "nano.app.json": MANIFEST,
161
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md", "deployment")),
162
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
163
+ });
164
+ const res = checkAgentPrompts(root);
165
+ assert(!res.ok);
166
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes('bindingType="deployment"')));
167
+ });
168
+
169
+ test("fails when a prompt link omits bindingType entirely", () => {
170
+ const root = fixture({
171
+ "nano.app.json": MANIFEST,
172
+ "resources/processes/loop.bpmn": serviceTask(
173
+ '<zeebe:linkedResource resourceId="review-round.md" linkName="prompt" />',
174
+ ),
175
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
176
+ });
177
+ const res = checkAgentPrompts(root);
178
+ assert(!res.ok);
179
+ assert(res.errors.some((e) => e.includes("review-round.md") && e.includes("(absent)")));
180
+ });
181
+
182
+ test("fails when no prompt link is wired at all", () => {
183
+ const root = fixture({
184
+ "nano.app.json": MANIFEST,
185
+ "resources/processes/loop.bpmn": serviceTask('<zeebe:taskDefinition type="pr.noop" />'),
186
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
187
+ });
188
+ const res = checkAgentPrompts(root);
189
+ assert(!res.ok);
190
+ assert(res.errors.some((e) => e.includes("unwired")));
191
+ });
192
+
193
+ test("fails when two deploy globs match files sharing a basename (ambiguous resource name)", () => {
194
+ // Two deployed files with the same basename would silently overwrite in the resourceId lookup, so
195
+ // a linkName="prompt" resourceId could resolve to the wrong file. The guard must fail fast.
196
+ const root = fixture({
197
+ "nano.app.json": JSON.stringify({
198
+ models: { processes: ["resources/processes/*.bpmn", "prompts/*.md", "extra/*.md"] },
199
+ }),
200
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
201
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
202
+ "extra/review-round.md": "# Duplicate\nWrite `$AGENT_RESULT_FILE`.",
203
+ });
204
+ const res = checkAgentPrompts(root);
205
+ assert(!res.ok);
206
+ assert(res.errors.some((e) => e.includes("duplicate deployed resource name") && e.includes("review-round.md")));
207
+ });
208
+
209
+ test("checks the real repo: all committed agent prompts link to deployed resources", () => {
104
210
  // The guard must be green against the actual app it protects — this is the case CI relies on.
105
211
  const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
106
212
  const res = checkAgentPrompts(repoRoot);
107
213
  assertEquals(res.errors, []);
108
214
  assert(res.ok);
109
- // Every senior:* agent prompt header in the three processes must have resolved.
215
+ // Every senior:* agent prompt across the processes must resolve to a deployed prompt resource.
110
216
  for (const t of ["review-round", "fix-ci", "plan", "plan-review", "feature", "trial-merge", "rebase", "retro"]) {
111
- assert(res.resolved.includes(t), `expected template ${t} to resolve`);
217
+ assert(res.resolved.includes(t), `expected prompt ${t} to resolve`);
112
218
  }
113
219
  });
@@ -1,33 +1,53 @@
1
- // check-agent-prompts — deploy-safety gate for the model-authored `{{template}}` agent prompts.
1
+ // check-agent-prompts — deploy-safety gate for the agent prompts, now authored as *linked
2
+ // resources* (issue #169) rather than baked `{{token}}` templates.
2
3
  //
3
- // Since #31 (v0.11.0) each agent's prompt is authored in the BPMN as a deploy-time template
4
- // header, e.g. `<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{review-round}}" />`.
5
- // `@nanobpm/urban` substitutes `{{token}}` with `prompts/<token>.md` (nano.app.json
6
- // `models.templates`) at deploy time; the harness (`c8ctl nano work`) does NO substitution and
7
- // relays whatever header ships. So a token that resolves to a missing/blank template — or a blank
8
- // agent-prompt header — runs the agent effectively prompt-less. For `senior:pr-review` that makes
9
- // it improvise as a "reviewer" and escalate with no question (Magikcraft/nano-bpm #597/#599).
4
+ // Since #169 each agent's base prompt is a generic resource: `prompts/<token>.md` is deployed as
5
+ // an `application/octet-stream` resource (via a `models` deploy glob — see nano.app.json) and each
6
+ // agent service task links it at job-activation time:
10
7
  //
11
- // urban's deploy only *warns* on an unresolved placeholder and ships the resource with the raw
12
- // token in place — and this project does not tolerate warnings. This guard runs urban's OWN
13
- // substitution (`applyTemplates`, the single source of truth for token scanning/escaping) exactly
14
- // as deploy does and turns any surviving placeholder into a hard failure, plus flags a blank
15
- // template or a blank agent-prompt header (which substitute to an empty prompt without being
16
- // "unresolved"). Importing from `@nanobpm/urban/runtime` also asserts the installed urban is new
17
- // enough to substitute at all (the capability was added in the 0.22 / nano-ide #106 release).
8
+ // <zeebe:linkedResources>
9
+ // <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
10
+ // </zeebe:linkedResources>
11
+ //
12
+ // The engine resolves the LATEST deployed key for that `resourceId` when the job activates and hands
13
+ // the content to the harness in the `linkedResources` activation header. Crucially, the engine
14
+ // *silently omits* an unresolvable link (a typo'd or undeployed `resourceId`) from the header no
15
+ // incident — so a mistake yields a blank base prompt at runtime, exactly the prompt-less-agent
16
+ // failure that produced the empty "(no question provided)" escalations (Magikcraft/nano-bpm
17
+ // #597/#599). This guard turns that silent runtime failure into a hard build failure:
18
+ //
19
+ // 1. Every `linkName="prompt"` link's `resourceId` MUST match a prompt file that the app actually
20
+ // deploys (a file matched by a `models` deploy glob). This catches both a typo'd `resourceId`
21
+ // and a prompt that exists on disk but is not wired into a deploy glob (so never reaches the
22
+ // engine — the link would resolve to nothing).
23
+ // 2. Each linked prompt file must be non-blank and must teach the agent to emit a machine-readable
24
+ // result (`$AGENT_RESULT_FILE`, or the `::nano:result::` stdout fallback) — a prose-only agent
25
+ // leaves `status` blank and the status gateway escalates/stalls (the fix-ci/rebase gap behind
26
+ // Magikcraft/nano-bpm#746's stuck merge).
27
+ // 3. No service task may still carry the retired baked `io.nanobpm.agentTask.task.prompt` header:
28
+ // the deploy no longer substitutes `{{token}}` templates, so such a header would ship a literal
29
+ // `{{token}}` (or stale frozen text) as the prompt.
18
30
  import { existsSync, readdirSync, readFileSync } from "node:fs";
19
31
  import { basename, join } from "node:path";
20
- import { applyTemplates } from "@nanobpm/urban/runtime";
21
32
 
22
- // The reserved header carrying an agent's base prompt. A blank value here means the agent gets no
23
- // instructions the exact failure mode we guard against.
24
- const AGENT_PROMPT_HEADER = "io.nanobpm.agentTask.task.prompt";
33
+ // The retired header that used to carry an agent's baked base prompt. Its continued presence is a
34
+ // migration regression (the deploy no longer substitutes templates), so we flag it.
35
+ const RETIRED_PROMPT_HEADER = "io.nanobpm.agentTask.task.prompt";
36
+
37
+ // The `linkName` that designates a linked resource as an agent's base prompt. Other link names (if
38
+ // any are ever added) are not agent prompts and are ignored by this guard.
39
+ const PROMPT_LINK_NAME = "prompt";
40
+
41
+ // A prompt link MUST bind `latest` — this whole migration (#169) is about live mid-epic prompt
42
+ // updates, which only work when the engine resolves the latest deployed key at activation. A
43
+ // missing or different `bindingType` would silently pin/omit the prompt, so the guard fails it.
44
+ const PROMPT_BINDING_TYPE = "latest";
25
45
 
26
46
  interface AppManifest {
27
47
  models?: { processes?: string[]; decisions?: string[]; forms?: string[]; templates?: string[] };
28
48
  }
29
49
 
30
- // Mirror urban deploy's `contentTypeFor`: only the escapable model types are substituted.
50
+ // Only the escapable model types are XML we scan for `<zeebe:linkedResource>` links.
31
51
  function contentTypeFor(path: string): string {
32
52
  if (path.endsWith(".bpmn") || path.endsWith(".dmn")) return "text/xml";
33
53
  if (path.endsWith(".form")) return "application/json";
@@ -48,53 +68,47 @@ function expandGlob(root: string, pattern: string): string[] {
48
68
  .map((f) => join(dir, f));
49
69
  }
50
70
 
51
- // The `name -> content` template map urban substitutes from (array source: name = file stem).
52
- function templateMap(root: string, patterns: string[]): Record<string, string> {
53
- const map: Record<string, string> = {};
54
- for (const pattern of patterns) {
55
- for (const rel of expandGlob(root, pattern)) {
56
- const stem = basename(rel).replace(/\.[^.]+$/, "");
57
- map[stem] = readFileSync(join(root, rel), "utf8");
58
- }
59
- }
60
- return map;
71
+ interface PromptLink {
72
+ resourceId: string;
73
+ bindingType: string | null;
61
74
  }
62
75
 
63
- // Blank reserved agent-prompt headers in a BPMN source the one blank case urban's `unresolved`
64
- // signal can't see (an empty value carries no `{{token}}` to be unresolved).
65
- function hasBlankAgentPromptHeader(bpmn: string): boolean {
66
- const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
76
+ // Extract the `<zeebe:linkedResource linkName="prompt" …>` links from a BPMN source. Each agent
77
+ // service task carries exactly one; a process may host several tasks.
78
+ function promptLinks(bpmn: string): PromptLink[] {
79
+ const links: PromptLink[] = [];
80
+ const re = /<zeebe:linkedResource\b([^>]*?)\/?>/g;
67
81
  let m = re.exec(bpmn);
68
82
  while (m !== null) {
69
- if (m[1] === AGENT_PROMPT_HEADER && m[2].trim() === "") return true;
83
+ const attrs = m[1];
84
+ const linkName = /\blinkName="([^"]*)"/.exec(attrs)?.[1];
85
+ if (linkName === PROMPT_LINK_NAME) {
86
+ const resourceId = /\bresourceId="([^"]*)"/.exec(attrs)?.[1] ?? "";
87
+ const bindingType = /\bbindingType="([^"]*)"/.exec(attrs)?.[1] ?? null;
88
+ links.push({ resourceId, bindingType });
89
+ }
70
90
  m = re.exec(bpmn);
71
91
  }
72
- return false;
92
+ return links;
73
93
  }
74
94
 
75
- // The template tokens a model wires as an agent's base prompt, e.g. the `fix-ci` in
76
- // `value="{{fix-ci}}"` on an `io.nanobpm.agentTask.task.prompt` header. These templates *drive an
77
- // agent*, so each must teach it to emit a machine-readable result (see agentPromptEmitsResult).
78
- function agentPromptTokens(bpmn: string): string[] {
79
- const tokens: string[] = [];
80
- const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
81
- let m = re.exec(bpmn);
82
- while (m !== null) {
83
- if (m[1] === AGENT_PROMPT_HEADER) {
84
- const tok = /^\{\{\s*([^}]+?)\s*\}\}$/.exec(m[2].trim());
85
- if (tok) tokens.push(tok[1]);
86
- }
87
- m = re.exec(bpmn);
88
- }
89
- return tokens;
95
+ // Escape a literal string for safe embedding in a RegExp the header key contains dots that would
96
+ // otherwise act as wildcards and match unintended header keys.
97
+ function escapeRegExp(literal: string): string {
98
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ }
100
+
101
+ // Any surviving retired baked-prompt header — a migration regression. XML attribute order is not
102
+ // significant, so match the `key` attribute anywhere within the opening `<zeebe:header …>` tag (not
103
+ // only immediately after the element name) — otherwise a reordered header would bypass the guard.
104
+ function hasRetiredPromptHeader(bpmn: string): boolean {
105
+ return new RegExp(`<zeebe:header\\s[^>]*\\bkey="${escapeRegExp(RETIRED_PROMPT_HEADER)}"`).test(bpmn);
90
106
  }
91
107
 
92
108
  // A prompt that drives an agent must tell it how to return a machine-readable result — the
93
109
  // `$AGENT_RESULT_FILE` write (or the `::nano:result::` stdout fallback). Without it the agent can
94
110
  // finish with prose only, its `status` variable comes back empty, the status gateway falls through
95
- // to its default escalation arm, and the run parks a human escalation / stalls the merge (the
96
- // fix-ci/rebase gap behind Magikcraft/nano-bpm#746's stuck merge). Prose is never parsed, so this
97
- // instruction is load-bearing, not documentation.
111
+ // to its default escalation arm, and the run parks a human escalation / stalls the merge.
98
112
  function agentPromptEmitsResult(body: string): boolean {
99
113
  return body.includes("AGENT_RESULT_FILE") || body.includes("::nano:result::");
100
114
  }
@@ -102,14 +116,13 @@ function agentPromptEmitsResult(body: string): boolean {
102
116
  export interface CheckResult {
103
117
  ok: boolean;
104
118
  errors: string[];
105
- /** template names successfully substituted into a model — surfaced for the CLI summary line. */
119
+ /** prompt resource ids (file stems) successfully linked — surfaced for the CLI summary line. */
106
120
  resolved: string[];
107
121
  }
108
122
 
109
123
  export function checkAgentPrompts(root: string): CheckResult {
110
124
  const errors: string[] = [];
111
125
  const resolved = new Set<string>();
112
- const agentTokens = new Set<string>();
113
126
 
114
127
  const manifestPath = join(root, "nano.app.json");
115
128
  if (!existsSync(manifestPath)) {
@@ -118,57 +131,96 @@ export function checkAgentPrompts(root: string): CheckResult {
118
131
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
119
132
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
120
133
  const models = manifest.models ?? {};
121
- const templates = templateMap(root, models.templates ?? []);
122
-
123
- // A declared-but-blank template substitutes to an empty prompt without being "unresolved" —
124
- // catch it up front (urban would silently produce a blank prompt).
125
- for (const [name, body] of Object.entries(templates)) {
126
- if (body.trim() === "") errors.push(`template {{${name}}} is empty — it would substitute to a blank prompt`);
127
- }
128
134
 
129
- const modelFiles = [
135
+ // The resources the app actually DEPLOYS: every file matched by a deploy glob (processes,
136
+ // decisions, forms). Its deployed resource name is the file's basename — the same string a
137
+ // `linkedResource resourceId` must reference. Building this from the deploy globs (not from the
138
+ // prompts/ directory) is what catches a prompt that exists on disk but is wired only into a
139
+ // non-deploying key (e.g. the retired `models.templates`), so it never reaches the engine.
140
+ const deployGlobs = [
130
141
  ...(models.processes ?? []),
131
142
  ...(models.decisions ?? []),
132
143
  ...(models.forms ?? []),
133
- ].flatMap((p) => expandGlob(root, p));
134
- if (modelFiles.length === 0) {
135
- errors.push(`no model files matched ${JSON.stringify(models.processes ?? [])}`);
144
+ ];
145
+ // Keyed by basename (the deployed resource name a `resourceId` references). Two deploy globs
146
+ // matching files with the same basename would silently overwrite here, so a `resourceId` lookup
147
+ // could resolve to the wrong file (or mask a misconfiguration). Fail fast on the collision so the
148
+ // lookup stays unambiguous.
149
+ const deployedFiles = new Map<string, string>();
150
+ for (const rel of deployGlobs.flatMap((p) => expandGlob(root, p))) {
151
+ const name = basename(rel);
152
+ const prior = deployedFiles.get(name);
153
+ if (prior != null && prior !== rel) {
154
+ errors.push(
155
+ `duplicate deployed resource name "${name}": both "${prior}" and "${rel}" deploy under the ` +
156
+ `same basename, so a linkName="prompt" resourceId="${name}" would resolve ambiguously — ` +
157
+ `rename one so deployed resource names stay unique`,
158
+ );
159
+ continue;
160
+ }
161
+ deployedFiles.set(name, rel);
136
162
  }
137
163
 
138
- for (const rel of modelFiles) {
139
- const contentType = contentTypeFor(rel);
140
- if (contentType === "application/octet-stream") continue; // urban does not substitute these
164
+ // The model files whose XML we scan for `<zeebe:linkedResource>` links.
165
+ const xmlModelFiles = deployGlobs
166
+ .flatMap((p) => expandGlob(root, p))
167
+ .filter((rel) => contentTypeFor(rel) === "text/xml");
168
+ if (xmlModelFiles.length === 0) {
169
+ errors.push(`no BPMN/DMN model files matched ${JSON.stringify(deployGlobs)}`);
170
+ }
171
+
172
+ let linkCount = 0;
173
+ for (const rel of xmlModelFiles) {
141
174
  const content = readFileSync(join(root, rel), "utf8");
142
175
 
143
- // Run urban's canonical substitution — the same call deploy makes — and fail on any token it
144
- // leaves unresolved (deploy only warns, which we don't tolerate).
145
- const applied = applyTemplates(content, contentType, templates);
146
- for (const name of applied.unresolved) {
176
+ if (hasRetiredPromptHeader(content)) {
147
177
  errors.push(
148
- `${rel}: unresolved template {{${name}}}no such template is declared in models.templates`,
178
+ `${rel}: a retired "${RETIRED_PROMPT_HEADER}" header is still present migrate it to a ` +
179
+ `<zeebe:linkedResource … linkName="prompt"/> (the deploy no longer substitutes {{token}} templates)`,
149
180
  );
150
181
  }
151
- for (const name of Object.keys(templates)) {
152
- if (content.includes(`{{${name}}}`)) resolved.add(name);
153
- }
154
182
 
155
- if (hasBlankAgentPromptHeader(content)) {
156
- errors.push(`${rel}: a reserved "${AGENT_PROMPT_HEADER}" header is empty (agent would run prompt-less)`);
183
+ for (const link of promptLinks(content)) {
184
+ linkCount++;
185
+ if (link.resourceId.trim() === "") {
186
+ errors.push(`${rel}: a linkName="prompt" linkedResource has an empty resourceId`);
187
+ continue;
188
+ }
189
+ if (link.bindingType !== PROMPT_BINDING_TYPE) {
190
+ errors.push(
191
+ `${rel}: linkName="prompt" resourceId="${link.resourceId}" has ` +
192
+ `bindingType=${link.bindingType == null ? "(absent)" : `"${link.bindingType}"`} — it must be ` +
193
+ `bindingType="${PROMPT_BINDING_TYPE}" so the engine resolves the latest deployed prompt at ` +
194
+ `activation (mid-epic prompt updates rely on it); any other value silently alters runtime ` +
195
+ `prompt resolution`,
196
+ );
197
+ }
198
+ const deployedRel = deployedFiles.get(link.resourceId);
199
+ if (deployedRel == null) {
200
+ errors.push(
201
+ `${rel}: linkName="prompt" resourceId="${link.resourceId}" has no deployed resource — ` +
202
+ `no file matched by a models deploy glob has that name, so the engine would omit the ` +
203
+ `link and the agent would run prompt-less`,
204
+ );
205
+ continue;
206
+ }
207
+ const body = readFileSync(join(root, deployedRel), "utf8");
208
+ const stem = basename(deployedRel).replace(/\.[^.]+$/, "");
209
+ if (body.trim() === "") {
210
+ errors.push(`prompt resource "${link.resourceId}" is empty — the agent would run prompt-less`);
211
+ } else if (!agentPromptEmitsResult(body)) {
212
+ errors.push(
213
+ `prompt resource "${link.resourceId}" drives an agent but never tells it to write ` +
214
+ `$AGENT_RESULT_FILE (or the ::nano:result:: fallback) — the agent can finish with prose ` +
215
+ `only, leaving its status blank so the process escalates/stalls`,
216
+ );
217
+ }
218
+ resolved.add(stem);
157
219
  }
158
- for (const tok of agentPromptTokens(content)) agentTokens.add(tok);
159
220
  }
160
221
 
161
- // Every template wired as an agent's base prompt must teach the agent to emit a machine-readable
162
- // result; a prose-only agent leaves `status` blank and the process escalates/stalls.
163
- for (const tok of [...agentTokens].sort()) {
164
- const body = templates[tok];
165
- if (body != null && body.trim() !== "" && !agentPromptEmitsResult(body)) {
166
- errors.push(
167
- `template {{${tok}}} drives an agent but never tells it to write $AGENT_RESULT_FILE ` +
168
- `(or the ::nano:result:: fallback) — the agent can finish with prose only, leaving its ` +
169
- `status blank so the process escalates/stalls`,
170
- );
171
- }
222
+ if (linkCount === 0 && errors.length === 0) {
223
+ errors.push("no linkName=\"prompt\" linkedResource found in any model agent prompts are unwired");
172
224
  }
173
225
 
174
226
  return { ok: errors.length === 0, errors, resolved: [...resolved].sort() };
@@ -183,5 +235,5 @@ if (import.meta.main) {
183
235
  for (const e of errors) console.error(` - ${e}`);
184
236
  process.exit(1);
185
237
  }
186
- console.log(`✔ agent prompt templates resolve (${resolved.length}: ${resolved.join(", ")})`);
238
+ console.log(`✔ agent prompts link to deployed resources (${resolved.length}: ${resolved.join(", ")})`);
187
239
  }