@nanobpm/nano-workforce 0.72.1 → 0.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -117,6 +117,33 @@ GENERATED, never hand-edited.**
117
117
  that touch a shared process file into one; do **not** paper the collision over
118
118
  with a `dependsOn` edge added purely to serialise otherwise-parallel work.
119
119
 
120
+ ## Deploy by convention: `resources/` (ADR 0062)
121
+
122
+ **Deployables live under `resources/` and deploy by convention — `nano.app.json`
123
+ declares no `models`.** urban walks `resources/` (shallow, one level deep) and deploys
124
+ every file: `resources/processes/*.bpmn`, `resources/forms/*.form`, and one prompt per
125
+ task under `resources/prompts/*.md`.
126
+
127
+ - **`resources/` is deploy-only.** Anything under it ships to the engine; anything
128
+ **outside** it never does. Docs therefore live under `docs/` (e.g.
129
+ `docs/agent-guide.md`) — put a `.md` in `resources/` only if you actually want it
130
+ deployed.
131
+ - **No `models` block.** Rely on the convention; add a `models` override *only* for a
132
+ genuinely non-standard layout (nwf doesn't need one). An explicit `models` is used
133
+ verbatim and skips the convention walk.
134
+ - **Basenames must be unique across the deploy set** — the deploy dedupe key is the
135
+ filename only, so two files sharing a basename in different dirs collide. `npm run
136
+ check:prompts` fails loudly on that.
137
+ - **Agent prompts: linkedResource is the blessed *and only* path.** Each agent service
138
+ task links its base prompt with
139
+ `<zeebe:linkedResource resourceId="<token>.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>`,
140
+ which the engine resolves to the latest deployed `resources/prompts/<token>.md` at job
141
+ activation, combined at runtime with the per-task `appendPrompt` FEEL. `bindingType="latest"`
142
+ lets a prompt update land mid-epic without a process redeploy.
143
+ - **Deploy-time `{{token}}` templating is removed — no back-compat.** To inject a per-run
144
+ value (URL, flag) into an agent, pass it as a runtime job variable / `appendPrompt`
145
+ FEEL; never bake it into a model at deploy time.
146
+
120
147
  ## Engine capabilities (Zeebe parity — use them, don't work around them)
121
148
 
122
149
  The nanobpmn engine (`~/workspace/nanobpmn` `engine-core`, deployed via the nano
@@ -247,7 +274,7 @@ npm run lint # biome check (incl. ban-`
247
274
  npm run typecheck # tsc --noEmit (Node)
248
275
  npm run check # urban check (manifest validation)
249
276
  npm run layout:check # BPMN diagram freshness (no drift)
250
- npm run check:prompts # agent-prompt template resolution
277
+ npm run check:prompts # agent-prompt linkedResource resolution
251
278
  npm run check:migrations # migration prefixes (no collisions)
252
279
  npm run check:contracts # contract registry (no synonyms / undeclared env keys)
253
280
  npm test # unit tests (node --test)
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.73.1](https://github.com/nanobpm/nano-workforce/compare/v0.73.0...v0.73.1) (2026-08-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge:** treat a Depends-on ref that is not a PR as non-blocking ([#246](https://github.com/nanobpm/nano-workforce/issues/246)) ([c800f81](https://github.com/nanobpm/nano-workforce/commit/c800f81b02b3a9a787d79552f4e5eef9971e3edf)), closes [Magikcraft/nano-bpm#806](https://github.com/Magikcraft/nano-bpm/issues/806)
7
+
8
+ # [0.73.0](https://github.com/nanobpm/nano-workforce/compare/v0.72.1...v0.73.0) (2026-08-15)
9
+
10
+
11
+ ### Features
12
+
13
+ * **deploy:** resources/ deploy-by-convention; drop models; docs→docs/ (ADR 0062 step 3) ([#241](https://github.com/nanobpm/nano-workforce/issues/241)) ([9f7f85c](https://github.com/nanobpm/nano-workforce/commit/9f7f85c1231ce228c4468ac8f5222dd14d84022b)), closes [nanobpm/nano-ide#244](https://github.com/nanobpm/nano-ide/issues/244) [#244](https://github.com/nanobpm/nano-workforce/issues/244) [#239](https://github.com/nanobpm/nano-workforce/issues/239) [nanobpm/nano-ide#244](https://github.com/nanobpm/nano-ide/issues/244) [nanobpm/nano-workforce#239](https://github.com/nanobpm/nano-workforce/issues/239)
14
+
1
15
  ## [0.72.1](https://github.com/nanobpm/nano-workforce/compare/v0.72.0...v0.72.1) (2026-08-15)
2
16
 
3
17
 
package/README.md CHANGED
@@ -156,8 +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` resource (linked into the task
160
- and fetched by the harness at activation) tells the agent how
159
+ `job.variables`) to stdin; the relevant `resources/prompts/*.md` resource (linked into
160
+ the task and fetched by the harness at activation) tells the agent how
161
161
  to read it and where to write its result.
162
162
  - **`--allow-all-tools` is essential** for an unattended worker — without it Copilot
163
163
  pauses for permission before each tool call and the job stalls.
@@ -351,14 +351,15 @@ agent's base prompt is **not** in the job payload — it is delivered as a
351
351
  **linked resource** (likewise `plan.md`, `feature.md`, `fix-ci.md`, …):
352
352
 
353
353
  ```xml
354
- <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
354
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
355
355
  ```
356
356
 
357
- that the engine resolves to the latest deployed `prompts/*.md` at job
358
- activation (the prompts deploy as generic resources via a `models` glob in
359
- `nano.app.json`); per-instance context (e.g. a human's escalation answer) is appended
360
- by the harness. Because the binding is `latest`, redeploying one `prompts/*.md` updates
361
- the prompt for the next activation in a **running** epic — see SPEC §9.
357
+ that the engine resolves to the latest deployed `resources/prompts/*.md` at job
358
+ activation (the prompts deploy as generic resources under the `resources/` deploy-by-
359
+ convention layout — `nano.app.json` declares no `models`; ADR 0062); per-instance context
360
+ (e.g. a human's escalation answer) is appended
361
+ by the harness. Because the binding is `latest`, redeploying one `resources/prompts/*.md`
362
+ updates the prompt for the next activation in a **running** epic — see SPEC §9.
362
363
 
363
364
  ---
364
365
 
package/SPEC.md CHANGED
@@ -65,11 +65,13 @@ nano-workforce/
65
65
  resources/
66
66
  processes/
67
67
  convergence-loop.bpmn # the durable convergence process
68
+ prompts/
69
+ review-round.md # agent instructions asset (deployed by the resources/ convention)
68
70
  db/
69
71
  migrations/
70
72
  001_init.sql # sqlite schema
71
- prompts/
72
- review-round.md # agent instructions asset (injected into job data)
73
+ docs/
74
+ agent-guide.md # operator guide (docs live OUTSIDE resources/ — never deployed)
73
75
  components/
74
76
  review-round.json # Zeebe element template for the senior:pr-review service task
75
77
  SPEC.md # this document
@@ -155,8 +157,8 @@ Notes:
155
157
 
156
158
  The base instructions are **not** a job variable: they are delivered as a
157
159
  **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
+ `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>`,
161
+ which the engine resolves to the latest deployed `resources/prompts/review-round.md` at job
160
162
  activation.
161
163
 
162
164
  **Output** (job result variables):
@@ -178,7 +180,7 @@ gives **each job its own `mkdtemp` run-dir + fresh clone**, runs the agent with
178
180
  **reaps that run-dir when the job ends**. So multiple agents on one host do **not**
179
181
  collide even in host mode — the isolation lives below the agent.
180
182
 
181
- Consequences the prompt (`prompts/review-round.md`) encodes:
183
+ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
182
184
  - The agent works only inside its provided `cwd`; it must **not** re-clone or create
183
185
  a separate `git worktree`, and must not touch global/host state.
184
186
  - The agent **cleans up anything it creates outside the commit** before returning
@@ -300,25 +302,26 @@ when the engine data is purged, to keep app state and engine state consistent).
300
302
 
301
303
  ## 9. Prompt delivery — linked resources (`bindingType: latest`)
302
304
 
303
- Each agent task's base prompt lives **only** in its `prompts/*.md` side-car, deployed
304
- as a **generic resource** and **linked** — not baked — into the model (issue #169).
305
- `nano.app.json` lists `prompts/*.md` in a `models` deploy glob, so `@nanobpm/urban`
305
+ Each agent task's base prompt lives **only** in its `resources/prompts/*.md` side-car,
306
+ deployed as a **generic resource** and **linked** — not baked — into the model (issue
307
+ #169). Under the ADR 0062 `resources/` deploy-by-convention layout, `nano.app.json`
308
+ declares **no `models`**, so `@nanobpm/urban` walks `resources/` (shallow, one level) and
306
309
  deploys each file as an `application/octet-stream` resource whose deployed **name is
307
- the file's basename** (`prompts/review-round.md` → resource `review-round.md`). Each
308
- agent service task links it:
310
+ the file's basename** (`resources/prompts/review-round.md` → resource `review-round.md`).
311
+ Each agent service task links it:
309
312
 
310
313
  ```xml
311
314
  <zeebe:linkedResources>
312
- <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
315
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
313
316
  </zeebe:linkedResources>
314
317
  ```
315
318
 
316
319
  At **job activation** the engine resolves the *latest deployed* key for that
317
320
  `resourceId` and hands the content to the harness in the `linkedResources` activation
318
321
  header; the harness fetches by key and uses it as the base prompt. Because the binding
319
- is `latest`, **redeploying a single `prompts/*.md` changes the prompt for the next task
320
- activation in a running epic** — no process redeploy, no in-flight epic restart. This
321
- is the live-prompt debugging loop: edit one Markdown file, `urban deploy` (or restart
322
+ is `latest`, **redeploying a single `resources/prompts/*.md` changes the prompt for the
323
+ next task activation in a running epic** — no process redeploy, no in-flight epic restart.
324
+ This is the live-prompt debugging loop: edit one Markdown file, `urban deploy` (or restart
322
325
  the app, which deploys on boot), and the next agent job of that type picks it up.
323
326
 
324
327
  > **Latest-for-now, audited.** The engine currently keeps only the latest version per
@@ -332,7 +335,8 @@ the app, which deploys on boot), and the next agent job of that type picks it up
332
335
  > `resourceId` is dropped from the activation header (no incident) — the agent would
333
336
  > then run prompt-less. `scripts/check-agent-prompts.ts` (CI gate `check:prompts`)
334
337
  > guards against this: every `linkName="prompt"` link's `resourceId` must match a
335
- > prompt file the app actually deploys (a file in a `models` deploy glob), each linked
338
+ > prompt file the app actually deploys (a file under the `resources/` convention walk, or
339
+ > a manifest `models` override glob), each linked
336
340
  > prompt must be non-blank and teach the agent to emit a machine-readable result
337
341
  > (`$AGENT_RESULT_FILE` / `::nano:result::`), and no task may still carry the retired
338
342
  > baked `io.nanobpm.agentTask.task.prompt` header.
@@ -344,7 +348,8 @@ the linked base — the model owns any separator, and a null/empty append leaves
344
348
  untouched. Base prompts can't be composed in FEEL (they are quote-heavy, and XML
345
349
  attribute escaping would corrupt a FEEL string literal), so composition happens via
346
350
  this append seam rather than inline in FEEL. Requires an `@nanobpm/urban` deploy that
347
- emits generic-resource deployments for `prompts/*.md` and a harness that consumes
351
+ deploys the `resources/` convention (generic-resource deployments for
352
+ `resources/prompts/*.md`) and a harness that consumes
348
353
  `linkedResources` and fetches the resource by key.
349
354
 
350
355
  ## 10. Poller
@@ -554,7 +559,7 @@ the loop runs one parallel `implement` MI fan-out per wave:
554
559
  its prerequisites have **landed on the base branch** — not merely opened. This lets
555
560
  a blocking prerequisite (e.g. app scaffolding) fully converge and merge before the
556
561
  next wave builds on it. `gate_wave` lives in `db/migrations/007_wave_gate.sql`.
557
- - **Adopting a decomposed epic** (`prompts/plan.md` Step 0): when adopting existing
562
+ - **Adopting a decomposed epic** (`resources/prompts/plan.md` Step 0): when adopting existing
558
563
  sub-issues, the planner honours an explicit `Depends-on: #N` / `Blocked by #N`
559
564
  directive in a sub-issue body, mapping each prerequisite `#M` to `issue-M` in the
560
565
  adopted task's `dependsOn` — so a human-declared blocking order survives adoption.
package/app/agentGuide.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // The agent operator guide served by GET /app/api/agent (operationId `getAgentInstructions`).
2
2
  //
3
- // The guide itself is authored as plain markdown in `resources/agent-guide.md` (kept out of
4
- // `prompts/` so it is NOT treated as a deployable agent template) and read from the checkout at
3
+ // The guide itself is authored as plain markdown in `docs/agent-guide.md` (kept OUT of
4
+ // `resources/` so the deploy-by-convention walk does NOT treat it as a deployable model docs
5
+ // live under `docs/`, ADR 0062) and read from the checkout at
5
6
  // module load — same "run the .ts sources directly, inspect the working tree at runtime" approach
6
7
  // as version.ts. Two placeholders are substituted per request/deployment so the embedded examples
7
8
  // are copy-pasteable against THIS instance:
@@ -15,7 +16,7 @@ import { dirname, join } from "node:path";
15
16
  import { fileURLToPath } from "node:url";
16
17
 
17
18
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
- const GUIDE_PATH = join(REPO_ROOT, "resources", "agent-guide.md");
19
+ const GUIDE_PATH = join(REPO_ROOT, "docs", "agent-guide.md");
19
20
 
20
21
  // Read the raw guide once, at module load. Frozen for the life of the process.
21
22
  const RAW_GUIDE: string = (() => {
package/app/feature.ts CHANGED
@@ -248,7 +248,7 @@ export function deriveFeatureBlockedPatch(
248
248
  export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
249
249
 
250
250
  /** The deterministic task id for a single-issue run — the implementation agent branches
251
- * `feat/<task.id>` (see prompts/feature.md), so it MUST be derivable from the issue alone
251
+ * `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
252
252
  * and stable across a resume. The PR is opened on the target repo, so the issue number
253
253
  * alone is unambiguous within it. */
254
254
  export function featureTaskId(issueNumber: number): string {
@@ -321,7 +321,7 @@ export async function startFeature(
321
321
  issueNumber: parsed.number,
322
322
  issueUrl: parsed.url,
323
323
  // The single slice the implementation agent builds. `task.prompt` is its primary instruction
324
- // (prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
324
+ // (resources/prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
325
325
  // resume. Unlike an epic, there is no planner — the whole issue IS the slice.
326
326
  task: {
327
327
  id: featureTaskId(parsed.number),
@@ -337,7 +337,7 @@ export async function startFeature(
337
337
  converge,
338
338
  autoMerge,
339
339
  // A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
340
- // claim it with a "starting work" comment on a first run (prompts/feature.md). Epic slices
340
+ // claim it with a "starting work" comment on a first run (resources/prompts/feature.md). Epic slices
341
341
  // (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
342
342
  // must never be claimed per-slice.
343
343
  claimIssue: true,
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles } from "./github.ts";
6
+ import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles, isNotAPullRequestError } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -236,3 +236,26 @@ test("ensureBaseBranch: missing non-epic/* branch throws BaseBranchMustExistErro
236
236
  // A rejected non-epic/* base must never spawn a wrong-rooted branch.
237
237
  assertEquals(state.creates.length, 0);
238
238
  });
239
+
240
+ // A `Depends-on:` ref that resolves to an issue (or a non-existent number) can never merge, so the
241
+ // merge-poller's dependency gate must treat it as non-blocking rather than wedging forever — the
242
+ // exact wedge behind Magikcraft/nano-bpm#806 declaring `Depends-on:` its epic tracking *issue*
243
+ // #796. `isNotAPullRequestError` is the discriminator: it must fire for both transports' "not a PR"
244
+ // signals and stay false for transient failures (which must keep the dependency blocking).
245
+ test("isNotAPullRequestError: gh GraphQL 'not a PullRequest' → true", () => {
246
+ const err = new Error(
247
+ "GraphQL: Could not resolve to a PullRequest with the number of 796. (repository.pullRequest)",
248
+ );
249
+ assertEquals(isNotAPullRequestError(err), true);
250
+ });
251
+
252
+ test("isNotAPullRequestError: token-mode 404 → true", () => {
253
+ assertEquals(isNotAPullRequestError(new Error("github 404 Not Found")), true);
254
+ });
255
+
256
+ test("isNotAPullRequestError: transient failures stay blocking (false)", () => {
257
+ assertEquals(isNotAPullRequestError(new Error("github 502 Bad Gateway")), false);
258
+ assertEquals(isNotAPullRequestError(new Error("API rate limit exceeded")), false);
259
+ assertEquals(isNotAPullRequestError(new Error("fetch failed")), false);
260
+ assertEquals(isNotAPullRequestError(null), false);
261
+ });
package/app/github.ts CHANGED
@@ -496,6 +496,20 @@ function allCheckNames(rollup: RollupEntry[]): string[] {
496
496
  return names;
497
497
  }
498
498
 
499
+ /** True when `err` is GitHub reporting that a ref which parsed as `owner/repo#N` is not a pull
500
+ * request — either it's an issue (issues and PRs share GitHub's number space, so an issue number
501
+ * is indistinguishable from a PR number by shape alone) or the number does not exist. Both
502
+ * transports surface here: `gh` mode throws the GraphQL message "Could not resolve to a
503
+ * PullRequest with the number of N", and token mode throws `github 404 …` from
504
+ * `GET /repos/{repo}/pulls/{N}`. A ref that is not a pull request can never merge, so a caller
505
+ * gating a merge queue on it (see `isDepMerged`) must treat it as non-blocking instead of wedging
506
+ * forever. Transient failures (rate-limit, 5xx, network) deliberately return `false` so the caller
507
+ * keeps waiting/retrying rather than silently clearing a real dependency. */
508
+ export function isNotAPullRequestError(err: unknown): boolean {
509
+ const msg = err instanceof Error ? err.message : String(err);
510
+ return /could not resolve to a pullrequest/i.test(msg) || /\bgithub 404\b/i.test(msg);
511
+ }
512
+
499
513
  export async function fetchPrState(
500
514
  repo: string,
501
515
  number: number | string,
package/app/plan.ts CHANGED
@@ -31,8 +31,10 @@ 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 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
34
+ // `senior:feature` prompts are generic resources (`resources/prompts/plan.md` /
35
+ // `resources/prompts/plan-review.md` / `resources/prompts/feature.md`, deployed under the
36
+ // `resources/` deploy-by-convention layout — nano.app.json declares no `models`) linked into each
37
+ // task as
36
38
  // `<zeebe:linkedResource … bindingType="latest" linkName="prompt"/>` and resolved by the engine at
37
39
  // job activation. Per-instance dynamic context (a plan's rejection findings, a task's brief) rides
38
40
  // `appendPrompt`, which the harness concatenates onto the linked base. The host only carries
@@ -244,7 +246,7 @@ export function normalizeBaseBranch(input: string | null | undefined): string {
244
246
 
245
247
  /** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
246
248
  * branch. It is authoritative over the static "branch off the default branch" wording in
247
- * prompts/feature.md, so the agent branches off — and opens its PR against — the integration
249
+ * resources/prompts/feature.md, so the agent branches off — and opens its PR against — the integration
248
250
  * branch, and reads the epic's latest landed state there rather than the repo default branch. */
249
251
  export function renderBaseBranchBrief(baseBranch: string): string {
250
252
  return [
package/app/service.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  fetchPrReviews,
21
21
  fetchPrState,
22
22
  hasPendingCopilotReviewer,
23
+ isNotAPullRequestError,
23
24
  type MergeMethod,
24
25
  type PrState,
25
26
  requestCopilotReview,
@@ -111,7 +112,8 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
111
112
  );
112
113
 
113
114
  // The `senior:pr-review` agent prompt is no longer read by the host: it is a generic resource
114
- // (`prompts/review-round.md`, deployed via a `models` glob in nano.app.json) linked into the task
115
+ // (`resources/prompts/review-round.md`, deployed under the `resources/` deploy-by-convention layout
116
+ // — nano.app.json declares no `models`) linked into the task
115
117
  // as `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`
116
118
  // and resolved by the engine at job activation. The host only carries runtime PR identity + the
117
119
  // round counter now.
@@ -737,8 +739,23 @@ async function isDepMerged(data: DataLayer, depKey: string, token: string): Prom
737
739
  if (tracked && tracked.status === "merged") return true;
738
740
  const parsed = parsePr(depKey);
739
741
  if (!parsed) return true; // unparseable dep can't be checked on GitHub → treat as cleared so it never wedges the PR
740
- const st = await fetchPrState(parsed.repo, parsed.number, token);
741
- return st?.merged ?? false;
742
+ try {
743
+ const st = await fetchPrState(parsed.repo, parsed.number, token);
744
+ return st?.merged ?? false;
745
+ } catch (err) {
746
+ // A ref that GitHub cannot resolve to a *pull request* — it's an issue (issues and PRs share
747
+ // GitHub's number space) or the number doesn't exist — can never merge, so it cannot gate a
748
+ // merge queue. Treat it as cleared (non-blocking) rather than wedging the run at `wait-deps`
749
+ // forever, as happened when a PR body declared `Depends-on:` its epic tracking *issue*
750
+ // (Magikcraft/nano-bpm#806 → #796). Transient failures rethrow so the poller logs and retries.
751
+ if (isNotAPullRequestError(err)) {
752
+ console.warn(
753
+ `[poller] dep ${depKey} is not a mergeable pull request (issue or missing) — treating as non-blocking`,
754
+ );
755
+ return true;
756
+ }
757
+ throw err;
758
+ }
742
759
  }
743
760
 
744
761
  /** Flip a PR into the transient `merging` status and publish the correlating message, reverting
package/app/taskDelta.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // nano-workforce — structured scope/impl-change report from implementers (D5, issue #55 / #49).
2
2
  //
3
- // The implementer result contract (prompts/feature.md) can carry an optional `delta`: a machine-
3
+ // The implementer result contract (resources/prompts/feature.md) can carry an optional `delta`: a machine-
4
4
  // readable record of how a slice's implementation diverged from its brief — a changed contract, a
5
5
  // discovered constraint, files it now touches beyond its slice, or other tasks it affects. Before
6
6
  // this, that information lived only in PR prose: invisible to the planner, to sibling agents, and
@@ -307,20 +307,29 @@ both live in the source repo, not in the job payload.
307
307
  `retro.bpmn`. These are the source of truth for routing. To understand *why* an
308
308
  instance went where it did, read the gateway conditions (FEEL expressions on the
309
309
  sequence flows) for the element it is parked on (§5).
310
- - **Prompts (agent base instructions):** `prompts/*.md` — `review-round.md`,
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
313
- **linked resource** (likewise `plan.md`, …):
310
+ - **Prompts (agent base instructions):** `resources/prompts/*.md` — `review-round.md`,
311
+ `plan.md`, `feature.md`, `fix-ci.md`, `rebase.md`, `trial-merge.md`, etc. They live
312
+ under `resources/` so they deploy by convention (ADR 0062 the app declares no
313
+ `models`; every file under `resources/` is deployed, one `.md` per task under
314
+ `resources/prompts/`). An agent's base prompt is **not** a job variable and is **not**
315
+ a deploy-time `{{token}}` substitution (that templating is removed — there is no
316
+ back-compat): it is delivered as a **linked resource**, the blessed **and only**
317
+ prompt-modularity path:
314
318
 
315
319
  ```xml
316
- <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
320
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" resourceType="GenericScript" linkName="prompt"/>
317
321
  ```
318
322
 
319
323
  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
323
- to inspect/fix.
324
+ resolves to the latest deployed prompt at job activation, combined at runtime with the
325
+ per-task `appendPrompt` FEEL (the task-specific slice appended to this base). Because
326
+ the binding is `latest`, redeploying one of these files updates the prompt mid-epic
327
+ without a process redeploy. If an agent misbehaves systematically, the prompt is the
328
+ first thing to inspect/fix.
329
+
330
+ > **Value-injection caveat:** never bake a per-run value (a URL, a flag) into a prompt
331
+ > at deploy time — that path is gone. Pass it as a runtime job variable / `appendPrompt`
332
+ > FEEL instead; deploy-time string substitution is not available.
324
333
  - **Job contract:** `senior:pr-review` receives `{ prUrl, repo, prNumber, round,
325
334
  answer? }` and must return a flat result `{ status, summary, question? }` with
326
335
  `status ∈ { converged, addressed, waiting, needs_input, blocked }`. A round that
@@ -329,7 +338,7 @@ both live in the source repo, not in the job payload.
329
338
  rather than escalating.
330
339
 
331
340
  To validate a model/prompt change locally: `npm run layout:check` (BPMN diagram
332
- freshness), `npm run check:prompts` (every template resolves), `npm run check`
341
+ freshness), `npm run check:prompts` (every prompt link resolves), `npm run check`
333
342
  (manifest), `npm run typecheck`, `npm run lint`, `npm test`.
334
343
 
335
344
  ---
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import type { EngineJob } from "@nanobpm/urban/runtime";
26
26
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
27
27
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
28
+ import { asEngineClient } from "./support/engine-client.ts";
28
29
  import {
29
30
  completeEscalationAsAgent,
30
31
  latestCompletion,
@@ -138,7 +139,7 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
138
139
 
139
140
  // Complete AS AN AGENT through the host-side completer — the same typed `{resolution, answer}`
140
141
  // a human submits through the inbox, only the caller differs.
141
- const r = await completeEscalationAsAgent(app.db, app.engine, {
142
+ const r = await completeEscalationAsAgent(app.db, asEngineClient(app.engine), {
142
143
  userTaskKey: task.userTaskKey,
143
144
  agentId: "senior:answer-bot",
144
145
  variables: { resolution: "answer", answer: "use v2" },
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import type { EngineJob } from "@nanobpm/urban/runtime";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
24
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
+ import { asEngineClient } from "./support/engine-client.ts";
25
26
  import { pollFeatureBlocked, pollFeatureEscalations } from "../app/service.ts";
26
27
 
27
28
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -192,7 +193,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
192
193
 
193
194
  // The poller fills in the completable user-task key (which no service task can know — the task
194
195
  // doesn't exist yet when record-feature runs) so the pages can drive an attributed acknowledge.
195
- await pollFeatureBlocked(app.db, app.engine);
196
+ await pollFeatureBlocked(app.db, asEngineClient(app.engine));
196
197
  const denorm = await featureRow(app, featureKey);
197
198
  assert.ok(denorm.blocked_user_task_key, "the poller denormalised the completable blocked user-task key");
198
199
  assert.equal(denorm.status, "awaiting_operator", "the run stays awaiting_operator while parked");
@@ -214,7 +215,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
214
215
  assert.equal(settled.blocked_user_task_key, null, "the completable-task pointer was cleared on ack");
215
216
 
216
217
  // A further poll pass is an idempotent no-op — a terminal run is not a candidate.
217
- await pollFeatureBlocked(app.db, app.engine);
218
+ await pollFeatureBlocked(app.db, asEngineClient(app.engine));
218
219
  assert.equal((await featureRow(app, featureKey)).status, "blocked");
219
220
  },
220
221
  );
@@ -297,7 +298,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
297
298
 
298
299
  // The poller fills in the completable user-task key (which the service task can't know — the
299
300
  // task doesn't exist yet when it runs) so the UI can drive an attributed answer.
300
- await pollFeatureEscalations(app.db, app.engine);
301
+ await pollFeatureEscalations(app.db, asEngineClient(app.engine));
301
302
  const escalated = await featureRow(app, featureKey);
302
303
  assert.ok(escalated.escalation_user_task_key, "the poller denormalised the completable user-task key");
303
304
  assert.equal(escalated.status, "escalated", "the run stays escalated while parked");
@@ -324,7 +325,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
324
325
  assert.equal(settled.escalation_question, null, "the surfaced question was cleared once resolved");
325
326
 
326
327
  // A further poll pass is an idempotent no-op — a terminal run is not a candidate.
327
- await pollFeatureEscalations(app.db, app.engine);
328
+ await pollFeatureEscalations(app.db, asEngineClient(app.engine));
328
329
  assert.equal((await featureRow(app, featureKey)).status, "opened");
329
330
  },
330
331
  );
@@ -0,0 +1,17 @@
1
+ import type { EngineClient } from "@nanobpm/urban";
2
+ import type { TestApp } from "@nanobpm/urban-testkit";
3
+
4
+ // urban 0.49.0 (ADR 0062) added `EngineClient.getForm`, but the published
5
+ // @nanobpm/urban-testkit (0.4.0) predates it, so its `WasmEngineClient` neither
6
+ // declares nor implements the method. These hermetic e2e flows drive user-task
7
+ // completion directly (`completeUserTask`) and never resolve a form schema, so we
8
+ // complete the contract with a null-returning `getForm` — the documented "no
9
+ // matching form" path — until a testkit release catches up with urban's engine
10
+ // seam. Scoped to the test harness; production adapters implement `getForm` for real.
11
+ export function asEngineClient(engine: TestApp["engine"]): EngineClient {
12
+ const e = engine as unknown as EngineClient & { getForm?: EngineClient["getForm"] };
13
+ if (typeof e.getForm !== "function") {
14
+ e.getForm = async () => null;
15
+ }
16
+ return e;
17
+ }
package/nano.app.json CHANGED
@@ -4,15 +4,6 @@
4
4
  "id": "nano-workforce",
5
5
  "name": "Nano Workforce",
6
6
  "codename": "nano-workforce",
7
- "models": {
8
- "processes": [
9
- "resources/processes/*.bpmn",
10
- "prompts/*.md"
11
- ],
12
- "forms": [
13
- "resources/forms/*.form"
14
- ]
15
- },
16
7
  "data": {
17
8
  "default": "app",
18
9
  "sources": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.72.1",
3
+ "version": "0.73.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -53,11 +53,11 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@nanobpm/agentic": "^0.1.0",
56
- "@nanobpm/urban": "^0.48.0"
56
+ "@nanobpm/urban": "^0.50.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@biomejs/biome": "^2.4.11",
60
- "@nanobpm/urban-testkit": "^0.4.0",
60
+ "@nanobpm/urban-testkit": "^0.5.0",
61
61
  "@semantic-release/changelog": "^6.0.3",
62
62
  "@semantic-release/git": "^10.0.1",
63
63
  "@semantic-release/npm": "^13.1.5",
@@ -1,8 +1,10 @@
1
1
  // Red/green coverage for the agent-prompt deploy guard (scripts/check-agent-prompts.ts).
2
2
  //
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
3
+ // Since #169 each agent's base prompt is a linked *resource*: `resources/prompts/<token>.md` is
4
+ // deployed as a generic resource and each service task links it with
5
+ // `<zeebe:linkedResource resourceId="<token>.md" bindingType="latest" linkName="prompt"/>`. Under
6
+ // ADR 0062 the app declares no `models`, so the guard discovers deployables by the `resources/`
7
+ // convention walk; a manifest with explicit `models` globs is still honoured as an override. The
6
8
  // engine silently OMITS an unresolvable link — a typo'd or undeployed `resourceId` yields a blank
7
9
  // base prompt at runtime (the prompt-less-agent root of the empty "(no question provided)"
8
10
  // escalations, Magikcraft/nano-bpm #597/#599). These cases assert the guard fails on each broken
@@ -14,12 +16,16 @@ import { tmpdir } from "node:os";
14
16
  import { dirname, join } from "node:path";
15
17
  import { checkAgentPrompts } from "./check-agent-prompts.ts";
16
18
 
17
- // A manifest that deploys BPMN and the prompt resources (the migrated shape: prompts are a deploy
18
- // glob, not a `templates` substitution source).
19
+ // A manifest that deploys BPMN and the prompt resources via an explicit `models` override (the
20
+ // escape hatch exercised here to prove overrides still work alongside the ADR 0062 convention).
19
21
  const MANIFEST = JSON.stringify({
20
22
  models: { processes: ["resources/processes/*.bpmn", "prompts/*.md"] },
21
23
  });
22
24
 
25
+ // The blessed ADR 0062 shape: NO `models` block, so deployables are discovered by the `resources/`
26
+ // convention walk (prompts live at `resources/prompts/*.md`).
27
+ const MANIFEST_CONVENTION = JSON.stringify({ id: "app" });
28
+
23
29
  function link(resourceId: string, bindingType = "latest"): string {
24
30
  return `<zeebe:linkedResource resourceId="${resourceId}" bindingType="${bindingType}" linkName="prompt" />`;
25
31
  }
@@ -51,6 +57,33 @@ test("passes when every prompt link resolves to a deployed, non-blank, result-em
51
57
  assertEquals(res.resolved, ["review-round"]);
52
58
  });
53
59
 
60
+ test("discovers prompts by the resources/ convention when the manifest declares no models", () => {
61
+ const root = fixture({
62
+ "nano.app.json": MANIFEST_CONVENTION,
63
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
64
+ "resources/prompts/review-round.md": "# Round\nDo the thing, then write `$AGENT_RESULT_FILE`.",
65
+ // Docs live OUTSIDE resources/ and must never be swept into the deploy set.
66
+ "docs/agent-guide.md": "# Guide\nnot a deployable",
67
+ });
68
+ const res = checkAgentPrompts(root);
69
+ assertEquals(res.errors, []);
70
+ assert(res.ok);
71
+ assertEquals(res.resolved, ["review-round"]);
72
+ });
73
+
74
+ test("convention walk flags a prompt link that has no deployed resource under resources/", () => {
75
+ const root = fixture({
76
+ "nano.app.json": MANIFEST_CONVENTION,
77
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
78
+ // The prompt lives OUTSIDE resources/ (old top-level prompts/ layout) so convention never
79
+ // deploys it — the link would resolve to nothing at runtime.
80
+ "prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
81
+ });
82
+ const res = checkAgentPrompts(root);
83
+ assert(!res.ok);
84
+ assert(res.errors.some((e) => e.includes("no deployed resource has that name")));
85
+ });
86
+
54
87
  test("fails when a prompt link references a resourceId with no deployed file", () => {
55
88
  const root = fixture({
56
89
  "nano.app.json": MANIFEST,
@@ -1,9 +1,10 @@
1
1
  // check-agent-prompts — deploy-safety gate for the agent prompts, now authored as *linked
2
2
  // resources* (issue #169) rather than baked `{{token}}` templates.
3
3
  //
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:
4
+ // Since #169 each agent's base prompt is a generic resource: `resources/prompts/<token>.md` is
5
+ // deployed as an `application/octet-stream` resource (under the ADR 0062 `resources/` deploy-by-
6
+ // convention layout see nano.app.json, which declares no `models`) and each agent service task
7
+ // links it at job-activation time:
7
8
  //
8
9
  // <zeebe:linkedResources>
9
10
  // <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
@@ -17,9 +18,9 @@
17
18
  // #597/#599). This guard turns that silent runtime failure into a hard build failure:
18
19
  //
19
20
  // 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).
21
+ // deploys (a file under the `resources/` convention walk, or a manifest `models` override
22
+ // glob). This catches both a typo'd `resourceId` and a prompt that exists on disk but is not
23
+ // wired into the deploy set (so never reaches the engine — the link would resolve to nothing).
23
24
  // 2. Each linked prompt file must be non-blank and must teach the agent to emit a machine-readable
24
25
  // result (`$AGENT_RESULT_FILE`, or the `::nano:result::` stdout fallback) — a prose-only agent
25
26
  // leaves `status` blank and the status gateway escalates/stalls (the fix-ci/rebase gap behind
@@ -68,6 +69,33 @@ function expandGlob(root: string, pattern: string): string[] {
68
69
  .map((f) => join(dir, f));
69
70
  }
70
71
 
72
+ // The convention directory (ADR 0062): deploy-only, walked one level deep when the manifest declares
73
+ // no `models`. Must stay in lock-step with urban's `RESOURCES_DIR`/`deployModels`.
74
+ const RESOURCES_DIR = "resources";
75
+
76
+ // Mirror urban's deploy-by-convention walk (ADR 0062): when the manifest declares no `models`, the
77
+ // deployables are every file directly under `resources/` PLUS every file one directory deeper
78
+ // (`resources/<subdir>/*`) — shallow, one level only. Deeper nesting is intentionally NOT swept in:
79
+ // the deploy dedupe key is the basename, so a deep walk would reintroduce cross-directory basename
80
+ // collision risk. Paths come back repo-relative (with `/`), matching `expandGlob`'s output so the
81
+ // two discovery modes are interchangeable downstream.
82
+ function discoverResources(root: string): string[] {
83
+ const base = join(root, RESOURCES_DIR);
84
+ if (!existsSync(base)) return [];
85
+ const out: string[] = [];
86
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
87
+ if (entry.isFile()) {
88
+ out.push(join(RESOURCES_DIR, entry.name));
89
+ } else if (entry.isDirectory()) {
90
+ const sub = join(base, entry.name);
91
+ for (const f of readdirSync(sub, { withFileTypes: true })) {
92
+ if (f.isFile()) out.push(join(RESOURCES_DIR, entry.name, f.name));
93
+ }
94
+ }
95
+ }
96
+ return out.sort();
97
+ }
98
+
71
99
  interface PromptLink {
72
100
  resourceId: string;
73
101
  bindingType: string | null;
@@ -130,24 +158,33 @@ export function checkAgentPrompts(root: string): CheckResult {
130
158
  }
131
159
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
132
160
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
133
- const models = manifest.models ?? {};
134
-
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 = [
141
- ...(models.processes ?? []),
142
- ...(models.decisions ?? []),
143
- ...(models.forms ?? []),
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.
161
+
162
+ // The resources the app actually DEPLOYS. Under ADR 0062 deploy-by-convention this is derived the
163
+ // SAME way urban's deployModels derives it, so this gate reasons about exactly the file set that
164
+ // ships to the engine:
165
+ // no `models` block discover by convention: every file under `resources/` (shallow, one
166
+ // level deep). This is nwf's blessed layout prompts live at `resources/prompts/*.md`.
167
+ // `models` globs present explicit override, used verbatim (the escape hatch for a
168
+ // non-convention layout). A declared-but-empty `models` is still an override, NOT a fallback
169
+ // to the convention walk — mirror deployModels, which keys convention off the block's absence.
170
+ // Either way a deployed resource's name is the file's basename — the same string a
171
+ // `linkedResource resourceId` must reference — which is what catches a prompt that exists on disk
172
+ // but is not actually deployed (so it never reaches the engine and the link resolves to nothing).
173
+ const byConvention = manifest.models === undefined;
174
+ const deployedRels = byConvention
175
+ ? discoverResources(root)
176
+ : [
177
+ ...(manifest.models?.processes ?? []),
178
+ ...(manifest.models?.decisions ?? []),
179
+ ...(manifest.models?.forms ?? []),
180
+ ].flatMap((p) => expandGlob(root, p));
181
+
182
+ // Keyed by basename (the deployed resource name a `resourceId` references). Two deployables sharing
183
+ // a basename would silently overwrite here — and clobber each other at the engine — so a
184
+ // `resourceId` lookup could resolve to the wrong file (or mask a misconfiguration). Fail fast on
185
+ // the collision so the lookup stays unambiguous.
149
186
  const deployedFiles = new Map<string, string>();
150
- for (const rel of deployGlobs.flatMap((p) => expandGlob(root, p))) {
187
+ for (const rel of deployedRels) {
151
188
  const name = basename(rel);
152
189
  const prior = deployedFiles.get(name);
153
190
  if (prior != null && prior !== rel) {
@@ -162,11 +199,13 @@ export function checkAgentPrompts(root: string): CheckResult {
162
199
  }
163
200
 
164
201
  // 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");
202
+ const xmlModelFiles = deployedRels.filter((rel) => contentTypeFor(rel) === "text/xml");
168
203
  if (xmlModelFiles.length === 0) {
169
- errors.push(`no BPMN/DMN model files matched ${JSON.stringify(deployGlobs)}`);
204
+ errors.push(
205
+ byConvention
206
+ ? `no BPMN/DMN model files found under ${RESOURCES_DIR}/ by convention`
207
+ : "no BPMN/DMN model files matched the manifest's models globs",
208
+ );
170
209
  }
171
210
 
172
211
  let linkCount = 0;
@@ -199,8 +238,9 @@ export function checkAgentPrompts(root: string): CheckResult {
199
238
  if (deployedRel == null) {
200
239
  errors.push(
201
240
  `${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`,
241
+ `no deployed resource has that name no file under the app's deploy set (the ` +
242
+ `resources/ convention walk, or the manifest's models globs) matches it, so the engine ` +
243
+ `would omit the link and the agent would run prompt-less`,
204
244
  );
205
245
  continue;
206
246
  }
@@ -6,7 +6,7 @@
6
6
  // merged. The process re-enters its existing `wait-deps` catch, so no human has to babysit an
7
7
  // ordering constraint the machinery already knows how to satisfy.
8
8
  //
9
- // `dependsOn` is whatever the agent returned (see prompts/fix-ci.md, prompts/rebase.md): a
9
+ // `dependsOn` is whatever the agent returned (see resources/prompts/fix-ci.md, resources/prompts/rebase.md): a
10
10
  // string of one or more `owner/repo#N` refs (or PR URLs) separated by commas/whitespace/newlines,
11
11
  // or an array of such tokens. We parse each robustly (reusing `parsePr`), drop self-references and
12
12
  // duplicates, and insert missing edges idempotently — a worker retry never double-inserts, and an
@@ -1,6 +1,6 @@
1
1
  // pr.record-feature — the single-issue `implement` block has finished (issue #172).
2
2
  //
3
- // The `senior:feature` agent reported one of `opened` / `blocked` / `skipped` (prompts/feature.md);
3
+ // The `senior:feature` agent reported one of `opened` / `blocked` / `skipped` (resources/prompts/feature.md);
4
4
  // anything else — including a missing status, or an `escalated` status that fell through to abandon
5
5
  // (the human abandoned or the SLA fired) — is treated as `blocked`: we must not assume a PR was
6
6
  // opened. This worker:
@@ -47,7 +47,7 @@ interface Out extends Record<string, unknown> {
47
47
  const str = (v: unknown): string | undefined =>
48
48
  typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
49
49
 
50
- // The implementation agent reports one of these (see prompts/feature.md). Anything else —
50
+ // The implementation agent reports one of these (see resources/prompts/feature.md). Anything else —
51
51
  // including a missing status — is treated as `blocked`: we must not assume a PR was opened,
52
52
  // and we only hand off / persist a PR when the status is `opened`.
53
53
  type WaveResultStatus = Extract<PlanTaskStatus, "opened" | "blocked" | "skipped">;
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes