@nanobpm/nano-workforce 0.97.0 → 0.98.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.98.0](https://github.com/nanobpm/nano-workforce/compare/v0.97.1...v0.98.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * host-orchestrated per-task capability barrier for epic dispatch ([#289](https://github.com/nanobpm/nano-workforce/issues/289)) ([#290](https://github.com/nanobpm/nano-workforce/issues/290)) ([bb2f4bf](https://github.com/nanobpm/nano-workforce/commit/bb2f4bffb04b63d3b43bdec762d37770d03b1efb)), closes [263/#274](https://github.com/nanobpm/nano-workforce/issues/274)
7
+
8
+ ## [0.97.1](https://github.com/nanobpm/nano-workforce/compare/v0.97.0...v0.97.1) (2026-08-19)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** give merge-blocked escalation a question + gw-escalated guard ([#329](https://github.com/nanobpm/nano-workforce/issues/329)) ([#331](https://github.com/nanobpm/nano-workforce/issues/331)) ([abb2952](https://github.com/nanobpm/nano-workforce/commit/abb2952b5ff5874ff928085167f4a0fd5f0580e5))
14
+
1
15
  # [0.97.0](https://github.com/nanobpm/nano-workforce/compare/v0.96.1...v0.97.0) (2026-08-19)
2
16
 
3
17
 
@@ -0,0 +1,156 @@
1
+ // Unit coverage for the cross-repo capability-edge helpers (app/capabilityNeed.ts, issue #289).
2
+ //
3
+ // The gate wiring is proven through the process; these tests pin the pure surface: tolerant need
4
+ // parsing + de-dupe, the handle → releases-repo derivation, the need → readiness-gate input mapping
5
+ // (reusing the #274 `capability` probe verbatim), the gate-key shape, and the late-bind prompt brief.
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals, assertStringIncludes, assertThrows } from "#test-assert";
8
+ import {
9
+ type CapabilityNeed,
10
+ capabilityGateKey,
11
+ capabilityNeedToProbeInput,
12
+ capabilityRefNumber,
13
+ capabilityReleasesRepo,
14
+ parseCapabilityNeed,
15
+ parseCapabilityNeeds,
16
+ renderResolvedDepsBrief,
17
+ UnresolvableCapabilityRefError,
18
+ } from "./capabilityNeed.ts";
19
+
20
+ // ── parseCapabilityNeed / parseCapabilityNeeds ───────────────────────────────────────────────────
21
+
22
+ test("parseCapabilityNeed: a well-formed need round-trips its fields", () => {
23
+ const need = parseCapabilityNeed({
24
+ capabilityRef: "nanobpm/nano-ide#274",
25
+ package: "@nanobpm/urban",
26
+ verifyCommand: "node -e 0",
27
+ });
28
+ assertEquals(need, {
29
+ capabilityRef: "nanobpm/nano-ide#274",
30
+ package: "@nanobpm/urban",
31
+ verifyCommand: "node -e 0",
32
+ });
33
+ });
34
+
35
+ test("parseCapabilityNeed: verifyCommand is dropped when blank (deterministic-only edge)", () => {
36
+ const need = parseCapabilityNeed({ capabilityRef: "#274", package: "@nanobpm/urban", verifyCommand: " " });
37
+ assertEquals(need, { capabilityRef: "#274", package: "@nanobpm/urban" });
38
+ });
39
+
40
+ test("parseCapabilityNeed: a blank capabilityRef or package is dropped (unusable, never throws)", () => {
41
+ assertEquals(parseCapabilityNeed({ capabilityRef: " ", package: "@nanobpm/urban" }), null);
42
+ assertEquals(parseCapabilityNeed({ capabilityRef: "#274", package: "" }), null);
43
+ assertEquals(parseCapabilityNeed(null), null);
44
+ assertEquals(parseCapabilityNeed("nope"), null);
45
+ });
46
+
47
+ test("parseCapabilityNeeds: drops malformed entries and de-dupes on capabilityRef@package", () => {
48
+ const needs = parseCapabilityNeeds([
49
+ { capabilityRef: "owner/repo#1", package: "@nanobpm/urban" },
50
+ { capabilityRef: "owner/repo#1", package: "@nanobpm/urban" }, // dup
51
+ { capabilityRef: "owner/repo#1", package: "@nanobpm/agentic" }, // different package, kept
52
+ { capabilityRef: " ", package: "@x" }, // malformed
53
+ "garbage",
54
+ ]);
55
+ assertEquals(needs.length, 2);
56
+ assertEquals(needs[0], { capabilityRef: "owner/repo#1", package: "@nanobpm/urban" });
57
+ assertEquals(needs[1], { capabilityRef: "owner/repo#1", package: "@nanobpm/agentic" });
58
+ });
59
+
60
+ test("parseCapabilityNeeds: a non-array is []", () => {
61
+ assertEquals(parseCapabilityNeeds(undefined), []);
62
+ assertEquals(parseCapabilityNeeds({}), []);
63
+ });
64
+
65
+ // ── capabilityRefNumber / capabilityReleasesRepo ─────────────────────────────────────────────────
66
+
67
+ test("capabilityRefNumber: extracts the trailing number from any handle form", () => {
68
+ assertEquals(capabilityRefNumber("nanobpm/nano-ide#274"), "274");
69
+ assertEquals(capabilityRefNumber("nano-ide#274"), "274");
70
+ assertEquals(capabilityRefNumber("#274"), "274");
71
+ assertEquals(capabilityRefNumber("274"), "274");
72
+ assertEquals(capabilityRefNumber("no-number-here"), undefined);
73
+ });
74
+
75
+ test("capabilityReleasesRepo: owner/repo prefix yields the releases source, bare/short forms yield undefined", () => {
76
+ assertEquals(capabilityReleasesRepo("nanobpm/nano-ide#274"), "nanobpm/nano-ide");
77
+ assertEquals(capabilityReleasesRepo("nano-ide#274"), undefined);
78
+ assertEquals(capabilityReleasesRepo("#274"), undefined);
79
+ assertEquals(capabilityReleasesRepo("a/b/c#1"), undefined);
80
+ });
81
+
82
+ // ── capabilityGateKey ────────────────────────────────────────────────────────────────────────────
83
+
84
+ test("capabilityGateKey: <planKey>:<taskId>:<capabilityRef>:<package> (stable across a resume)", () => {
85
+ assertEquals(
86
+ capabilityGateKey("owner/repo#289", "issue-289", "nanobpm/nano-ide#274", "@nanobpm/urban"),
87
+ "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban",
88
+ );
89
+ });
90
+
91
+ test("capabilityGateKey: same capabilityRef, different package → distinct keys (no gate collision)", () => {
92
+ const a = capabilityGateKey("owner/repo#289", "issue-289", "nanobpm/nano-ide#274", "@nanobpm/urban");
93
+ const b = capabilityGateKey("owner/repo#289", "issue-289", "nanobpm/nano-ide#274", "@nanobpm/urban-testkit");
94
+ assertEquals(a === b, false);
95
+ });
96
+
97
+ // ── capabilityNeedToProbeInput ───────────────────────────────────────────────────────────────────
98
+
99
+ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability probe input", () => {
100
+ const need: CapabilityNeed = {
101
+ capabilityRef: "nanobpm/nano-ide#274",
102
+ package: "@nanobpm/urban",
103
+ verifyCommand: "verify.sh",
104
+ };
105
+ const input = capabilityNeedToProbeInput(need, {
106
+ planKey: "owner/repo#289",
107
+ taskId: "issue-289",
108
+ probeTimeout: "PT12H",
109
+ });
110
+ assertEquals(input.gateKey, "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban");
111
+ assertEquals(input.probeTimeout, "PT12H");
112
+ assertEquals(input.onTimeout, "escalate");
113
+ assertEquals(input.probe.kind, "capability");
114
+ assertEquals(input.probe.target, "github-releases:nanobpm/nano-ide");
115
+ assertEquals(input.probe.onTimeout, "escalate");
116
+ assertEquals(input.probe.match?.capabilityRef, "nanobpm/nano-ide#274");
117
+ assertEquals(input.probe.match?.package, "@nanobpm/urban");
118
+ assertEquals(input.probe.match?.verifyCommand, "verify.sh");
119
+ });
120
+
121
+ test("capabilityNeedToProbeInput: omits verifyCommand when the need has none", () => {
122
+ const input = capabilityNeedToProbeInput(
123
+ { capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" },
124
+ { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
125
+ );
126
+ assertEquals(input.probe.match?.verifyCommand, undefined);
127
+ });
128
+
129
+ test("capabilityNeedToProbeInput: throws when the handle names no releases source (fail loudly)", () => {
130
+ assertThrows(
131
+ () =>
132
+ capabilityNeedToProbeInput(
133
+ { capabilityRef: "#274", package: "@nanobpm/urban" },
134
+ { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
135
+ ),
136
+ UnresolvableCapabilityRefError,
137
+ "names no owner/repo releases source",
138
+ );
139
+ });
140
+
141
+ // ── renderResolvedDepsBrief ──────────────────────────────────────────────────────────────────────
142
+
143
+ test("renderResolvedDepsBrief: empty list renders nothing (unconditional concatenation is safe)", () => {
144
+ assertEquals(renderResolvedDepsBrief([]), "");
145
+ });
146
+
147
+ test("renderResolvedDepsBrief: pins each capabilityRef → resolvedArtifact", () => {
148
+ const brief = renderResolvedDepsBrief([
149
+ { capabilityRef: "nanobpm/nano-ide#274", resolvedArtifact: "@nanobpm/urban@0.54.0" },
150
+ { capabilityRef: "nanobpm/nano-ide#280", resolvedArtifact: "@nanobpm/agentic@0.9.0" },
151
+ ]);
152
+ assert(brief.startsWith("\n\n---\n"));
153
+ assertStringIncludes(brief, "`nanobpm/nano-ide#274` → `@nanobpm/urban@0.54.0`");
154
+ assertStringIncludes(brief, "`nanobpm/nano-ide#280` → `@nanobpm/agentic@0.9.0`");
155
+ assertStringIncludes(brief, "pin these EXACT versions");
156
+ });
@@ -0,0 +1,200 @@
1
+ // nano-workforce — the cross-repo CAPABILITY EDGE authoring/plumbing helpers (issue #289).
2
+ //
3
+ // This is the pure, I/O-free half of the "consumer readiness edge" (ADR 0001 §4, #263): a plan task
4
+ // declares that it consumes an upstream capability C that ships as some `pkg@version` from ANOTHER
5
+ // repo, and must not start until C first ships. The planner emits this as `RecordPlanTask.needs[]`
6
+ // (plan-fanout.bpmn); `pr.record-plan` levelizes it to `plan_task_needs` and `pr.select-wave` reads
7
+ // it back to gate the task before dispatch.
8
+ //
9
+ // The gate itself is the EXISTING durable `readiness-gate` process (#258) driven with a `capability`
10
+ // {@link ReadinessProbe} (#274) — this module never re-implements the wait or the matcher; it only
11
+ // (a) normalises a raw planner need, (b) maps a need to the gate's `ReadinessProbeIn`, and (c) renders
12
+ // the late-bound "resolved-dependencies" prompt brief that pins each `pkg@version` into the agent's
13
+ // context (mirroring `renderBaseBranchBrief` in app/plan.ts). Pure + unit-testable — no network, no DB.
14
+ import type { OnTimeout, ProbeMatch, ReadinessProbe } from "./readiness.ts";
15
+
16
+ /** A single cross-repo capability dependency declared on a plan task — the stable authoring contract
17
+ * (#263: declare the HANDLE, never a version). Mirrors the `CapabilityNeed` `nano:shape` in
18
+ * plan-fanout.bpmn so it is typed end to end. */
19
+ export interface CapabilityNeed {
20
+ /** The upstream capability handle — `owner/repo#NNN`, `repo#NNN`, or the bare `#NNN`. It carries
21
+ * the provenance issue/PR ref the resolved version must reference; the leading `owner/repo` (when
22
+ * present) also names the releases source repo the gate polls (see {@link capabilityReleasesRepo}). */
23
+ readonly capabilityRef: string;
24
+ /** The artifact whose GitHub Releases carry the publish provenance (e.g. `@nanobpm/urban`).
25
+ * Per-package scoped — a sibling package's provenance can never resolve this edge. */
26
+ readonly package: string;
27
+ /** OPTIONAL gated empirical verifier (#274 decision 5): run ONCE at the gate boundary against the
28
+ * newest published `package` version when deterministic provenance resolved nothing. Left unset,
29
+ * the edge is deterministic-provenance-only. */
30
+ readonly verifyCommand?: string;
31
+ }
32
+
33
+ /** The gate's input envelope — mirrors the `ReadinessProbeIn` `nano:shape` in readiness-gate.bpmn
34
+ * (`gateKey`, `probeTimeout`, optional `onTimeout`, nested `probe`). Kept local (not imported) so this
35
+ * pure module never depends on the generated worker-io types. */
36
+ export interface ReadinessProbeInput {
37
+ readonly gateKey: string;
38
+ readonly probeTimeout: string;
39
+ readonly onTimeout?: OnTimeout;
40
+ readonly probe: ReadinessProbe;
41
+ }
42
+
43
+ /** One resolved capability edge — the late-bound fact the gate hands back (`capabilityRef →
44
+ * resolvedArtifact`, e.g. `nanobpm/nano-ide#274 → @nanobpm/urban@0.54.0`). Fanned in over a task's
45
+ * needs and rendered into the implementation prompt by {@link renderResolvedDepsBrief}. */
46
+ export interface ResolvedCapability {
47
+ readonly capabilityRef: string;
48
+ readonly resolvedArtifact: string;
49
+ }
50
+
51
+ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
52
+
53
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
54
+ typeof v === "object" && v !== null && !Array.isArray(v);
55
+
56
+ /** Normalise ONE raw planner-emitted need into a {@link CapabilityNeed}, or `null` when it is unusable
57
+ * (blank `capabilityRef` or `package`). A malformed need is DROPPED rather than throwing so one bad
58
+ * entry can never fail the whole plan record — mirroring `record-plan`'s tolerant task normalisation. */
59
+ export function parseCapabilityNeed(raw: unknown): CapabilityNeed | null {
60
+ if (!isRecord(raw)) return null;
61
+ const capabilityRef = str(raw.capabilityRef).trim();
62
+ const pkg = str(raw.package).trim();
63
+ if (capabilityRef === "" || pkg === "") return null;
64
+ const verifyCommand = str(raw.verifyCommand).trim();
65
+ return { capabilityRef, package: pkg, ...(verifyCommand === "" ? {} : { verifyCommand }) };
66
+ }
67
+
68
+ /** Normalise a whole `needs[]` array, dropping malformed entries and de-duplicating on
69
+ * `capabilityRef@package` (a task that lists the same edge twice must gate on it once). */
70
+ export function parseCapabilityNeeds(raw: unknown): CapabilityNeed[] {
71
+ if (!Array.isArray(raw)) return [];
72
+ const out: CapabilityNeed[] = [];
73
+ const seen = new Set<string>();
74
+ for (const entry of raw) {
75
+ const need = parseCapabilityNeed(entry);
76
+ if (!need) continue;
77
+ const key = `${need.capabilityRef}\u0000${need.package}`;
78
+ if (seen.has(key)) continue;
79
+ seen.add(key);
80
+ out.push(need);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** The bare numeric id of a capability handle — `nanobpm/nano-ide#274`, `nano-ide#274`, `#274`, and a
86
+ * naked `274` all normalise to `274`. Returns `undefined` for a handle with no number (never
87
+ * resolvable). Kept in lockstep with `capabilityNumber` in app/readiness.ts (the matcher's predicate). */
88
+ export function capabilityRefNumber(capabilityRef: string): string | undefined {
89
+ const m = capabilityRef.match(/(\d+)\s*$/);
90
+ return m ? m[1] : undefined;
91
+ }
92
+
93
+ /** The releases-source repo a capability handle names, as `owner/repo`, or `undefined` when the handle
94
+ * carries no `owner/repo` prefix (a bare `#274` / `repo#274`). The gate polls THIS repo's GitHub
95
+ * Releases for the package's publish provenance, so a handle without it cannot be gated deterministically
96
+ * — the planner is taught to always write the full `owner/repo#NNN` (resources/prompts/plan.md). */
97
+ export function capabilityReleasesRepo(capabilityRef: string): string | undefined {
98
+ const beforeHash = capabilityRef.split("#")[0]?.trim() ?? "";
99
+ const m = beforeHash.match(/^([^/\s]+\/[^/\s]+)$/);
100
+ return m ? m[1] : undefined;
101
+ }
102
+
103
+ /** Raised when a capability need cannot be turned into a gateable probe because its handle names no
104
+ * `owner/repo` releases source (e.g. a bare `#274`). Fail LOUDLY at wiring time — a silently
105
+ * un-pollable edge would only surface as a spurious gate timeout much later. */
106
+ export class UnresolvableCapabilityRefError extends Error {
107
+ readonly capabilityRef: string;
108
+ constructor(capabilityRef: string) {
109
+ super(
110
+ `capability need '${capabilityRef}': the handle names no owner/repo releases source. ` +
111
+ `Declare the full 'owner/repo#NNN' handle so the gate knows which repo's Releases to poll.`,
112
+ );
113
+ this.name = "UnresolvableCapabilityRefError";
114
+ this.capabilityRef = capabilityRef;
115
+ }
116
+ }
117
+
118
+ /** The gate's correlation/idempotency key for a task's capability edge:
119
+ * `<planKey>:<taskId>:<capabilityRef>:<package>` (issue #289 design §2). Stable across a resume so a
120
+ * re-dispatched agent re-attaches to the same gate. `package` is part of the identity because a task
121
+ * can legitimately declare the SAME `capabilityRef` for different packages (`parseCapabilityNeeds`
122
+ * de-dupes on `capabilityRef+package`; `plan_task_needs`' PK includes `package`) — each
123
+ * `(capabilityRef, package)` edge is a distinct need with its own gate row, so the key must be 1:1
124
+ * with the need or two edges would collide on one gate row and only one could ever resolve. */
125
+ export function capabilityGateKey(
126
+ planKey: string,
127
+ taskId: string,
128
+ capabilityRef: string,
129
+ pkg: string,
130
+ ): string {
131
+ return `${planKey}:${taskId}:${capabilityRef}:${pkg}`;
132
+ }
133
+
134
+ /** The message name plan-fanout's per-task capability barrier (`wait-caps-resolved`) subscribes to and
135
+ * the host publishes to release the gated task once every one of its capability needs has resolved
136
+ * (issue #289 §2/§3). The single source of truth for the string shared by the BPMN subscription and
137
+ * the host publisher, mirroring `WAVE_MERGED_MESSAGE`. */
138
+ export const CAPS_RESOLVED_MESSAGE = "caps-resolved";
139
+
140
+ /** The per-TASK barrier correlation key `<planKey>:<taskId>` the `wait-caps-resolved` catch binds
141
+ * and the host publishes `caps-resolved` on (issue #289 §2). Distinct from {@link capabilityGateKey}
142
+ * (which is per-NEED): a task fans in ALL its needs, so its barrier releases ONCE, keyed on the task —
143
+ * not once per need. Stable across a resume so a re-dispatched fan-out re-attaches to the same barrier. */
144
+ export function capabilityTaskBarrierKey(planKey: string, taskId: string): string {
145
+ return `${planKey}:${taskId}`;
146
+ }
147
+
148
+ /** Map a normalised {@link CapabilityNeed} to the EXISTING `readiness-gate` process input (#289 §2):
149
+ * a `capability` probe scanning the handle's releases source for the package's provenance, bounded by
150
+ * `probeTimeout` and escalating on timeout. Reuses the gate + matcher verbatim (derivation over
151
+ * duplication) — this only shapes the descriptor. Throws {@link UnresolvableCapabilityRefError} when
152
+ * the handle names no releases source. */
153
+ export function capabilityNeedToProbeInput(
154
+ need: CapabilityNeed,
155
+ opts: { planKey: string; taskId: string; probeTimeout: string },
156
+ ): ReadinessProbeInput {
157
+ const repo = capabilityReleasesRepo(need.capabilityRef);
158
+ if (!repo) throw new UnresolvableCapabilityRefError(need.capabilityRef);
159
+ const match: ProbeMatch = {
160
+ capabilityRef: need.capabilityRef,
161
+ package: need.package,
162
+ ...(need.verifyCommand ? { verifyCommand: need.verifyCommand } : {}),
163
+ };
164
+ const probe: ReadinessProbe = {
165
+ kind: "capability",
166
+ target: `github-releases:${repo}`,
167
+ match,
168
+ onTimeout: "escalate",
169
+ };
170
+ return {
171
+ gateKey: capabilityGateKey(opts.planKey, opts.taskId, need.capabilityRef, need.package),
172
+ probeTimeout: opts.probeTimeout,
173
+ onTimeout: "escalate",
174
+ probe,
175
+ };
176
+ }
177
+
178
+ /** Render the per-task "resolved-dependencies" brief appended to an implementer agent's prompt once
179
+ * every capability gate on the task has resolved (#289 §3), mirroring `renderBaseBranchBrief`. It pins
180
+ * each `capabilityRef → pkg@version` the gate discovered so the agent installs EXACTLY that version —
181
+ * no pre-named version, no human. Returns "" for an empty list so callers can concatenate unconditionally. */
182
+ export function renderResolvedDepsBrief(resolved: readonly ResolvedCapability[]): string {
183
+ if (resolved.length === 0) return "";
184
+ const lines = [
185
+ "",
186
+ "",
187
+ "---",
188
+ "",
189
+ "**Resolved cross-repo dependencies (authoritative — pin these EXACT versions):**",
190
+ "",
191
+ "An upstream capability this task depends on has shipped. Install/pin exactly the resolved",
192
+ "`package@version` below — do NOT bump, float, or re-resolve it:",
193
+ "",
194
+ ];
195
+ for (const r of resolved) lines.push(`- \`${r.capabilityRef}\` → \`${r.resolvedArtifact}\``);
196
+ lines.push("");
197
+ lines.push("These versions first carry the capability your slice consumes; a newer or older version");
198
+ lines.push("may not. Treat them as the contract you build against.");
199
+ return lines.join("\n");
200
+ }
@@ -0,0 +1,39 @@
1
+ // Unit coverage for the capability-barrier bounded-wait duration policy (#289). The value is baked
2
+ // into every plan-fanout instance's `capsWaitTimeout` process variable and evaluated by the
3
+ // `wait-caps-timeout` timer arm on the `wait-caps-resolved` event-based gateway, so a malformed
4
+ // operator env must never deploy an uninterpretable `<bpmn:timeDuration>` — it falls back to the
5
+ // default instead. Run with `node --test`.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
10
+
11
+ test("capsWaitTimeout: blank / absent / malformed → default", () => {
12
+ assert.equal(capsWaitTimeout(undefined), DEFAULT_CAPS_WAIT_TIMEOUT);
13
+ assert.equal(capsWaitTimeout(""), DEFAULT_CAPS_WAIT_TIMEOUT);
14
+ assert.equal(capsWaitTimeout(" "), DEFAULT_CAPS_WAIT_TIMEOUT);
15
+ assert.equal(capsWaitTimeout("2h"), DEFAULT_CAPS_WAIT_TIMEOUT); // missing leading P/T
16
+ assert.equal(capsWaitTimeout("P"), DEFAULT_CAPS_WAIT_TIMEOUT); // no component
17
+ assert.equal(capsWaitTimeout("PT"), DEFAULT_CAPS_WAIT_TIMEOUT); // T with no time part
18
+ assert.equal(capsWaitTimeout("garbage"), DEFAULT_CAPS_WAIT_TIMEOUT);
19
+ });
20
+
21
+ test("capsWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
22
+ assert.equal(capsWaitTimeout("PT30M"), "PT30M");
23
+ assert.equal(capsWaitTimeout("pt2h"), "PT2H");
24
+ assert.equal(capsWaitTimeout("P2D"), "P2D");
25
+ assert.equal(capsWaitTimeout(" pt15m "), "PT15M");
26
+ });
27
+
28
+ test("capsWaitTimeout: an explicit fallback is honoured for a bad value", () => {
29
+ assert.equal(capsWaitTimeout("nope", "PT10M"), "PT10M");
30
+ assert.equal(capsWaitTimeout("PT45M", "PT10M"), "PT45M");
31
+ });
32
+
33
+ test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
34
+ // Validate the default against the grammar with a *distinct* fallback: if the default were
35
+ // malformed it would fall through to the sentinel, so equality to itself proves it parses.
36
+ const sentinel = "PT1S";
37
+ assert.notEqual(DEFAULT_CAPS_WAIT_TIMEOUT, sentinel);
38
+ assert.equal(capsWaitTimeout(DEFAULT_CAPS_WAIT_TIMEOUT, sentinel), DEFAULT_CAPS_WAIT_TIMEOUT);
39
+ });
@@ -0,0 +1,33 @@
1
+ // Capability-barrier bounded-wait policy (issue #289), kept as a pure module (no env, no I/O) so it
2
+ // is trivially testable — mirrors app/escalationSla.ts and app/reviewWait.ts. `app/plan.ts` seeds the
3
+ // validated `capsWaitTimeout` process variable at submit; the `wait-caps-timeout` timer catch on the
4
+ // `wait-caps-resolved` event-based gateway in plan-fanout.bpmn evaluates it at timer creation
5
+ // (FEEL-expression timer durations, engine-native).
6
+ //
7
+ // This models liveness IN the process: a task parked at the `wait-caps-resolved` capability barrier
8
+ // can no longer hang forever when a declared cross-repo capability never resolves — most acutely when
9
+ // the handle names no `owner/repo` releases source (an `UnresolvableCapabilityRefError`), so the host
10
+ // reconciler can never start a readiness-gate and never publishes `caps-resolved`. When this bound
11
+ // elapses the event-based gateway's timer arm fires and the token routes to the existing
12
+ // `feature-escalation` operator user task — a bounded wait + operator escalation, not a poller-side
13
+ // watchdog and not a silent wedge.
14
+
15
+ import { isoDuration } from "./reviewWait.ts";
16
+
17
+ /** Default capability-barrier bound (ISO-8601 duration): how long a task may park at
18
+ * `wait-caps-resolved` waiting for every declared cross-repo capability to ship before the timer arm
19
+ * fires and the token escalates to an operator. A day is generous for a genuine cross-team dependency
20
+ * to publish while still bounding the wait — and it decisively unwedges a permanently-unresolvable
21
+ * capability handle, which would otherwise never resolve at all. */
22
+ export const DEFAULT_CAPS_WAIT_TIMEOUT = "P1D";
23
+
24
+ /** Validate the operator-supplied capability-barrier bound (env `NANO_CAPS_WAIT_TIMEOUT`, ISO-8601
25
+ * duration), falling back to {@link DEFAULT_CAPS_WAIT_TIMEOUT} when absent, blank, or malformed — a
26
+ * bad env value must never deploy an uninterpretable timer expression. Derives its validation from
27
+ * the single canonical {@link isoDuration}. */
28
+ export function capsWaitTimeout(
29
+ raw: string | undefined,
30
+ def: string = DEFAULT_CAPS_WAIT_TIMEOUT,
31
+ ): string {
32
+ return isoDuration(raw, def);
33
+ }
package/app/contracts.ts CHANGED
@@ -208,6 +208,14 @@ export const ENV_CONTRACTS = {
208
208
  "SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.",
209
209
  default: "PT2H",
210
210
  },
211
+ NANO_CAPS_WAIT_TIMEOUT: {
212
+ category: "env",
213
+ name: "NANO_CAPS_WAIT_TIMEOUT",
214
+ owner: "app/plan.ts",
215
+ semantics:
216
+ "Bounded wait (FEEL/ISO-8601 duration) a plan-fanout task may park at the wait-caps-resolved capability barrier before the event-based gateway's timer arm fires and it escalates to an operator. Bounds a permanently-unresolvable capability handle (UnresolvableCapabilityRefError) so it can never silently wedge the epic. A malformed value falls back to the default.",
217
+ default: "P1D",
218
+ },
211
219
  NANO_READINESS_POLL_TIMEOUT: {
212
220
  category: "env",
213
221
  name: "NANO_READINESS_POLL_TIMEOUT",
@@ -0,0 +1,140 @@
1
+ // Regression guard for the question-less merge escalation defect (issue #329).
2
+ //
3
+ // The merge loop (`resources/processes/merge-loop.bpmn`) raised escalations with NO question
4
+ // whenever a PR was blocked by anything other than a merge conflict: `merge-esc-attempt` called
5
+ // `pr.persist-escalation` with no `question`/`status` ioMapping, and its output flowed
6
+ // UNCONDITIONALLY into `wait-merge-answer`. Two coupled defects fell out of that:
7
+ //
8
+ // 1. A blank question surfaced on the merge-driving inbox — the human was asked to answer but
9
+ // told nothing (observed live on nano-ide PR #354).
10
+ // 2. Per ADR 0002 §1 a blank question is a NON-escalation: `pr.persist-escalation` opens no row
11
+ // and returns `escalated:false`. The convergence loop honours this via a `gw-escalated`
12
+ // branch; the merge loop had none, so a question-less job still parked a dead
13
+ // `wait-merge-answer` with nothing for a human to answer.
14
+ //
15
+ // The fix (mirroring the convergence loop): give `merge-esc-attempt` a human-actionable
16
+ // `status`/`question` that distinguishes its four trigger conditions, and add a `gw-merge-escalated`
17
+ // guard so a `persist-escalation` returning `escalated:false` re-enters the loop (re-arms the
18
+ // poller) instead of parking a dead wait.
19
+ //
20
+ // These are pure text assertions over the committed BPMN (no engine), matching the repo's
21
+ // lightweight model-guard style (see mergeRebaseArm.test.ts, mergeEscalationUserTask.test.ts).
22
+
23
+ import { test } from "node:test";
24
+ import { assert, assertStringIncludes } from "#test-assert";
25
+ import { readFileSync } from "node:fs";
26
+
27
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
28
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
29
+ const flat = bpmn.replace(/\s+/g, " ");
30
+
31
+ function flowHasId(id: string, source: string, target: string): boolean {
32
+ const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*(?:/>|>)`));
33
+ if (!m) return false;
34
+ const tag = m[0];
35
+ return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
36
+ }
37
+
38
+ function gatewayDefault(id: string, def: string): boolean {
39
+ const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
40
+ if (!m) return false;
41
+ return m[0].includes(`default="${def}"`);
42
+ }
43
+
44
+ // The <serviceTask> element for merge-esc-attempt, including its ioMapping. Unescape XML entities so
45
+ // FEEL string literals (authored as `&#34;ready&#34;` inside the attribute) read naturally here.
46
+ const escAttemptRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="merge-esc-attempt"[\s\S]*?<\/bpmn:serviceTask>/);
47
+ const escAttempt = escAttemptRaw
48
+ ? [escAttemptRaw[0].replace(/&#34;/g, '"').replace(/&amp;/g, "&").replace(/&#10;/g, "\n")]
49
+ : null;
50
+
51
+ test("merge-esc-attempt carries a non-blank, human-actionable status + question", () => {
52
+ assert(escAttempt, "merge-esc-attempt service task must exist");
53
+ const el = escAttempt![0];
54
+ // Mirror the merge-esc-conflict mapping style: an explicit `blocked` status…
55
+ assertStringIncludes(el, "<zeebe:ioMapping", "merge-esc-attempt must set an ioMapping (was absent — the #329 defect)");
56
+ assertStringIncludes(el, 'target="status"', "merge-esc-attempt must set a `status`");
57
+ assertStringIncludes(el, 'target="question"', "merge-esc-attempt must set a non-blank `question`");
58
+ // Tighten: assert the explicit `status` INPUT MAPPING sets blocked, not merely the substring
59
+ // `="blocked"` (which the FEEL question's `agentVerdict = "blocked"` comparison would also satisfy
60
+ // even if the status mapping were removed/changed). Match tolerant of attribute order/spacing: the
61
+ // file is XML and a formatter could reorder `source`/`target` within the tag.
62
+ const escInputs = el.match(/<zeebe:input\b[^>]*\/>/g) ?? [];
63
+ const setsBlockedStatus = escInputs.some(
64
+ (t) => t.includes('target="status"') && t.includes('source="="blocked""'),
65
+ );
66
+ assert(setsBlockedStatus, "the explicit `status` input mapping must set `blocked`");
67
+ });
68
+
69
+ test("arm-merge clears the prior verdict `status` so a stale `blocked` cannot misclassify the CI-fix SLA escalation", () => {
70
+ // merge-esc-attempt captures `agentVerdict = status` to split its CI could-not-fix vs SLA question
71
+ // arms. On the SLA boundary path (`f_ci_sla`) no worker sets a fresh `status`, and every escalation
72
+ // task overwrites `status = "blocked"` (merge-esc-attempt line 194, merge-esc-conflict line 174).
73
+ // Without a reset, a retry after any prior escalation re-enters fix-ci with `status` still
74
+ // "blocked", so an SLA timeout would render the wrong ("could not fix") question. arm-merge is the
75
+ // single loop hub every fix-ci entry passes through, so clearing `status` there (to null) each
76
+ // iteration guarantees a genuine SLA reads no stale verdict. Nothing between arm-merge and the next
77
+ // verdict-setter (fix-ci/rebase) reads `status`, so the reset is safe.
78
+ const armRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="arm-merge"[\s\S]*?<\/bpmn:serviceTask>/);
79
+ assert(armRaw, "arm-merge service task must exist");
80
+ const armOutputs = armRaw![0].match(/<zeebe:output\b[^>]*\/>/g) ?? [];
81
+ const clearsStatus = armOutputs.some(
82
+ (t) => t.includes('target="status"') && t.includes('source="=null"'),
83
+ );
84
+ assert(clearsStatus, "arm-merge must reset `status` to null each loop iteration so a stale `blocked` cannot misclassify the SLA escalation");
85
+ });
86
+
87
+ test("the question distinguishes all four blocked/SLA triggers rather than a single generic string", () => {
88
+ // Four flows route into merge-esc-attempt — the gate `blocked` default, CI could-not-fix,
89
+ // rebase could-not-resolve, and the CI-fix SLA. Each is a legitimately different escalation and
90
+ // the question must explain which one fired.
91
+ assert(escAttempt, "merge-esc-attempt service task must exist");
92
+ const el = escAttempt![0];
93
+ // gate blocked (gw-merge default): distinguishes on the `ready` mergeState + surfaces mergeStatus.
94
+ assertStringIncludes(el, 'mergeState = "ready"', "must branch on the gate-blocked (ready) trigger");
95
+ assertStringIncludes(el, "mergeStatus", "the gate-blocked question must surface the merge result");
96
+ // rebase could-not-resolve (conflict arm).
97
+ assertStringIncludes(el, 'mergeState = "conflict"', "must branch on the rebase (conflict) trigger");
98
+ // CI could-not-fix vs CI SLA both arrive with mergeState = blocked — split on the agent verdict,
99
+ // captured into a dedicated `agentVerdict` binding so the escalation-classification `status =
100
+ // "blocked"` override in the SAME ioMapping cannot make the SLA branch unreachable (issue #329
101
+ // review). Assert the question branches on that binding, not on the overwritten `status`.
102
+ assertStringIncludes(el, "agentVerdict", "must capture the agent verdict into a dedicated binding");
103
+ assertStringIncludes(el, 'agentVerdict = "blocked"', "must branch CI could-not-fix vs SLA on the agent verdict binding, not the overwritten status");
104
+ });
105
+
106
+ test("a gw-merge-escalated guard honours persist-escalation's escalated:false (mirrors the convergence loop)", () => {
107
+ // The escalation output no longer flows UNCONDITIONALLY into the durable answer wait: it passes
108
+ // through a gateway that reads the worker's `escalated` output.
109
+ assert(
110
+ flowHasId("f_m_escA", "merge-esc-attempt", "gw-merge-escalated"),
111
+ "merge-esc-attempt must route through gw-merge-escalated, not straight to wait-merge-answer",
112
+ );
113
+ // escalated:true → park the native user task for a human to answer.
114
+ assert(
115
+ flowHasId("f_m_escWait", "gw-merge-escalated", "wait-merge-answer"),
116
+ "gw-merge-escalated → wait-merge-answer (escalated) missing",
117
+ );
118
+ const escWait = flat.match(/<bpmn:sequenceFlow[^>]*id="f_m_escWait"[\s\S]*?<\/bpmn:sequenceFlow>/);
119
+ assert(escWait, "f_m_escWait flow missing");
120
+ assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
121
+ // escalated:false (a non-escalation, e.g. a blank question) → re-enter the loop, NOT a dead wait.
122
+ assert(
123
+ gatewayDefault("gw-merge-escalated", "f_m_escReenter"),
124
+ "gw-merge-escalated must default to f_m_escReenter (re-enter, not park)",
125
+ );
126
+ assert(
127
+ flowHasId("f_m_escReenter", "gw-merge-escalated", "arm-merge"),
128
+ "f_m_escReenter must re-arm the merge poller instead of parking a dead wait-merge-answer",
129
+ );
130
+ });
131
+
132
+ test("regression: a question-less escalation can no longer park a dead wait-merge-answer", () => {
133
+ // The exact #329 wedge: `merge-esc-attempt → wait-merge-answer` as a direct, unconditional edge.
134
+ // It must be gone — the only path into the answer wait from the attempt arm is now guarded by
135
+ // `escalated = true`.
136
+ assert(
137
+ !flowHasId("f_m_escA", "merge-esc-attempt", "wait-merge-answer"),
138
+ "merge-esc-attempt must NOT flow directly into wait-merge-answer (the #329 dead-wait defect)",
139
+ );
140
+ });