@nanobpm/nano-workforce 0.72.0 → 0.73.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.
@@ -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