@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.
@@ -204,7 +204,14 @@ Write a JSON object of **result variables** to the file named by the
204
204
  "id": "short-stable-slug",
205
205
  "title": "One-line summary of the slice",
206
206
  "prompt": "Full, self-contained instructions for the implementing agent: what to build, where, acceptance criteria.",
207
- "dependsOn": ["id-of-a-task-this-one-builds-on"]
207
+ "dependsOn": ["id-of-a-task-this-one-builds-on"],
208
+ "needs": [
209
+ {
210
+ "capabilityRef": "owner/repo#274",
211
+ "package": "@nanobpm/urban",
212
+ "verifyCommand": "optional shell probe, exit 0 == capability present"
213
+ }
214
+ ]
208
215
  }
209
216
  ]
210
217
  }
@@ -223,6 +230,27 @@ Rules:
223
230
  sub-issue tasks (Step 0), derive `dependsOn` from any `Depends-on: #N` /
224
231
  `Blocked by #N` directive in the sub-issue body (mapping each prerequisite `#M`
225
232
  to `issue-M`) — otherwise leave it empty.
233
+ - `needs` — an optional array of **cross-repo capability edges**. Use it (and only
234
+ it) when a slice consumes an upstream capability that ships as a **published
235
+ package version from another repo** — e.g. it needs a new `@nanobpm/urban` API
236
+ that lands in some future release. This is different from `dependsOn` (which
237
+ orders slices *within this epic*): `needs` blocks the task until the capability
238
+ is **published**, then late-binds and pins the exact `package@version` into the
239
+ agent's prompt automatically. Each entry:
240
+ - `capabilityRef` — the **stable upstream handle**, never a version: the
241
+ issue/PR that introduces the capability, written as **`owner/repo#NNN`** (the
242
+ `owner/repo` names the repo whose GitHub Releases the gate polls for publish
243
+ provenance). Only set this when the sub-issue text explicitly references such
244
+ an upstream capability (a `Needs:`/`Consumes:` line, a "requires
245
+ `@pkg` ≥ the release carrying #NNN" note). **Do not** invent one, and **do not**
246
+ put a version here.
247
+ - `package` — the npm package whose releases carry that provenance (e.g.
248
+ `@nanobpm/urban`).
249
+ - `verifyCommand` — OPTIONAL. A shell probe (exit 0 == capability present) used
250
+ only as a gated empirical fallback when provenance is inconclusive; omit it for
251
+ the common deterministic case.
252
+ Omit `needs` entirely for the overwhelmingly common case of a slice with no
253
+ cross-repo capability dependency.
226
254
  - `prompt` — must stand alone: the implementing agent sees only this prompt plus
227
255
  the issue reference, not your reasoning.
228
256
  - Emit `{ "tasks": [] }` if the issue needs no code (and say why in a
@@ -0,0 +1,62 @@
1
+ // Unit coverage for `pr.caps-prepare` (issue #289). The worker derives the per-task capability
2
+ // barrier key + gate flag from the MI child's `task` (surfaced as `planKey`/`taskId`/`needs` job
3
+ // vars), which the model hoists into the child scope so the following gateway + catch can read them.
4
+ // The key MUST equal `capabilityTaskBarrierKey` (the host publishes `caps-resolved` on it), and the
5
+ // flag MUST reflect whether any VALID need survives the tolerant parse.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { capabilityTaskBarrierKey } from "../../app/capabilityNeed.ts";
9
+ import handler from "./worker.ts";
10
+
11
+ test("caps-prepare: a task with needs yields the barrier key and hasNeeds=true", async () => {
12
+ const out = await handler(
13
+ {
14
+ variables: {
15
+ planKey: "owner/repo#7",
16
+ taskId: "gap-a",
17
+ needs: [{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" }],
18
+ },
19
+ } as any,
20
+ {} as any,
21
+ );
22
+ assertEquals(out.capsGateKey, capabilityTaskBarrierKey("owner/repo#7", "gap-a"));
23
+ assertEquals(out.capsGateKey, "owner/repo#7:gap-a");
24
+ assertEquals(out.hasNeeds, true);
25
+ });
26
+
27
+ test("caps-prepare: a task with no needs yields hasNeeds=false (takes the no-needs shortcut)", async () => {
28
+ const out = await handler(
29
+ { variables: { planKey: "owner/repo#7", taskId: "gap-b", needs: [] } } as any,
30
+ {} as any,
31
+ );
32
+ assertEquals(out.capsGateKey, "owner/repo#7:gap-b");
33
+ assertEquals(out.hasNeeds, false);
34
+ });
35
+
36
+ test("caps-prepare: a missing/undefined needs list is treated as no needs", async () => {
37
+ const out = await handler(
38
+ { variables: { planKey: "owner/repo#7", taskId: "gap-c" } } as any,
39
+ {} as any,
40
+ );
41
+ assertEquals(out.hasNeeds, false);
42
+ });
43
+
44
+ test("caps-prepare: a malformed need is dropped — only a valid remainder gates the task", async () => {
45
+ const onlyBad = await handler(
46
+ { variables: { planKey: "p", taskId: "t", needs: [{ capabilityRef: "", package: "" }] } } as any,
47
+ {} as any,
48
+ );
49
+ assertEquals(onlyBad.hasNeeds, false, "a wholly-malformed need must not gate the fan-out");
50
+
51
+ const mixed = await handler(
52
+ {
53
+ variables: {
54
+ planKey: "p",
55
+ taskId: "t",
56
+ needs: [{ capabilityRef: "", package: "" }, { capabilityRef: "o/r#1", package: "pkg" }],
57
+ },
58
+ } as any,
59
+ {} as any,
60
+ );
61
+ assertEquals(mixed.hasNeeds, true, "a valid need still gates even alongside a malformed one");
62
+ });
@@ -0,0 +1,38 @@
1
+ // pr.caps-prepare — hoist a task's capability-barrier key + gate flag into the MI-child scope
2
+ // (issue #289).
3
+ //
4
+ // The `implement` multi-instance subprocess must decide, PER child, whether the task declared
5
+ // cross-repo capability `needs` and — if so — park at the `wait-caps-resolved` message barrier keyed
6
+ // on a per-task correlation key. The MI `inputElement` `task` is only readable inside a *service
7
+ // task's* ioMapping in the WASM testkit (an in-subprocess gateway / catch reads a stale/empty value
8
+ // — see AGENTS.md "Testing flows against the testkit"). So this worker runs first in the subprocess,
9
+ // derives the barrier key + the boolean from `task`, and RETURNS them; the model hoists both into the
10
+ // child scope with `zeebe:output source="=var" target="var"` so the following gateway (`w_gw_needs`)
11
+ // and the catch event's `correlationKey=capsGateKey` can read them reliably.
12
+ //
13
+ // PURE: no I/O. `capsGateKey` MUST equal `capabilityTaskBarrierKey(planKey, taskId)` — the same key
14
+ // the host reconciler (`pollCapabilityGatesImpl`) publishes `caps-resolved` on — or the barrier never
15
+ // releases.
16
+ import type { AppJobHandler } from "@nanobpm/urban";
17
+ import { capabilityTaskBarrierKey, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
18
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
19
+
20
+ type In = WorkerInputs["pr.caps-prepare"];
21
+ interface Out extends Record<string, unknown> {
22
+ capsGateKey: string;
23
+ hasNeeds: boolean;
24
+ }
25
+
26
+ const handler: AppJobHandler<In, Out> = async (job) => {
27
+ const planKey = String(job.variables.planKey ?? "");
28
+ const taskId = String(job.variables.taskId ?? "");
29
+ // Tolerant re-parse: `needs` arrives as the modelled CapabilityNeed[] but a malformed entry must
30
+ // never wedge the fan-out — drop it and gate only on the valid remainder (mirrors record-plan).
31
+ const needs = parseCapabilityNeeds(job.variables.needs);
32
+ return {
33
+ capsGateKey: capabilityTaskBarrierKey(planKey, taskId),
34
+ hasNeeds: needs.length > 0,
35
+ };
36
+ };
37
+
38
+ export default handler;
@@ -17,6 +17,7 @@ interface Row extends Record<string, unknown> {
17
17
  function fakeApp() {
18
18
  const planTasks: Row[] = [];
19
19
  const planTaskDeps: Row[] = [];
20
+ const planTaskNeeds: Row[] = [];
20
21
  const plans: Row[] = [{ plan_key: "owner/repo#137" }];
21
22
  let nextId = 1;
22
23
  const app = {
@@ -27,6 +28,8 @@ function fakeApp() {
27
28
  ? planTasks
28
29
  : name === "plan_task_deps"
29
30
  ? planTaskDeps
31
+ : name === "plan_task_needs"
32
+ ? planTaskNeeds
30
33
  : plans;
31
34
  return {
32
35
  get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
@@ -53,7 +56,7 @@ function fakeApp() {
53
56
  },
54
57
  },
55
58
  } as any;
56
- return { app, plans, planTasks };
59
+ return { app, plans, planTasks, planTaskNeeds };
57
60
  }
58
61
 
59
62
  test("record-plan initializes wave progress fields for a taskful plan", async () => {
@@ -88,3 +91,32 @@ test("record-plan leaves all three wave progress fields NULL for a taskless plan
88
91
  assertEquals(plans[0].current_wave, null);
89
92
  assertEquals(plans[0].wave_label, null);
90
93
  });
94
+
95
+ test("record-plan persists per-task capability needs into plan_task_needs (issue #289)", async () => {
96
+ const { app, planTaskNeeds } = fakeApp();
97
+ await handler(
98
+ {
99
+ variables: {
100
+ planKey: "owner/repo#137",
101
+ tasks: [
102
+ {
103
+ id: "a",
104
+ prompt: "do A",
105
+ needs: [
106
+ { capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban", verifyCommand: "v.sh" },
107
+ { capabilityRef: " ", package: "dropme" }, // malformed -> dropped by parse
108
+ ],
109
+ },
110
+ { id: "b", prompt: "do B", dependsOn: ["a"] }, // no needs
111
+ ],
112
+ },
113
+ } as any,
114
+ app,
115
+ );
116
+ assertEquals(planTaskNeeds.length, 1);
117
+ assertEquals(planTaskNeeds[0].plan_key, "owner/repo#137");
118
+ assertEquals(planTaskNeeds[0].task_id, "a");
119
+ assertEquals(planTaskNeeds[0].capability_ref, "nanobpm/nano-ide#274");
120
+ assertEquals(planTaskNeeds[0].package, "@nanobpm/urban");
121
+ assertEquals(planTaskNeeds[0].verify_command, "v.sh");
122
+ });
@@ -16,8 +16,9 @@
16
16
  // warning; the ordering is lost but every task still runs. No `plan_task_deps` are recorded
17
17
  // in that case (the edges were invalid).
18
18
  import type { AppJobHandler } from "@nanobpm/urban";
19
+ import { type CapabilityNeed, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
19
20
  import { deriveEpicPhase } from "../../app/epicPhase.ts";
20
- import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
21
+ import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
21
22
  import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
22
23
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
23
24
 
@@ -30,6 +31,10 @@ interface NormalTask {
30
31
  title: string;
31
32
  prompt: string;
32
33
  dependsOn: string[];
34
+ // Cross-repo capability edges declared on the task (issue #289). Normalised + de-duped; empty when
35
+ // the task consumes no upstream capability. Levelized into `plan_task_needs`, NOT the wave DAG —
36
+ // a capability edge gates a task on an EXTERNAL publish, never on a sibling task's wave.
37
+ needs: CapabilityNeed[];
33
38
  }
34
39
  interface Out extends Record<string, unknown> {
35
40
  currentWave: number;
@@ -54,6 +59,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
54
59
  // Dedupe: a planner-emitted `["a","a"]` would otherwise violate the
55
60
  // `plan_task_deps` PK on the second edge insert and fail the job.
56
61
  dependsOn: [...new Set(strList(t?.dependsOn))],
62
+ // Cross-repo capability edges (issue #289): tolerant-parse + de-dupe; a malformed need is
63
+ // dropped, never fatal. Independent of the wave DAG — these gate on an external publish.
64
+ needs: parseCapabilityNeeds(t?.needs),
57
65
  };
58
66
  });
59
67
 
@@ -90,6 +98,10 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
90
98
  // `plan_task_deps` is keyed on `plan_key`, so one delete clears the plan's whole edge set.
91
99
  const depTable = planTaskDeps(app.data);
92
100
  await depTable.delete(planKey);
101
+ // `plan_task_needs` is likewise keyed on `plan_key` — one delete clears the plan's capability
102
+ // edges before rewrite (issue #289). Independent of the DAG-degrade path below.
103
+ const needTable = planTaskNeeds(app.data);
104
+ await needTable.delete(planKey);
93
105
 
94
106
  for (let i = 0; i < tasks.length; i++) {
95
107
  const t = tasks[i];
@@ -113,6 +125,17 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
113
125
  });
114
126
  }
115
127
  }
128
+ // Capability edges persist regardless of `depsValid` — they gate on an external publish, not on
129
+ // the (possibly malformed) intra-epic DAG. The PK dedupes a task listing the same edge twice.
130
+ for (const need of t.needs) {
131
+ await needTable.insert({
132
+ plan_key: planKey,
133
+ task_id: t.id,
134
+ capability_ref: need.capabilityRef,
135
+ package: need.package,
136
+ verify_command: need.verifyCommand ?? null,
137
+ });
138
+ }
116
139
  }
117
140
 
118
141
  const patch: Record<string, unknown> = {
@@ -25,12 +25,18 @@ interface DepRow {
25
25
  depends_on_task_id: string;
26
26
  }
27
27
 
28
- function fakeApp(rows: Row[], deps: DepRow[], plans: Record<string, unknown>[] = []) {
28
+ function fakeApp(rows: Row[], deps: DepRow[], plans: Record<string, unknown>[] = [], needs: Record<string, unknown>[] = []) {
29
29
  return {
30
30
  log: { error() {}, info() {}, warn() {} },
31
31
  data: {
32
32
  table(name: string, key: string) {
33
- const store = name === "plan_tasks" ? rows : name === "plans" ? plans : deps;
33
+ const store = name === "plan_tasks"
34
+ ? rows
35
+ : name === "plans"
36
+ ? plans
37
+ : name === "plan_task_needs"
38
+ ? needs
39
+ : deps;
34
40
  return {
35
41
  find: (q: any) =>
36
42
  Promise.resolve(
@@ -196,3 +202,29 @@ test("select-wave still skips dependents behind failed or otherwise non-open dep
196
202
  });
197
203
  }
198
204
  });
205
+
206
+ test("select-wave attaches each dispatched task's capability needs, [] when none (issue #289)", async () => {
207
+ const rows: Row[] = [
208
+ { id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 1 },
209
+ { id: 2, plan_key: "owner/repo#63", task_id: "b", title: "B", prompt: "do B", status: "pending", wave: 1 },
210
+ ];
211
+ const needs: Record<string, unknown>[] = [
212
+ {
213
+ plan_key: "owner/repo#63",
214
+ task_id: "a",
215
+ capability_ref: "nanobpm/nano-ide#274",
216
+ package: "@nanobpm/urban",
217
+ verify_command: "verify.sh",
218
+ },
219
+ ];
220
+ const out = await handler(
221
+ { variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
222
+ fakeApp(rows, [], [], needs),
223
+ );
224
+ const waveTasks = (out as { waveTasks: any[] }).waveTasks;
225
+ const byId = new Map(waveTasks.map((t) => [t.id, t]));
226
+ assertEquals(byId.get("a").needs, [
227
+ { capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban", verifyCommand: "verify.sh" },
228
+ ]);
229
+ assertEquals(byId.get("b").needs, []);
230
+ });
@@ -15,8 +15,9 @@
15
15
  // Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
16
16
  // immediately (the same 0-task path the flat fan-out already relied on).
17
17
  import type { AppJobHandler } from "@nanobpm/urban";
18
+ import type { CapabilityNeed } from "../../app/capabilityNeed.ts";
18
19
  import { deriveEpicPhase } from "../../app/epicPhase.ts";
19
- import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
20
+ import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
20
21
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
21
22
 
22
23
  // Input typed off the model data envelope (`SelectWaveIn` in plan-fanout.bpmn) — ADR 0040.
@@ -25,6 +26,10 @@ interface WaveTaskOut {
25
26
  id: string;
26
27
  title: string;
27
28
  prompt: string;
29
+ // Cross-repo capability edges to gate this task on before its agent starts (issue #289). Empty
30
+ // for a task with no upstream capability dependency; carried through so the fan-out can gate on
31
+ // each need (readiness-gate) and late-bind the resolved `pkg@version` into the agent's prompt.
32
+ needs: CapabilityNeed[];
28
33
  }
29
34
  interface Out extends Record<string, unknown> {
30
35
  waveTasks: WaveTaskOut[];
@@ -96,6 +101,20 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
96
101
  depsByTask.set(d.task_id, list);
97
102
  }
98
103
 
104
+ // Cross-repo capability edges (issue #289): loaded once and grouped per task so each dispatched
105
+ // wave task carries its `needs[]` for the fan-out to gate on. A task with no needs gets [].
106
+ const needRows = await planTaskNeeds(app.data).find({ plan_key: planKey });
107
+ const needsByTask = new Map<string, CapabilityNeed[]>();
108
+ for (const n of needRows) {
109
+ const list = needsByTask.get(n.task_id) ?? [];
110
+ list.push({
111
+ capabilityRef: n.capability_ref,
112
+ package: n.package,
113
+ ...(n.verify_command ? { verifyCommand: n.verify_command } : {}),
114
+ });
115
+ needsByTask.set(n.task_id, list);
116
+ }
117
+
99
118
  const waveTasks: WaveTaskOut[] = [];
100
119
  for (const r of rows) {
101
120
  if ((r.wave ?? 0) !== currentWave) continue;
@@ -116,7 +135,12 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
116
135
  continue;
117
136
  }
118
137
  if (depIds.some((d) => statusById.get(d) === "waiting-for-lane")) continue;
119
- waveTasks.push({ id: r.task_id, title: r.title ?? r.task_id, prompt: r.prompt ?? "" });
138
+ waveTasks.push({
139
+ id: r.task_id,
140
+ title: r.title ?? r.task_id,
141
+ prompt: r.prompt ?? "",
142
+ needs: needsByTask.get(r.task_id) ?? [],
143
+ });
120
144
  }
121
145
 
122
146
  return { waveTasks };