@nanobpm/nano-workforce 0.83.0 → 0.85.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,17 @@
1
+ # [0.85.0](https://github.com/nanobpm/nano-workforce/compare/v0.84.0...v0.85.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * **readiness:** capability probe kind — resolve capability→version from publish provenance, late-bind + pin ([#274](https://github.com/nanobpm/nano-workforce/issues/274)) ([#275](https://github.com/nanobpm/nano-workforce/issues/275)) ([33171b7](https://github.com/nanobpm/nano-workforce/commit/33171b73a69d5b130f205be12e7f0988b3f268a6)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258)
7
+
8
+ # [0.84.0](https://github.com/nanobpm/nano-workforce/compare/v0.83.0...v0.84.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * **ui:** render narrative epic-detail sections with urban prose renderer ([#270](https://github.com/nanobpm/nano-workforce/issues/270)) ([#271](https://github.com/nanobpm/nano-workforce/issues/271)) ([38c67a4](https://github.com/nanobpm/nano-workforce/commit/38c67a479693730b9a79f3a7b0f88ef574d3a456)), closes [nano-ide#274](https://github.com/nano-ide/issues/274) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [274/#275](https://github.com/nanobpm/nano-workforce/issues/275)
14
+
1
15
  # [0.83.0](https://github.com/nanobpm/nano-workforce/compare/v0.82.1...v0.83.0) (2026-08-17)
2
16
 
3
17
 
package/app/feature.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
- import { deriveListBucket, deriveStage } from "./stage.ts";
20
+ import { deriveEscalationOpen, deriveListBucket, deriveStage } from "./stage.ts";
21
21
 
22
22
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
23
23
  export const FEATURE_PROCESS_ID = "feature";
@@ -63,6 +63,16 @@ export interface FeatureRun {
63
63
  * (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
64
64
  * `pollFeatureEscalations` while parked; NULL otherwise. */
65
65
  escalation_user_task_key: string | null;
66
+ /** Gateway projection (issue #272): the single fail-closed "open escalation" display signal, `1` iff
67
+ * ALL THREE escalation columns agree the run is parked at an answerable escalation
68
+ * (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question` non-NULL),
69
+ * else `0`. Derived by `deriveEscalationOpen` at write time. The escalation tuple is spread across
70
+ * three independently-written columns, so an interim read can see a TORN state (a live pointer with a
71
+ * blank question, or a status lagging behind a cleared question); the pages gate the Abandon action
72
+ * and the answer form on THIS conjunction instead of on `escalation_user_task_key` alone, so a torn
73
+ * tuple renders as not-escalated rather than escalated-but-blank. NULL only on legacy rows before the
74
+ * projection reached them (backfilled once at boot). */
75
+ escalation_open: number | null;
66
76
  /** The completable native `feature-blocked` user-task key the "Acknowledge blocked" affordance posts
67
77
  * to (`completeUserTaskAttributed`) and the pages gate the acknowledge control on (`showWhenField`).
68
78
  * Kept DISTINCT from `escalation_user_task_key` so the two human tasks (an escalation answer vs a
@@ -296,6 +306,7 @@ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
296
306
  "stage_skipped",
297
307
  "attention",
298
308
  "list_bucket",
309
+ "escalation_open",
299
310
  ];
300
311
 
301
312
  /** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
@@ -326,6 +337,13 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
326
337
  stage_skipped: skipped,
327
338
  attention,
328
339
  list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
340
+ escalation_open: deriveEscalationOpen({
341
+ status: row.status,
342
+ escalation_question: row.escalation_question ?? null,
343
+ escalation_user_task_key: row.escalation_user_task_key ?? null,
344
+ })
345
+ ? 1
346
+ : 0,
329
347
  };
330
348
  }
331
349
 
@@ -370,21 +388,25 @@ export const featureRuns = (data: DataLayer) => {
370
388
  });
371
389
  };
372
390
 
373
- /** Re-project every feature_runs row through the gateway so rows written before migration 039 (whose
374
- * projection columns are NULL) get correct `stage`/`stage_state`/`stage_skipped`/`attention`/
375
- * `list_bucket` values. Idempotent and safe to re-run: it re-derives from each row's own stored fields,
376
- * so a second pass is a no-op. Runs once at boot (pollOnce) the gateway keeps every future write
377
- * fresh, so this only needs to catch legacy rows once. */
391
+ /** Re-project every feature_runs row through the gateway so rows missing any projection column get
392
+ * correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`/`escalation_open` values.
393
+ * Catches rows written before migration 039 (the pipeline columns) AND rows written before migration
394
+ * 040 (whose `escalation_open` column is NULL, issue #272). Idempotent and safe to re-run: it
395
+ * re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at boot
396
+ * (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy rows.
397
+ * Reprojecting on a missing `escalation_open` matters for a run parked at a LIVE escalation when
398
+ * migration 040 lands: `pollFeatureEscalations` writes nothing while it stays parked (no change), so
399
+ * without this the fail-closed signal would stay NULL and hide a genuinely-open escalation. */
378
400
  export async function backfillFeatureStages(data: DataLayer): Promise<number> {
379
401
  const table = featureRuns(data);
380
402
  const rows = await table.all();
381
403
  let stamped = 0;
382
404
  for (const row of rows) {
383
- // Only touch rows the projection has never reached — a legacy pre-039 row whose `stage` column is
384
- // still NULL. The gateway keeps every write fresh, so an already-projected row needs no re-write;
385
- // skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
386
- // rows actually backfilled (not the total row count).
387
- if (row.stage != null) continue;
405
+ // Only touch rows the projection has never fully reached — a legacy row whose `stage` (pre-039) or
406
+ // `escalation_open` (pre-040) column is still NULL. The gateway keeps every write fresh, so a
407
+ // fully-projected row needs no re-write; skipping them avoids a full-table rewrite on every boot and
408
+ // keeps `stamped` an honest count of rows actually backfilled (not the total row count).
409
+ if (row.stage != null && row.escalation_open != null) continue;
388
410
  // Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
389
411
  // would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
390
412
  // so backfill projects explicitly rather than relying on an empty-patch reproject.)
@@ -72,6 +72,56 @@ test("update {status:'escalated'} on a row with no pr_key projects Implementing
72
72
  assertEquals(rows[0].stage_state, null);
73
73
  });
74
74
 
75
+ // issue #272: the fail-closed open-escalation projection. The escalation tuple is spread across three
76
+ // independently-written columns; the gateway projects `escalation_open=1` ONLY when all three agree.
77
+ test("escalation_open is 1 only when status=escalated AND pointer AND question all present", async () => {
78
+ const { data, rows } = memData();
79
+ rows.push({ feature_key: "o/r#esc", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
80
+ // A complete, answerable escalation → 1.
81
+ await featureRuns(data).update("o/r#esc", {
82
+ status: "escalated",
83
+ escalation_user_task_key: "ut-9",
84
+ escalation_question: "which base branch?",
85
+ });
86
+ assertEquals(rows[0].escalation_open, 1);
87
+ });
88
+
89
+ test("a torn escalation tuple projects escalation_open=0 (renders not-escalated, fail closed)", async () => {
90
+ const { data, rows } = memData();
91
+ // Simulate the exit-path/poller race: the question was cleared while status + pointer still lag in the
92
+ // escalated projection (the mirror tear observed on nwf#270). The page must NOT show an escalation.
93
+ rows.push({
94
+ feature_key: "o/r#torn",
95
+ status: "escalated",
96
+ pr_key: null,
97
+ converge: 1,
98
+ auto_merge: 1,
99
+ escalation_user_task_key: "ut-9",
100
+ escalation_question: "which base?",
101
+ escalation_open: 1,
102
+ });
103
+ await featureRuns(data).update("o/r#torn", { escalation_question: null });
104
+ assertEquals(rows[0].escalation_open, 0);
105
+ });
106
+
107
+ test("clearing the escalation tuple flips escalation_open to 0 without waiting a poll pass", async () => {
108
+ const { data, rows } = memData();
109
+ // The answer operation eagerly clears pointer + question; the gateway reprojects on that same write,
110
+ // so the affordance disappears immediately (issue #272 acceptance: no poll-pass lag).
111
+ rows.push({
112
+ feature_key: "o/r#ans",
113
+ status: "escalated",
114
+ pr_key: null,
115
+ converge: 1,
116
+ auto_merge: 1,
117
+ escalation_user_task_key: "ut-9",
118
+ escalation_question: "which base?",
119
+ escalation_open: 1,
120
+ });
121
+ await featureRuns(data).update("o/r#ans", { escalation_user_task_key: null, escalation_question: null });
122
+ assertEquals(rows[0].escalation_open, 0);
123
+ });
124
+
75
125
  test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
76
126
  const { data, rows } = memData();
77
127
  rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
@@ -162,9 +212,12 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
162
212
  // Legacy rows written before migration 039 → projection columns absent/NULL.
163
213
  rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
164
214
  rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
215
+ // A legacy row parked at a LIVE escalation when migration 040 lands: escalation_open is NULL and the
216
+ // poller won't re-write it while it stays parked, so backfill MUST reproject it to 1 (issue #272).
217
+ rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1, escalation_user_task_key: "ut-9", escalation_question: "which base?" });
165
218
 
166
219
  const stamped = await backfillFeatureStages(data);
167
- assertEquals(stamped, 2);
220
+ assertEquals(stamped, 3);
168
221
 
169
222
  const terminal = rows.find((r) => r.feature_key === "o/r#8");
170
223
  assertEquals(terminal.stage, "Done");
@@ -176,19 +229,24 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
176
229
  assertEquals(live.stage_state, null);
177
230
  assertEquals(live.stage_skipped, "Converging Merging");
178
231
  assertEquals(live.list_bucket, "active");
232
+ assertEquals(live.escalation_open, 0);
233
+
234
+ const escalated = rows.find((r) => r.feature_key === "o/r#esc");
235
+ assertEquals(escalated.escalation_open, 1);
179
236
  });
180
237
 
181
238
  test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
182
239
  const { data, rows } = memData();
183
240
  // One legacy row (no projection) + one already-projected row (gateway kept it fresh).
184
241
  rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
185
- rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
242
+ rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", escalation_open: 0, updated_at: "0" });
186
243
 
187
244
  const stamped = await backfillFeatureStages(data);
188
245
  // Only the legacy row is stamped; the already-projected row is skipped.
189
246
  assertEquals(stamped, 1);
190
247
  const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
191
248
  assertEquals(legacy.stage, "Done");
249
+ assertEquals(legacy.escalation_open, 0);
192
250
  // The already-projected row was not re-written (its sentinel updated_at is untouched).
193
251
  const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
194
252
  assertEquals(fresh.updated_at, "0");
@@ -7,20 +7,27 @@ import { test } from "node:test";
7
7
  import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "#test-assert";
8
8
  import {
9
9
  type CommandResult,
10
+ cmpVersion,
10
11
  DEFAULT_ATTEMPT_TIMEOUT_MS,
11
12
  DEFAULT_EVERY_MS,
12
13
  DEFAULT_TIMEOUT_MS,
13
14
  defaultProbeExec,
15
+ type GithubRelease,
14
16
  type HttpResponse,
17
+ makeCapabilityFallback,
15
18
  MAX_EVERY_MS,
19
+ matchCapability,
16
20
  matchCommand,
17
21
  matchGithubCheck,
18
22
  matchHttp,
19
23
  matchNpm,
20
24
  msToIsoDuration,
25
+ newestPublishedVersion,
21
26
  nextDelay,
22
27
  normalizePoll,
23
28
  parseProbe,
29
+ parseReleases,
30
+ parseReleasesTarget,
24
31
  parseRepoRef,
25
32
  probeBudgetMs,
26
33
  probeOnce,
@@ -102,6 +109,186 @@ test("parseProbe: accepts a declared credentialEnv (http) and parses nested matc
102
109
  assertEquals(p.poll?.backoff, "fixed");
103
110
  });
104
111
 
112
+ // ── parseProbe: capability kind (#274 — required capabilityRef + package, fail loudly on blank) ──
113
+ test("parseProbe: a capability probe with a blank capabilityRef throws (a never-resolvable edge must fail loudly)", () => {
114
+ assertThrows(
115
+ () => parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: { package: "@nanobpm/urban" } }),
116
+ Error,
117
+ "capabilityRef' is required",
118
+ );
119
+ });
120
+
121
+ test("parseProbe: a capability probe with a blank package throws (provenance is per-package scoped)", () => {
122
+ assertThrows(
123
+ () => parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: { capabilityRef: "#274" } }),
124
+ Error,
125
+ "package' is required",
126
+ );
127
+ });
128
+
129
+ test("parseProbe: a capability probe whose capabilityRef carries no numeric id throws (never resolvable)", () => {
130
+ assertThrows(
131
+ () =>
132
+ parseProbe({
133
+ kind: "capability",
134
+ target: "github-releases:nanobpm/nano-ide",
135
+ match: { capabilityRef: "nano-ide#", package: "@nanobpm/urban" },
136
+ }),
137
+ Error,
138
+ "must carry a",
139
+ );
140
+ });
141
+
142
+ test("parseProbe: a valid capability probe round-trips its required match fields", () => {
143
+ const p = parseProbe({
144
+ kind: "capability",
145
+ target: "github-releases:nanobpm/nano-ide",
146
+ match: { capabilityRef: "nano-ide#274", package: "@nanobpm/urban", verifyCommand: "node -e 0" },
147
+ });
148
+ assertEquals(p.kind, "capability");
149
+ assertEquals(p.match?.capabilityRef, "nano-ide#274");
150
+ assertEquals(p.match?.package, "@nanobpm/urban");
151
+ assertEquals(p.match?.verifyCommand, "node -e 0");
152
+ assertEquals(p.onTimeout, "escalate");
153
+ });
154
+
155
+ // ── matchCapability: the deterministic lowest-version-per-package resolver (#274 Gap A) ──────────
156
+ const rel = (tag: string, refs: number[]): GithubRelease => ({
157
+ tag,
158
+ body: `Automated release of \`${tag}\`.\n\n## Provenance\n${refs.map((n) => `- #${n}`).join("\n")}\n`,
159
+ });
160
+ const capMatch = { capabilityRef: "nano-ide#274", package: "@nanobpm/urban" };
161
+
162
+ test("matchCapability: a capability in exactly one release resolves that version and binds resolvedArtifact", () => {
163
+ const res = matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [273, 274, 275])]);
164
+ assert(res.ready);
165
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0");
166
+ });
167
+
168
+ test("matchCapability: present in multiple releases resolves the LOWEST version (first-carries)", () => {
169
+ const releases = [
170
+ rel("@nanobpm/urban@0.60.0", [274]),
171
+ rel("@nanobpm/urban@0.54.0", [274]),
172
+ rel("@nanobpm/urban@0.9.0", [274]), // 0.9 < 0.54 numerically — cmpVersion, not lexicographic
173
+ ];
174
+ const res = matchCapability(capMatch, releases);
175
+ assert(res.ready);
176
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.9.0");
177
+ });
178
+
179
+ test("matchCapability: an absent capability is not-ready (still waiting), never throws", () => {
180
+ const res = matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [200, 201])]);
181
+ assert(!res.ready);
182
+ assertEquals(res.bind, undefined);
183
+ });
184
+
185
+ test("matchCapability: the same #C in another package resolves ONLY within the named package", () => {
186
+ const releases = [
187
+ rel("@nanobpm/other@1.0.0", [274]), // same #274, wrong package — must be ignored
188
+ rel("@nanobpm/urban@0.55.0", [274]),
189
+ ];
190
+ const res = matchCapability(capMatch, releases);
191
+ assert(res.ready);
192
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.55.0");
193
+ });
194
+
195
+ test("matchCapability: a prefix ref (#27) never spuriously satisfies #274", () => {
196
+ assert(!matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [27])]).ready);
197
+ });
198
+
199
+ test("matchCapability: a malformed/empty releases list is not-ready and never throws", () => {
200
+ assert(!matchCapability(capMatch, []).ready);
201
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed rows exercise the tolerant guard.
202
+ assert(!matchCapability(capMatch, [{ tag: 123, body: null } as any, null as any]).ready);
203
+ });
204
+
205
+ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-ide#274'", () => {
206
+ const res = matchCapability({ capabilityRef: "#274", package: "@nanobpm/urban" }, [rel("@nanobpm/urban@1.2.3", [274])]);
207
+ assert(res.ready);
208
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
209
+ });
210
+
211
+ test("cmpVersion: numeric dotted compare (0.9 < 0.54 < 0.60), matching nano-ide publish.mjs", () => {
212
+ assert(cmpVersion("0.9.0", "0.54.0") < 0);
213
+ assert(cmpVersion("0.54.0", "0.60.0") < 0);
214
+ assertEquals(cmpVersion("1.2", "1.2.0"), 0);
215
+ });
216
+
217
+ test("newestPublishedVersion: picks the highest version of the named package only", () => {
218
+ const releases = [rel("@nanobpm/urban@0.9.0", []), rel("@nanobpm/urban@0.54.0", []), rel("@nanobpm/other@9.9.9", [])];
219
+ assertEquals(newestPublishedVersion("@nanobpm/urban", releases), "0.54.0");
220
+ assertEquals(newestPublishedVersion("@nanobpm/missing", releases), undefined);
221
+ });
222
+
223
+ test("parseReleases: reduces a gh api payload to {tag, body}; non-array input yields []", () => {
224
+ const parsed = parseReleases([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274" }, { nope: 1 }]);
225
+ assertEquals(parsed[0]?.tag, "@nanobpm/urban@0.54.0");
226
+ assertEquals(parseReleases("not-an-array").length, 0);
227
+ assertEquals(parseReleases(null).length, 0);
228
+ });
229
+
230
+ test("parseReleases: flattens the --paginate --slurp array-of-pages shape (>100 releases are seen)", () => {
231
+ const slurped = [
232
+ [{ tag_name: "@nanobpm/urban@0.54.0", body: "- #274" }],
233
+ [{ tag_name: "@nanobpm/urban@0.9.0", body: "- #274" }],
234
+ ];
235
+ const tags = parseReleases(slurped).map((r) => r.tag);
236
+ assertEquals(tags.includes("@nanobpm/urban@0.54.0"), true, "first page's release is seen");
237
+ assertEquals(tags.includes("@nanobpm/urban@0.9.0"), true, "a later page's release is seen too");
238
+ });
239
+
240
+ test("parseReleasesTarget: strips the optional github-releases: scheme, else passes owner/repo through", () => {
241
+ assertEquals(parseReleasesTarget("github-releases:nanobpm/nano-ide"), "nanobpm/nano-ide");
242
+ assertEquals(parseReleasesTarget("nanobpm/nano-ide"), "nanobpm/nano-ide");
243
+ });
244
+
245
+ // ── probeOnce capability dispatch + gated fallback (#274 decision 5) ─────────────────────────────
246
+ test("probeOnce capability: queries the repo's releases and binds the resolved artifact", async () => {
247
+ const cap: { cmd?: string } = {};
248
+ const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
249
+ const exec = stubExec({ command: { code: 0, stdout: payload, stderr: "" }, capture: cap });
250
+ const res = await probeOnce(parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: capMatch }), exec, {});
251
+ assert(res.ready);
252
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0");
253
+ assertStringIncludes(cap.cmd ?? "", "repos/nanobpm/nano-ide/releases");
254
+ });
255
+
256
+ test("probeOnce capability: a failed gh api call is not-ready (never throws)", async () => {
257
+ const exec = stubExec({ command: { code: 1, stdout: "", stderr: "boom" } });
258
+ const res = await probeOnce(parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: capMatch }), exec, {});
259
+ assert(!res.ready);
260
+ });
261
+
262
+ test("makeCapabilityFallback: null for a capability probe with no verifyCommand (deterministic-only)", async () => {
263
+ const probe = parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: capMatch });
264
+ const fb = makeCapabilityFallback(probe, stubExec({}), {});
265
+ assertEquals(await fb(), null);
266
+ });
267
+
268
+ test("makeCapabilityFallback: verifies the NEWEST version empirically and binds it when the verifier passes", async () => {
269
+ const payload = JSON.stringify([
270
+ { tag_name: "@nanobpm/urban@0.54.0", body: "no ref" },
271
+ { tag_name: "@nanobpm/urban@0.60.0", body: "no ref" },
272
+ ]);
273
+ const seen: string[] = [];
274
+ const exec: ProbeExec = {
275
+ async httpGet() {
276
+ return { status: 0, body: "" };
277
+ },
278
+ async run(command, env) {
279
+ seen.push(command);
280
+ if (command.includes("gh api")) return { code: 0, stdout: payload, stderr: "" };
281
+ // the verifier: assert the newest version was handed to it via the env
282
+ return { code: env.RESOLVED_VERSION === "0.60.0" ? 0 : 1, stdout: "", stderr: "" };
283
+ },
284
+ };
285
+ const probe = parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: { ...capMatch, verifyCommand: "verify.sh" } });
286
+ const res = await makeCapabilityFallback(probe, exec, {})();
287
+ assert(res?.ready);
288
+ assertEquals(res?.bind?.resolvedArtifact, "@nanobpm/urban@0.60.0");
289
+ assert(seen.some((c) => c === "verify.sh"), "the verifier command was run at the boundary");
290
+ });
291
+
105
292
  // ── matchers ────────────────────────────────────────────────────────────────────────────────
106
293
  test("matchHttp: any 2xx is ready by default; a 503 is not", () => {
107
294
  assert(matchHttp(undefined, { status: 204, body: "" }).ready);