@nanobpm/nano-workforce 0.161.0 → 0.162.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.
@@ -266,7 +266,7 @@
266
266
  <bpmn:extensionElements>
267
267
  <zeebe:taskDefinition type="senior:plan" />
268
268
  <zeebe:linkedResources>
269
- <zeebe:linkedResource resourceId="plan.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
269
+ <zeebe:linkedResource resourceId="prompts/plan.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
270
270
  </zeebe:linkedResources>
271
271
  <zeebe:ioMapping>
272
272
  <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" />
@@ -292,7 +292,7 @@
292
292
  <bpmn:extensionElements>
293
293
  <zeebe:taskDefinition type="senior:plan-review" />
294
294
  <zeebe:linkedResources>
295
- <zeebe:linkedResource resourceId="plan-review.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
295
+ <zeebe:linkedResource resourceId="prompts/plan-review.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
296
296
  </zeebe:linkedResources>
297
297
  </bpmn:extensionElements>
298
298
  <bpmn:incoming>f_toReviewPlan</bpmn:incoming>
@@ -419,7 +419,7 @@
419
419
  <bpmn:extensionElements>
420
420
  <zeebe:taskDefinition type="senior:feature" />
421
421
  <zeebe:linkedResources>
422
- <zeebe:linkedResource resourceId="feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
422
+ <zeebe:linkedResource resourceId="prompts/feature.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
423
423
  </zeebe:linkedResources>
424
424
  <zeebe:ioMapping>
425
425
  <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) + (if (resolvedDepsBrief = null) then &#34;&#34; else resolvedDepsBrief) + (if (resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**Bound producer capabilities (inter-epic gate).** This epic depends on capabilities another epic in the set just published. Build/install/clone against EXACTLY these published versions (the ones first carrying the awaited capability), never merely the newest:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;)))" target="appendPrompt" />
@@ -518,7 +518,7 @@
518
518
  <bpmn:extensionElements>
519
519
  <zeebe:taskDefinition type="senior:trial-merge" />
520
520
  <zeebe:linkedResources>
521
- <zeebe:linkedResource resourceId="trial-merge.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
521
+ <zeebe:linkedResource resourceId="prompts/trial-merge.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
522
522
  </zeebe:linkedResources>
523
523
  <zeebe:ioMapping>
524
524
  <zeebe:input source="=null" target="result" />
@@ -44,7 +44,7 @@
44
44
  <bpmn:extensionElements>
45
45
  <zeebe:taskDefinition type="senior:conformance" />
46
46
  <zeebe:linkedResources>
47
- <zeebe:linkedResource resourceId="conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
47
+ <zeebe:linkedResource resourceId="prompts/conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
48
48
  </zeebe:linkedResources>
49
49
  <zeebe:ioMapping>
50
50
  <zeebe:input source="=conformanceDigest" target="appendPrompt" />
@@ -92,7 +92,7 @@
92
92
  <bpmn:extensionElements>
93
93
  <zeebe:taskDefinition type="senior:retro" />
94
94
  <zeebe:linkedResources>
95
- <zeebe:linkedResource resourceId="retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
95
+ <zeebe:linkedResource resourceId="prompts/retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
96
96
  </zeebe:linkedResources>
97
97
  <zeebe:ioMapping>
98
98
  <zeebe:input source="=retroDigest" target="appendPrompt" />
@@ -60,7 +60,9 @@ test("passes when every prompt link resolves to a deployed, non-blank, result-em
60
60
  test("discovers prompts by the resources/ convention when the manifest declares no models", () => {
61
61
  const root = fixture({
62
62
  "nano.app.json": MANIFEST_CONVENTION,
63
- "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
63
+ // Under ADR 0062 deploy-by-convention the resource id is the path relative to resources/, so the
64
+ // link must reference `prompts/review-round.md` — the exact id urban deploys and the engine resolves.
65
+ "resources/processes/loop.bpmn": serviceTask(link("prompts/review-round.md")),
64
66
  "resources/prompts/review-round.md": "# Round\nDo the thing, then write `$AGENT_RESULT_FILE`.",
65
67
  // Docs live OUTSIDE resources/ and must never be swept into the deploy set.
66
68
  "docs/agent-guide.md": "# Guide\nnot a deployable",
@@ -71,6 +73,23 @@ test("discovers prompts by the resources/ convention when the manifest declares
71
73
  assertEquals(res.resolved, ["review-round"]);
72
74
  });
73
75
 
76
+ test("convention: a bare basename resourceId (pre-ADR-0062) fails — the id must be resources/-relative", () => {
77
+ // Regression guard for the #241 migration miss: the prompt WAS moved under resources/prompts/, so it
78
+ // deploys as `prompts/review-round.md`, but the link still names the bare `review-round.md`. The
79
+ // engine resolves ids exactly, omits the unresolvable link, and the agent runs prompt-less. The gate
80
+ // MUST catch this, not silently pass as it did before.
81
+ const root = fixture({
82
+ "nano.app.json": MANIFEST_CONVENTION,
83
+ "resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
84
+ "resources/prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
85
+ });
86
+ const res = checkAgentPrompts(root);
87
+ assert(!res.ok);
88
+ assert(
89
+ res.errors.some((e) => e.includes('resourceId="review-round.md"') && e.includes("no deployed resource")),
90
+ );
91
+ });
92
+
74
93
  test("convention walk flags a prompt link that has no deployed resource under resources/", () => {
75
94
  const root = fixture({
76
95
  "nano.app.json": MANIFEST_CONVENTION,
@@ -236,7 +255,7 @@ test("fails when two deploy globs match files sharing a basename (ambiguous reso
236
255
  });
237
256
  const res = checkAgentPrompts(root);
238
257
  assert(!res.ok);
239
- assert(res.errors.some((e) => e.includes("duplicate deployed resource name") && e.includes("review-round.md")));
258
+ assert(res.errors.some((e) => e.includes("duplicate deployed resource id") && e.includes("review-round.md")));
240
259
  });
241
260
 
242
261
  test("checks the real repo: all committed agent prompts link to deployed resources", () => {
@@ -29,7 +29,7 @@
29
29
  // the deploy no longer substitutes `{{token}}` templates, so such a header would ship a literal
30
30
  // `{{token}}` (or stale frozen text) as the prompt.
31
31
  import { existsSync, readdirSync, readFileSync } from "node:fs";
32
- import { basename, join } from "node:path";
32
+ import { basename, join, relative, sep } from "node:path";
33
33
 
34
34
  // The retired header that used to carry an agent's baked base prompt. Its continued presence is a
35
35
  // migration regression (the deploy no longer substitutes templates), so we flag it.
@@ -69,33 +69,49 @@ function expandGlob(root: string, pattern: string): string[] {
69
69
  .map((f) => join(dir, f));
70
70
  }
71
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`.
72
+ // The convention directory (ADR 0062): deploy-only, walked recursively (every file at any depth)
73
+ // when the manifest declares no `models`. Must stay in lock-step with urban's
74
+ // `RESOURCES_DIR`/`deployModels`.
74
75
  const RESOURCES_DIR = "resources";
75
76
 
76
77
  // 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.
78
+ // deployables are every file under `resources/` at ANY depth (urban's deploy.js walks it
79
+ // recursively). A convention resource is keyed by its path relative to `resources/`, so files
80
+ // sharing a basename in different sub-directories deploy as distinct resources no collision and
81
+ // a deep walk is safe. Paths come back repo-relative (with `/`), matching `expandGlob`'s output so
82
+ // the two discovery modes are interchangeable downstream.
82
83
  function discoverResources(root: string): string[] {
83
84
  const base = join(root, RESOURCES_DIR);
84
85
  if (!existsSync(base)) return [];
85
86
  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));
87
+ const walk = (dir: string): void => {
88
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
89
+ const abs = join(dir, entry.name);
90
+ if (entry.isFile()) {
91
+ out.push(join(RESOURCES_DIR, relative(base, abs)));
92
+ } else if (entry.isDirectory()) {
93
+ walk(abs);
93
94
  }
94
95
  }
95
- }
96
+ };
97
+ walk(base);
96
98
  return out.sort();
97
99
  }
98
100
 
101
+ // The deployed `resourceId` a `linkName="prompt"` link must reference. Kept in lock-step with
102
+ // urban's deploy.js: a CONVENTION resource (no `models` block) is keyed by its path relative to
103
+ // `resources/` (POSIX) — `resources/prompts/plan.md` → `prompts/plan.md`; a `models` OVERRIDE
104
+ // resource is keyed by its basename. This is the exact string the engine matches a linkedResource
105
+ // `resourceId` against at activation, so the gate must reason about it (not the bare basename) —
106
+ // otherwise a prompt moved into a sub-directory deploys as `prompts/plan.md` while a stale bare
107
+ // `resourceId="plan.md"` link resolves to nothing and the agent runs prompt-less.
108
+ function deployResourceId(rel: string, byConvention: boolean): string {
109
+ if (!byConvention) return basename(rel);
110
+ const posix = rel.split(sep).join("/");
111
+ const prefix = `${RESOURCES_DIR}/`;
112
+ return posix.startsWith(prefix) ? posix.slice(prefix.length) : basename(rel);
113
+ }
114
+
99
115
  interface PromptLink {
100
116
  resourceId: string;
101
117
  bindingType: string | null;
@@ -162,14 +178,16 @@ export function checkAgentPrompts(root: string): CheckResult {
162
178
  // The resources the app actually DEPLOYS. Under ADR 0062 deploy-by-convention this is derived the
163
179
  // SAME way urban's deployModels derives it, so this gate reasons about exactly the file set that
164
180
  // 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`.
181
+ // • no `models` block → discover by convention: every file under `resources/` at any depth
182
+ // (recursive, mirroring urban). This is nwf's blessed layout — prompts live at
183
+ // `resources/prompts/*.md`.
167
184
  // • `models` globs present → explicit override, used verbatim (the escape hatch for a
168
185
  // non-convention layout). A declared-but-empty `models` is still an override, NOT a fallback
169
186
  // 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).
187
+ // Either way a deployed resource's id is what a `linkedResource resourceId` must reference for a
188
+ // convention resource its path relative to `resources/` (`prompts/plan.md`), for a `models`
189
+ // override its basename (see deployResourceId) which is what catches a prompt that exists on
190
+ // disk but is not actually deployed under the id the link names (so the link resolves to nothing).
173
191
  const byConvention = manifest.models === undefined;
174
192
  const deployedRels = byConvention
175
193
  ? discoverResources(root)
@@ -179,19 +197,20 @@ export function checkAgentPrompts(root: string): CheckResult {
179
197
  ...(manifest.models?.forms ?? []),
180
198
  ].flatMap((p) => expandGlob(root, p));
181
199
 
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
200
+ // Keyed by the DEPLOYED resourceId a link references: the path relative to `resources/` for a
201
+ // convention resource, or the basename for a `models` override (see deployResourceId). Two
202
+ // deployables that would deploy under the same id clobber each other at the engine, so a
184
203
  // `resourceId` lookup could resolve to the wrong file (or mask a misconfiguration). Fail fast on
185
204
  // the collision so the lookup stays unambiguous.
186
205
  const deployedFiles = new Map<string, string>();
187
206
  for (const rel of deployedRels) {
188
- const name = basename(rel);
207
+ const name = deployResourceId(rel, byConvention);
189
208
  const prior = deployedFiles.get(name);
190
209
  if (prior != null && prior !== rel) {
191
210
  errors.push(
192
- `duplicate deployed resource name "${name}": both "${prior}" and "${rel}" deploy under the ` +
193
- `same basename, so a linkName="prompt" resourceId="${name}" would resolve ambiguously — ` +
194
- `rename one so deployed resource names stay unique`,
211
+ `duplicate deployed resource id "${name}": both "${prior}" and "${rel}" deploy under the ` +
212
+ `same id, so a linkName="prompt" resourceId="${name}" would resolve ambiguously — ` +
213
+ `rename one so deployed resource ids stay unique`,
195
214
  );
196
215
  continue;
197
216
  }
@@ -108,7 +108,7 @@ const retroFlow: DeclarativeFlow = defineFlow(
108
108
  w.task("gather", { jobType: "pr.retro-gather" });
109
109
  w.task("conformance", {
110
110
  jobType: "senior:conformance",
111
- prompt: { resourceId: "conformance.md", bindingType: "latest", append: "=conformanceDigest" },
111
+ prompt: { resourceId: "prompts/conformance.md", bindingType: "latest", append: "=conformanceDigest" },
112
112
  });
113
113
  w.task("record-conformance", { jobType: "pr.conformance-record" });
114
114
  w.branch("hasDeviations = true", {
@@ -130,7 +130,7 @@ const retroFlow: DeclarativeFlow = defineFlow(
130
130
  });
131
131
  w.task("synthesize", {
132
132
  jobType: "senior:retro",
133
- prompt: { resourceId: "retro.md", bindingType: "latest", append: "=retroDigest" },
133
+ prompt: { resourceId: "prompts/retro.md", bindingType: "latest", append: "=retroDigest" },
134
134
  });
135
135
  w.task("record", { jobType: "pr.retro-record" });
136
136
  },