@nanobpm/nano-workforce 0.35.1 → 0.36.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.
@@ -25,6 +25,9 @@ jobs:
25
25
  - name: Install dependencies
26
26
  run: npm ci
27
27
 
28
+ - name: Lint (Biome)
29
+ run: npm run lint
30
+
28
31
  - name: Typecheck (Node / tsc)
29
32
  run: npm run typecheck
30
33
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.36.0](https://github.com/nanobpm/nano-workforce/compare/v0.35.2...v0.36.0) (2026-08-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * adopt Biome + GritQL ban-`as` lint gate ([#107](https://github.com/nanobpm/nano-workforce/issues/107)) ([38c16fb](https://github.com/nanobpm/nano-workforce/commit/38c16fb319e8c5d6b8c20faf95318de39b948a96)), closes [#105](https://github.com/nanobpm/nano-workforce/issues/105)
7
+
8
+ ## [0.35.2](https://github.com/nanobpm/nano-workforce/compare/v0.35.1...v0.35.2) (2026-08-10)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pages:** route-driven page actions via @nanobpm/urban@0.33.0 ([#106](https://github.com/nanobpm/nano-workforce/issues/106)) ([0ee4ab2](https://github.com/nanobpm/nano-workforce/commit/0ee4ab29aa8cfca6a04af6eb141a96d7878a6b1a))
14
+
1
15
  ## [0.35.1](https://github.com/nanobpm/nano-workforce/compare/v0.35.0...v0.35.1) (2026-08-10)
2
16
 
3
17
 
package/README.md CHANGED
@@ -133,15 +133,15 @@ app emits, then put it to work:
133
133
  c8ctl nano hire \
134
134
  --name fleet \
135
135
  --rank senior \
136
- --capabilities pr-review plan plan-review feature fix-ci \
136
+ --capabilities pr-review plan plan-review feature trial-merge fix-ci rebase retro \
137
137
  --command 'copilot -p - --allow-all-tools' \
138
138
  --model <your-model>
139
139
 
140
140
  c8ctl nano work fleet # polls every senior:* agent task below until Ctrl-C
141
141
  ```
142
142
 
143
- `--rank senior` × those five capabilities subscribes the worker to exactly the agent
144
- task types the three workflows emit (one `senior:<capability>` job type per
143
+ `--rank senior` × those eight capabilities subscribes the worker to exactly the agent
144
+ task types the four workflows emit (one `senior:<capability>` job type per
145
145
  capability):
146
146
 
147
147
  | Capability | Job type | Workflow | Task |
@@ -150,7 +150,10 @@ capability):
150
150
  | `plan` | `senior:plan` | `plan-fanout` | Plan an issue into levelized tasks |
151
151
  | `plan-review` | `senior:plan-review` | `plan-fanout` | Review the plan before fan-out |
152
152
  | `feature` | `senior:feature` | `plan-fanout` | Implement one planned task → open a PR |
153
+ | `trial-merge` | `senior:trial-merge` | `plan-fanout` | Integration gate: trial-merge a wave, catch semantic conflicts CI can't see |
153
154
  | `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
155
+ | `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
156
+ | `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
154
157
 
155
158
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
156
159
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
@@ -169,9 +172,12 @@ whichever PRs/tasks are ready — that is the idle time you reclaim. Run more th
169
172
  job per worker with `--max-parallel 2`. The app-hosted `pr.*` workers (record-plan,
170
173
  select-wave, finalize, merge, …) run **inside** the app, not on an agent worker.
171
174
 
172
- Already have a narrower worker? Extend it in place instead of re-hiring — `c8ctl nano
173
- assign <name> plan plan-review feature fix-ci` unions the roles onto the profile,
174
- then restart its worker.
175
+ Already have a narrower worker? Extend it in place instead of re-hiring — this unions
176
+ the roles onto the profile, then restart its worker:
177
+
178
+ ```sh
179
+ c8ctl nano assign <name> pr-review plan plan-review feature trial-merge fix-ci rebase retro
180
+ ```
175
181
 
176
182
  ---
177
183
 
@@ -35,6 +35,7 @@ const handler: ActionHandler = async ({ req, body }, app) => {
35
35
  }
36
36
 
37
37
  if (req.method === "POST") {
38
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
38
39
  const b = (body ?? {}) as Record<string, unknown>;
39
40
  const text = typeof b.body === "string" ? b.body.trim() : "";
40
41
  if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
@@ -20,6 +20,7 @@ const handler: ActionHandler = async ({ req, body }, app) => {
20
20
  if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
23
24
  const b = (body ?? {}) as {
24
25
  corrKey?: unknown;
25
26
  plan?: unknown;
@@ -10,8 +10,9 @@ const handler: ActionHandler = async ({ req, body }, app) => {
10
10
  if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
11
11
  return { status: 401, body: { error: "unauthorized" } };
12
12
  }
13
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
13
14
  const b = (body ?? {}) as { url?: unknown; issue?: unknown };
14
- const parsed = parseIssue(String((b.issue ?? b.url ?? "") as string));
15
+ const parsed = parseIssue(String(b.issue ?? b.url ?? ""));
15
16
  if (!parsed) return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
16
17
  return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
17
18
  };
@@ -10,8 +10,9 @@ const handler: ActionHandler = async ({ req, body }, app) => {
10
10
  if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
11
11
  return { status: 401, body: { error: "unauthorized" } };
12
12
  }
13
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
13
14
  const b = (body ?? {}) as { url?: unknown; pr?: unknown; dependsOn?: unknown; maxRounds?: unknown };
14
- const parsed = parsePr(String((b.url ?? b.pr ?? "") as string));
15
+ const parsed = parsePr(String(b.url ?? b.pr ?? ""));
15
16
  if (!parsed) return { status: 400, body: { error: "could not parse PR url" } };
16
17
  const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
17
18
  const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
package/app/blackboard.ts CHANGED
@@ -58,11 +58,9 @@ export interface BlackboardInput {
58
58
  dedupe_key?: string;
59
59
  }
60
60
 
61
- const KIND_SET = new Set<string>(BLACKBOARD_KINDS);
62
-
63
61
  /** Coerce an arbitrary `kind` to a known value, defaulting to "note" for anything unrecognised. */
64
62
  export function normalizeKind(kind: unknown): BlackboardKind {
65
- return typeof kind === "string" && KIND_SET.has(kind) ? (kind as BlackboardKind) : "note";
63
+ return BLACKBOARD_KINDS.find((k) => k === kind) ?? "note";
66
64
  }
67
65
 
68
66
  /** A URL-safe, unguessable capability token (192 bits of randomness, base64url, no padding). */
@@ -79,9 +77,10 @@ export function mintBlackboardToken(): string {
79
77
  export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBLIC_BASE_URL): string {
80
78
  // Cascade through the fallback chain, skipping any value that is unset OR blank/whitespace, so an
81
79
  // explicitly-set-but-empty NANO_PR_PUBLIC_BASE_URL can't yield a malformed capability URL.
82
- const base = [env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
83
- .map((v) => v?.trim())
84
- .find((v) => v) as string;
80
+ const base =
81
+ [env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
82
+ .map((v) => v?.trim())
83
+ .find((v): v is string => Boolean(v)) ?? "http://localhost:3000";
85
84
  return base.replace(/\/+$/, "");
86
85
  }
87
86
 
@@ -313,8 +312,10 @@ export async function appendEntry(
313
312
  * corruption, not a benign duplicate) is always rethrown rather than silently swallowed. */
314
313
  export function isUniqueViolation(err: unknown): boolean {
315
314
  if (!err || typeof err !== "object") return false;
315
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
316
316
  const code = (err as { code?: unknown }).code;
317
317
  if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
318
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
318
319
  const message = (err as { message?: unknown }).message;
319
320
  return typeof message === "string" &&
320
321
  /(unique|primary key) constraint failed|duplicate/i.test(message);
package/app/github.ts CHANGED
@@ -37,6 +37,7 @@ interface DenoCommandCtor {
37
37
  * `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
38
38
  * non-zero exit with stderr as the message. */
39
39
  async function runGh(args: string[]): Promise<string> {
40
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
40
41
  const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
41
42
  if (g.Deno?.Command) {
42
43
  const { code, stdout, stderr } = await new g.Deno.Command("gh", {
@@ -66,7 +67,10 @@ async function runGh(args: string[]): Promise<string> {
66
67
  let ghAvailable: Promise<boolean> | undefined;
67
68
  /** Whether the host `gh` CLI is present (memoized — probed at most once per process). */
68
69
  function isGhAvailable(): Promise<boolean> {
69
- return (ghAvailable ??= runGh(["--version"]).then(() => true, () => false));
70
+ if (!ghAvailable) {
71
+ ghAvailable = runGh(["--version"]).then(() => true, () => false);
72
+ }
73
+ return ghAvailable;
70
74
  }
71
75
 
72
76
  /** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
@@ -81,6 +85,7 @@ export async function fetchPrReviews(
81
85
  const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
82
86
  if (useGh) {
83
87
  const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
88
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
84
89
  return JSON.parse(out) as GhReview[];
85
90
  }
86
91
  if (!token) return null; // token mode with no token → poller idles
@@ -88,6 +93,7 @@ export async function fetchPrReviews(
88
93
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
89
94
  });
90
95
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
96
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
91
97
  return (await r.json()) as GhReview[];
92
98
  }
93
99
 
@@ -122,6 +128,7 @@ export async function hasPendingCopilotReviewer(
122
128
  let users: { login?: string }[];
123
129
  if (await useGh()) {
124
130
  const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
131
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
125
132
  users = (JSON.parse(out) as { users?: { login?: string }[] }).users ?? [];
126
133
  } else {
127
134
  if (!token) return null;
@@ -129,6 +136,7 @@ export async function hasPendingCopilotReviewer(
129
136
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
130
137
  });
131
138
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
139
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
132
140
  users = ((await r.json()) as { users?: { login?: string }[] }).users ?? [];
133
141
  }
134
142
  return users.some((u) => isCopilot(u.login));
@@ -198,6 +206,7 @@ export async function fetchPrMeta(
198
206
  ): Promise<PrMeta | null> {
199
207
  if (await useGh()) {
200
208
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
209
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
201
210
  const j = JSON.parse(out) as { title?: string; body?: string };
202
211
  return { title: j.title ?? null, body: j.body ?? "" };
203
212
  }
@@ -206,6 +215,7 @@ export async function fetchPrMeta(
206
215
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
207
216
  });
208
217
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
218
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
209
219
  const j = (await r.json()) as { title?: string; body?: string };
210
220
  return { title: j.title ?? null, body: j.body ?? "" };
211
221
  }
@@ -273,6 +283,7 @@ export async function fetchPrState(
273
283
  "--json",
274
284
  "state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid",
275
285
  ]);
286
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
276
287
  const j = JSON.parse(out) as {
277
288
  state?: string;
278
289
  mergedAt?: string | null;
@@ -298,6 +309,7 @@ export async function fetchPrState(
298
309
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
299
310
  });
300
311
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
312
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
301
313
  const j = (await r.json()) as {
302
314
  merged?: boolean;
303
315
  merged_at?: string | null;
@@ -328,6 +340,7 @@ export async function fetchPrFiles(
328
340
  ): Promise<string[] | null> {
329
341
  if (await useGh()) {
330
342
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "files"]);
343
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
331
344
  const j = JSON.parse(out) as { files?: { path?: string }[] };
332
345
  return (j.files ?? []).map((f) => f.path ?? "").filter((p) => p !== "");
333
346
  }
@@ -341,6 +354,7 @@ export async function fetchPrFiles(
341
354
  { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
342
355
  );
343
356
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
357
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
344
358
  const batch = (await r.json()) as { filename?: string }[];
345
359
  for (const f of batch) if (f.filename) paths.push(f.filename);
346
360
  // A short final page means we've read every file — the list is complete.
@@ -367,6 +381,7 @@ export async function fetchPrHead(
367
381
  ): Promise<{ headRef: string | null; headSha: string | null } | null> {
368
382
  if (await useGh()) {
369
383
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
384
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
370
385
  const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
371
386
  return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
372
387
  }
@@ -375,6 +390,7 @@ export async function fetchPrHead(
375
390
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
376
391
  });
377
392
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
393
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
378
394
  const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
379
395
  return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
380
396
  }
@@ -389,6 +405,7 @@ export async function fetchPrBase(
389
405
  ): Promise<string | null> {
390
406
  if (await useGh()) {
391
407
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "baseRefName"]);
408
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
392
409
  const j = JSON.parse(out) as { baseRefName?: string };
393
410
  return j.baseRefName ?? null;
394
411
  }
@@ -397,6 +414,7 @@ export async function fetchPrBase(
397
414
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
398
415
  });
399
416
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
417
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
400
418
  const j = (await r.json()) as { base?: { ref?: string } };
401
419
  return j.base?.ref ?? null;
402
420
  }
@@ -413,6 +431,7 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
413
431
  let name: string | null = null;
414
432
  if (await useGh()) {
415
433
  const out = await runGh(["repo", "view", repo, "--json", "defaultBranchRef"]);
434
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
416
435
  const j = JSON.parse(out) as { defaultBranchRef?: { name?: string } };
417
436
  name = j.defaultBranchRef?.name ?? null;
418
437
  } else if (token) {
@@ -420,6 +439,7 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
420
439
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
421
440
  });
422
441
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
442
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
423
443
  const j = (await r.json()) as { default_branch?: string };
424
444
  name = j.default_branch ?? null;
425
445
  } else {
@@ -455,6 +475,7 @@ export async function baseBranchLanded(
455
475
  "--limit",
456
476
  "20",
457
477
  ]);
478
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
458
479
  const arr = JSON.parse(out) as { state?: string }[];
459
480
  if (arr.some((p) => (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
460
481
  if (arr.some((p) => (p.state ?? "").toUpperCase() === "OPEN")) return "open";
@@ -467,6 +488,7 @@ export async function baseBranchLanded(
467
488
  { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
468
489
  );
469
490
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
491
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
470
492
  const arr = (await r.json()) as { state?: string; merged_at?: string | null }[];
471
493
  if (arr.some((p) => p.merged_at || (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
472
494
  if (arr.some((p) => (p.state ?? "").toLowerCase() === "open")) return "open";
@@ -491,7 +513,6 @@ export function classifyMergeability(s: PrState): Mergeability {
491
513
  // A required check failed -> a human must act. Pending checks / awaiting review -> wait.
492
514
  // When we can't enumerate checks (failingChecks < 0, token mode) stay conservative: wait.
493
515
  return s.failingChecks > 0 ? "blocked" : "waiting";
494
- case "DRAFT":
495
516
  default: // UNKNOWN / "" — GitHub is still computing mergeability
496
517
  return "waiting";
497
518
  }
@@ -550,6 +571,7 @@ export async function mergePr(
550
571
  // in this pass. Trust `merged` when true; otherwise verify the PR's actual state and report
551
572
  // `queued` when it hasn't landed yet, so the merge-loop waits for `merge-landed` rather than
552
573
  // marking it merged prematurely.
574
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
553
575
  const body = (await r.json().catch(() => ({}))) as { merged?: boolean };
554
576
  if (body.merged) return { outcome: "merged", detail: "merged" };
555
577
  const st = await fetchPrState(repo, number, token).catch(() => null);
@@ -169,12 +169,17 @@ export async function clearExclusions(data: DataLayer, planKey: string): Promise
169
169
  export function mergeLanes(edges: ExclusionEdge[], allTasks: Iterable<string> = []): string[][] {
170
170
  const parent = new Map<string, string>();
171
171
  const find = (x: string): string => {
172
+ const parentOf = (node: string): string => {
173
+ const p = parent.get(node);
174
+ if (p === undefined) throw new Error(`missing union-find parent for ${node}`);
175
+ return p;
176
+ };
172
177
  let root = x;
173
- while (parent.get(root) !== root) root = parent.get(root)!;
178
+ while (parentOf(root) !== root) root = parentOf(root);
174
179
  // Path-compress so repeated finds stay near-flat.
175
180
  let cur = x;
176
- while (parent.get(cur) !== root) {
177
- const next = parent.get(cur)!;
181
+ while (parentOf(cur) !== root) {
182
+ const next = parentOf(cur);
178
183
  parent.set(cur, root);
179
184
  cur = next;
180
185
  }
@@ -72,6 +72,7 @@ function strArray(v: unknown): string[] | undefined {
72
72
  }
73
73
  function oneOf<T extends string>(v: unknown, allowed: ReadonlySet<string>): T | undefined {
74
74
  const s = str(v);
75
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
75
76
  return s !== undefined && allowed.has(s) ? (s as T) : undefined;
76
77
  }
77
78
 
package/app/plan.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
- import { clearTaskDeltas } from "./taskDelta.ts";
15
14
  import { clearExclusions } from "./mergeExclusion.ts";
15
+ import { clearTaskDeltas } from "./taskDelta.ts";
16
16
 
17
17
  /** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
18
18
  export const PLAN_PROCESS_ID = "plan-fanout";
package/app/retro.ts CHANGED
@@ -14,16 +14,17 @@
14
14
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
15
15
  // app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
16
16
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
- import { planReviews, planTasks } from "./plan.ts";
18
17
  import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
- import { aggregateEpicDeltas } from "./taskDelta.ts";
18
+ import { planReviews, planTasks } from "./plan.ts";
20
19
  import { TERMINAL_STATUSES } from "./service.ts";
20
+ import { aggregateEpicDeltas } from "./taskDelta.ts";
21
21
 
22
22
  export const RETRO_PROCESS_ID = "retro";
23
23
 
24
24
  /** Opt-out env toggle. Retro runs by default; set NANO_AUTO_RETRO=0/false to disable (e.g. in a
25
25
  * review-only deployment that doesn't want the fleet opening promotion PRs). */
26
26
  export function autoRetroEnabled(): boolean {
27
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
27
28
  const v = (globalThis as { process?: { env?: Record<string, string | undefined> } })
28
29
  .process?.env?.NANO_AUTO_RETRO;
29
30
  if (v == null) return true;
@@ -128,6 +129,7 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
128
129
  .sort((a, b) => a.round - b.round);
129
130
  const reviewRejections = reviews
130
131
  .filter((r) => r.approved === 0 && (r.findings ?? "").trim() !== "")
132
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
131
133
  .map((r) => ({ round: r.round, findings: r.findings as string }));
132
134
  const planApproved = reviews.length > 0 && reviews[reviews.length - 1].approved === 1;
133
135
 
package/app/service.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
+ import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
11
12
  import {
12
13
  classifyMergeability,
13
14
  ensureFreshHeadRun,
@@ -18,13 +19,12 @@ import {
18
19
  type MergeMethod,
19
20
  requestCopilotReview,
20
21
  } from "./github.ts";
21
- import { planTaskDeps, planTasks, plans } from "./plan.ts";
22
- import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
22
+ import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
23
23
  import { freshHeadRunAction, loadMergeProtocol } from "./mergeProtocol.ts";
24
+ import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
25
+ import { plans, planTaskDeps, planTasks } from "./plan.ts";
24
26
  import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
25
27
  import { waveMergeTargets } from "./waves.ts";
26
- import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
27
- import { planPrLane, type PrLaneDecision, taskDependencyDepths } from "./mergeTrain.ts";
28
28
 
29
29
  /** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
30
30
  export const PROCESS_ID = "convergence-loop";
@@ -34,10 +34,12 @@ export const MERGE_PROCESS_ID = "merge-loop";
34
34
  * in convergence-loop.bpmn). Deliberately NOT hosted here — an external harness services it; the
35
35
  * activation poll keys off it to tell "agent working" from "queued". */
36
36
  const REVIEW_JOB_TYPE = "senior:pr-review";
37
+
37
38
  /** Default round cap before the loop escalates to a human. A per-submit override (submit form /
38
39
  * webhook / start action) takes precedence; this env var sets the fleet-wide default. The cap
39
40
  * coercion + ceiling live in the pure `./rounds.ts` module (re-exported for callers). */
40
41
  export { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
42
+
41
43
  import { clampCiFixBudget, clampRounds } from "./rounds.ts";
42
44
  export const MAX_ROUNDS = clampRounds(process.env.NANO_PR_MAX_ROUNDS, 20);
43
45
 
@@ -830,6 +832,7 @@ async function pollJobActivation(
830
832
  }),
831
833
  });
832
834
  if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
835
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
833
836
  const body = (await res.json()) as { items?: JobSearchItem[] };
834
837
  // An open job with a leasing worker means an agent has activated it. Prefer the one with
835
838
  // the latest deadline if several are open (there is normally at most one).
@@ -929,6 +932,7 @@ export async function pollIncidentsImpl(
929
932
  }),
930
933
  });
931
934
  if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
935
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
932
936
  const body = (await res.json()) as { items?: IncidentSearchItem[] };
933
937
  // Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
934
938
  // instance somehow parks more than one). Re-filter on state defensively in case the wire
package/app/taskDelta.ts CHANGED
@@ -71,6 +71,7 @@ function decodeArray(raw: string | null): string[] {
71
71
  * nothing actionable (so callers persist/broadcast only real deltas, never empty noise). */
72
72
  export function parseTaskDelta(raw: unknown): TaskDelta | null {
73
73
  if (!raw || typeof raw !== "object") return null;
74
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
74
75
  const o = raw as Record<string, unknown>;
75
76
  const delta: TaskDelta = {
76
77
  contractChange: trimStr(o.contractChange),
package/app/version.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  // Every probe is best-effort: a missing file or unavailable `.git` yields `null` for that field
12
12
  // rather than throwing, so `/app/api/version` never fails just because one source is absent.
13
13
  import { readFileSync } from "node:fs";
14
+ import { dirname, isAbsolute, join, resolve } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { dirname, join, resolve, isAbsolute } from "node:path";
16
16
 
17
17
  // Captured once, at module load — i.e. when the running process booted this code.
18
18
  const STARTED_AT = new Date();
@@ -31,6 +31,7 @@ function readJson(path: string): Record<string, unknown> | null {
31
31
  const text = readText(path);
32
32
  if (text == null) return null;
33
33
  try {
34
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
34
35
  return JSON.parse(text) as Record<string, unknown>;
35
36
  } catch {
36
37
  return null;
@@ -44,6 +45,7 @@ function readJson(path: string): Record<string, unknown> | null {
44
45
  export function envVar(name: string): string | null {
45
46
  const fromProcess = globalThis.process?.env?.[name];
46
47
  if (typeof fromProcess === "string" && fromProcess.trim()) return fromProcess.trim();
48
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
47
49
  const deno = (globalThis as { Deno?: { env?: { get?(k: string): string | undefined } } }).Deno;
48
50
  try {
49
51
  const fromDeno = deno?.env?.get?.(name);
@@ -129,7 +131,7 @@ function gitSha(): string | null {
129
131
  const refPath = ref.slice(4).trim(); // e.g. "refs/heads/main"
130
132
  // A loose ref may live in the per-worktree dir or the common dir; check both.
131
133
  const loose = readText(join(dirs.gitDir, refPath)) ?? readText(join(dirs.commonDir, refPath));
132
- if (loose != null && loose.trim()) return loose.trim();
134
+ if (loose?.trim()) return loose.trim();
133
135
 
134
136
  // Packed refs fallback (always in the common dir): lines of "<sha> <refname>".
135
137
  const packed = readText(join(dirs.commonDir, "packed-refs"));
@@ -159,6 +161,7 @@ function gitBranch(): string | null {
159
161
  function runtime(): string {
160
162
  const proc = globalThis.process;
161
163
  // Deno exposes `Deno.version.deno`; Node exposes `process.version` (e.g. "v24.15.0").
164
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
162
165
  const deno = (globalThis as { Deno?: { version?: { deno?: string } } }).Deno;
163
166
  if (deno?.version?.deno) return `deno ${deno.version.deno}`;
164
167
  if (proc?.version) return `node ${proc.version}`;
package/biome.json ADDED
@@ -0,0 +1,143 @@
1
+ {
2
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3
+ "files": {
4
+ "includes": [
5
+ "app/**/*.ts",
6
+ "operations/**/*.ts",
7
+ "actions/**/*.ts",
8
+ "workers/**/*.ts",
9
+ "pages/**/*.ts",
10
+ "components/**/*.ts",
11
+ "scripts/**/*.ts",
12
+ "main.ts",
13
+ "!**/*.test.ts",
14
+ "!nano-generated/**",
15
+ "!node_modules/**"
16
+ ]
17
+ },
18
+ "linter": {
19
+ "enabled": true,
20
+ "includes": [
21
+ "app/**/*.ts",
22
+ "operations/**/*.ts",
23
+ "actions/**/*.ts",
24
+ "workers/**/*.ts",
25
+ "pages/**/*.ts",
26
+ "components/**/*.ts",
27
+ "scripts/**/*.ts",
28
+ "main.ts",
29
+ "!**/*.test.ts",
30
+ "!nano-generated/**",
31
+ "!node_modules/**"
32
+ ],
33
+ "rules": {
34
+ "recommended": true,
35
+ "complexity": {
36
+ "noAdjacentSpacesInRegex": "error",
37
+ "noArguments": "error",
38
+ "noBannedTypes": "error",
39
+ "noCommaOperator": "error",
40
+ "noEmptyTypeParameters": "error",
41
+ "noExtraBooleanCast": "error",
42
+ "noFlatMapIdentity": "error",
43
+ "noImportantStyles": "error",
44
+ "noStaticOnlyClass": "error",
45
+ "noThisInStatic": "error",
46
+ "noUselessCatch": "error",
47
+ "noUselessConstructor": "error",
48
+ "noUselessContinue": "error",
49
+ "noUselessEmptyExport": "error",
50
+ "noUselessEscapeInRegex": "error",
51
+ "noUselessFragments": "error",
52
+ "noUselessLabel": "error",
53
+ "noUselessLoneBlockStatements": "error",
54
+ "noUselessRename": "error",
55
+ "noUselessStringRaw": "error",
56
+ "noUselessSwitchCase": "error",
57
+ "noUselessTernary": "error",
58
+ "noUselessThisAlias": "error",
59
+ "noUselessTypeConstraint": "error",
60
+ "noUselessUndefinedInitialization": "error",
61
+ "useArrowFunction": "error",
62
+ "useDateNow": "error",
63
+ "useFlatMap": "error",
64
+ "useIndexOf": "error",
65
+ "useLiteralKeys": "error",
66
+ "useNumericLiterals": "error",
67
+ "useOptionalChain": "error",
68
+ "useRegexLiterals": "error",
69
+ "useSimpleNumberKeys": "error"
70
+ },
71
+ "correctness": {
72
+ "noUnusedFunctionParameters": "error",
73
+ "noUnusedImports": "error",
74
+ "noUnusedLabels": "error",
75
+ "noUnusedPrivateClassMembers": "error",
76
+ "noUnusedVariables": "error",
77
+ "useParseIntRadix": "error"
78
+ },
79
+ "performance": {
80
+ "noAccumulatingSpread": "error",
81
+ "noDynamicNamespaceImportAccess": "error"
82
+ },
83
+ "style": {
84
+ "noDescendingSpecificity": "error",
85
+ "noNonNullAssertion": "error",
86
+ "useArrayLiterals": "error",
87
+ "useConst": "error",
88
+ "useDeprecatedReason": "error",
89
+ "useExponentiationOperator": "error",
90
+ "useExportType": "error",
91
+ "useImportType": "error",
92
+ "useLiteralEnumMembers": "error",
93
+ "useNodejsImportProtocol": "error",
94
+ "useShorthandFunctionType": "error",
95
+ "useTemplate": "error"
96
+ },
97
+ "suspicious": {
98
+ "noApproximativeNumericConstant": "error",
99
+ "noCatchAssign": "error",
100
+ "noConfusingLabels": "error",
101
+ "noConfusingVoidType": "error",
102
+ "noConstEnum": "error",
103
+ "noDocumentCookie": "error",
104
+ "noDuplicateFields": "error",
105
+ "noEmptyBlock": "error",
106
+ "noEvolvingTypes": "error",
107
+ "noExplicitAny": "error",
108
+ "noExtraNonNullAssertion": "error",
109
+ "noGlobalIsFinite": "error",
110
+ "noGlobalIsNan": "error",
111
+ "noImplicitAnyLet": "error",
112
+ "noIrregularWhitespace": "error",
113
+ "noOctalEscape": "error",
114
+ "noPrototypeBuiltins": "error",
115
+ "noQuickfixBiome": "error",
116
+ "noRedundantUseStrict": "error",
117
+ "noSuspiciousSemicolonInJsx": "error",
118
+ "noTemplateCurlyInString": "error",
119
+ "noTsIgnore": "error",
120
+ "noUselessEscapeInString": "error",
121
+ "noUselessRegexBackrefs": "error",
122
+ "useAdjacentOverloadSignatures": "error",
123
+ "useBiomeIgnoreFolder": "error",
124
+ "useDefaultSwitchClauseLast": "error",
125
+ "useGoogleFontDisplay": "error",
126
+ "useIsArray": "error"
127
+ }
128
+ },
129
+ "domains": {
130
+ "react": "none",
131
+ "solid": "none",
132
+ "next": "none",
133
+ "qwik": "none",
134
+ "vue": "none"
135
+ }
136
+ },
137
+ "plugins": [
138
+ "./plugins/no-unsafe-type-assertion.grit"
139
+ ],
140
+ "formatter": {
141
+ "enabled": false
142
+ }
143
+ }
package/deno.json CHANGED
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  },
8
8
  "imports": {
9
- "@nanobpm/urban": "npm:@nanobpm/urban@^0.32.0"
9
+ "@nanobpm/urban": "npm:@nanobpm/urban@^0.33.0"
10
10
  },
11
11
  "tasks": {
12
12
  "start": "deno run --allow-net --allow-read --allow-write --allow-run=gh --allow-env main.ts",
package/deno.lock CHANGED
@@ -1756,11 +1756,12 @@
1756
1756
  },
1757
1757
  "workspace": {
1758
1758
  "dependencies": [
1759
- "npm:@nanobpm/urban@0.32"
1759
+ "npm:@nanobpm/urban@0.33"
1760
1760
  ],
1761
1761
  "packageJson": {
1762
1762
  "dependencies": [
1763
- "npm:@nanobpm/urban@0.32",
1763
+ "npm:@biomejs/biome@^2.4.11",
1764
+ "npm:@nanobpm/urban@0.33",
1764
1765
  "npm:@semantic-release/changelog@^6.0.3",
1765
1766
  "npm:@semantic-release/git@^10.0.1",
1766
1767
  "npm:@semantic-release/npm@^13.1.5",
package/nano.app.json CHANGED
@@ -141,7 +141,6 @@
141
141
  },
142
142
  "api": {
143
143
  "spec": "openapi.json",
144
- "base": "/app/api",
145
144
  "dir": "operations",
146
145
  "validateResponses": "dev"
147
146
  },
@@ -9,8 +9,8 @@
9
9
  // for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
10
10
  // is app logic, not something the JSON schema can express.
11
11
  import { defineOperation } from "@nanobpm/urban";
12
- import { answerEscalation } from "../app/service.ts";
13
12
  import { answerTaskEscalation, FEATURE_ESCALATION_MESSAGE } from "../app/plan.ts";
13
+ import { answerEscalation } from "../app/service.ts";
14
14
 
15
15
  interface Body {
16
16
  name?: unknown;
@@ -28,7 +28,7 @@ export default defineOperation<
28
28
 
29
29
  if (name === "escalation-answered") {
30
30
  const prKey = String(b.correlationKey ?? "");
31
- const answer = String((b.variables?.answer ?? "") as string).trim();
31
+ const answer = String(b.variables?.answer ?? "").trim();
32
32
  if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
33
33
  if (!answer) return { status: 400, body: { error: "answer is required" } };
34
34
  const r = await answerEscalation(app.data, app.engine, prKey, answer);
@@ -40,7 +40,7 @@ export default defineOperation<
40
40
  // `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
41
41
  // open escalation.
42
42
  const corrKey = String(b.correlationKey ?? "");
43
- const answer = String((b.variables?.answer ?? "") as string).trim();
43
+ const answer = String(b.variables?.answer ?? "").trim();
44
44
  if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
45
45
  if (!answer) return { status: 400, body: { error: "answer is required" } };
46
46
  const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.35.1",
3
+ "version": "0.36.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -37,12 +37,15 @@
37
37
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
38
38
  "dev": "urban dev",
39
39
  "compile": "deno task compile",
40
- "test": "deno test -A"
40
+ "test": "deno test -A",
41
+ "lint": "biome check app operations actions workers pages components scripts main.ts",
42
+ "lint:fix": "biome check --write app operations actions workers pages components scripts main.ts"
41
43
  },
42
44
  "dependencies": {
43
- "@nanobpm/urban": "^0.32.0"
45
+ "@nanobpm/urban": "^0.33.0"
44
46
  },
45
47
  "devDependencies": {
48
+ "@biomejs/biome": "^2.4.11",
46
49
  "@semantic-release/changelog": "^6.0.3",
47
50
  "@semantic-release/git": "^10.0.1",
48
51
  "@semantic-release/npm": "^13.1.5",
@@ -33,7 +33,7 @@
33
33
  "props": {
34
34
  "title": "Hand an issue to the fleet",
35
35
  "submitLabel": "Plan & implement",
36
- "action": { "kind": "startProcess", "process": "plan-fanout" },
36
+ "action": { "path": "/app/api/actions/start/plan-fanout" },
37
37
  "fields": [
38
38
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
39
39
  ]
@@ -33,7 +33,7 @@
33
33
  "props": {
34
34
  "title": "Submit a pull request",
35
35
  "submitLabel": "Start review",
36
- "action": { "kind": "startProcess", "process": "convergence-loop" },
36
+ "action": { "path": "/app/api/actions/start/convergence-loop" },
37
37
  "fields": [
38
38
  { "key": "pr", "label": "owner/repo#123 or a GitHub PR URL", "type": "text" },
39
39
  { "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" }
@@ -82,7 +82,7 @@
82
82
  "label": "Cancel",
83
83
  "confirm": "Cancel this PR's review run?",
84
84
  "showWhenField": "process_key",
85
- "action": { "kind": "cancelProcess", "keyField": "process_key" }
85
+ "action": { "path": "/app/actions/cancel", "body": { "processInstanceKey": "{{row.process_key}}" } }
86
86
  }
87
87
  ],
88
88
  "detail": {
@@ -149,9 +149,8 @@
149
149
  "inputLabel": "Your answer",
150
150
  "submitLabel": "Send answer",
151
151
  "action": {
152
- "kind": "publishMessage",
153
- "message": "escalation-answered",
154
- "correlationKeyField": "pr_key"
152
+ "path": "/app/api/actions/message",
153
+ "body": { "name": "escalation-answered", "correlationKey": "{{row.pr_key}}", "variables": "{{form}}" }
155
154
  }
156
155
  }
157
156
  }
@@ -192,7 +191,7 @@
192
191
  "label": "Cancel",
193
192
  "confirm": "Cancel this plan's fan-out run?",
194
193
  "showWhenField": "process_key",
195
- "action": { "kind": "cancelProcess", "keyField": "process_key" }
194
+ "action": { "path": "/app/actions/cancel", "body": { "processInstanceKey": "{{row.process_key}}" } }
196
195
  }
197
196
  ],
198
197
  "detail": {
@@ -301,9 +300,8 @@
301
300
  "inputLabel": "Your answer",
302
301
  "submitLabel": "Send answer",
303
302
  "action": {
304
- "kind": "publishMessage",
305
- "message": "feature-escalation-answered",
306
- "correlationKeyField": "open_task_corr_key"
303
+ "path": "/app/api/actions/message",
304
+ "body": { "name": "feature-escalation-answered", "correlationKey": "{{row.open_task_corr_key}}", "variables": "{{form}}" }
307
305
  }
308
306
  }
309
307
  }
@@ -0,0 +1,12 @@
1
+ engine biome(1.0)
2
+ language js(typescript)
3
+
4
+ `$expr as $type` as $assertion where {
5
+ $type <: not r"^const$",
6
+ $assertion <: not within JsImport(),
7
+ register_diagnostic(
8
+ span = $assertion,
9
+ message = "Type assertions (`as T`) bypass the type system. Use a type guard or `satisfies` instead. If unavoidable, add `// biome-ignore lint/plugin: <reason>` above.",
10
+ severity = "error"
11
+ )
12
+ }
@@ -64,9 +64,10 @@ function templateMap(root: string, patterns: string[]): Record<string, string> {
64
64
  // signal can't see (an empty value carries no `{{token}}` to be unresolved).
65
65
  function hasBlankAgentPromptHeader(bpmn: string): boolean {
66
66
  const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
67
- let m: RegExpExecArray | null;
68
- while ((m = re.exec(bpmn)) !== null) {
67
+ let m = re.exec(bpmn);
68
+ while (m !== null) {
69
69
  if (m[1] === AGENT_PROMPT_HEADER && m[2].trim() === "") return true;
70
+ m = re.exec(bpmn);
70
71
  }
71
72
  return false;
72
73
  }
@@ -86,6 +87,7 @@ export function checkAgentPrompts(root: string): CheckResult {
86
87
  if (!existsSync(manifestPath)) {
87
88
  return { ok: false, errors: [`nano.app.json not found under ${root}`], resolved: [] };
88
89
  }
90
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
89
91
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
90
92
  const models = manifest.models ?? {};
91
93
  const templates = templateMap(root, models.templates ?? []);
@@ -12,9 +12,11 @@ import { layoutBpmn } from "@nanobpm/urban";
12
12
 
13
13
  // Host-agnostic file I/O: Deno inside a compiled binary, else node:fs under Node — mirrors
14
14
  // app/plan.ts's readAsset seam so this runs the same under `npm run` and `deno task`.
15
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
15
16
  const g = globalThis as {
16
17
  Deno?: {
17
18
  args: string[];
19
+ exit(c: number): never;
18
20
  readDir(p: string): AsyncIterable<{ name: string; isFile: boolean }>;
19
21
  readTextFile(p: string): Promise<string>;
20
22
  writeTextFile(p: string, s: string): Promise<void>;
@@ -52,7 +54,7 @@ const countDi = (xml: string) => ({
52
54
  });
53
55
 
54
56
  function exit(code: number): never {
55
- if (g.Deno) return (globalThis as { Deno?: { exit(c: number): never } }).Deno!.exit(code);
57
+ if (g.Deno) return g.Deno.exit(code);
56
58
  process.exit(code);
57
59
  }
58
60
 
@@ -51,7 +51,8 @@ for (const suffix of ["", "-wal", "-shm"]) {
51
51
  rmSync(path + suffix);
52
52
  console.log(`removed ${path}${suffix}`);
53
53
  } catch (err) {
54
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
54
+ const code = typeof err === "object" && err !== null ? Reflect.get(err, "code") : undefined;
55
+ if (code !== "ENOENT") throw err;
55
56
  }
56
57
  }
57
58
  console.log("app db purged");
@@ -29,6 +29,8 @@
29
29
  // (skips `npm pack`; --version/--package are ignored)
30
30
  // --force overlay even if the cwd doesn't look like this app
31
31
  // -h, --help show this help
32
+
33
+ import { execFileSync } from "node:child_process";
32
34
  import {
33
35
  cpSync,
34
36
  existsSync,
@@ -39,7 +41,6 @@ import {
39
41
  rmSync,
40
42
  statSync,
41
43
  } from "node:fs";
42
- import { execFileSync } from "node:child_process";
43
44
  import { tmpdir } from "node:os";
44
45
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
45
46
 
@@ -199,10 +200,12 @@ function runTar(tarArgs: string[], tgz: string): string {
199
200
  try {
200
201
  return execFileSync("tar", tarArgs, { encoding: "utf8" });
201
202
  } catch (e) {
202
- if ((e as NodeJS.ErrnoException).code === "ENOENT") {
203
+ const code = typeof e === "object" && e !== null ? Reflect.get(e, "code") : undefined;
204
+ if (code === "ENOENT") {
203
205
  throw new Error(`'tar' not found on PATH — install it to extract ${tgz}`, { cause: e });
204
206
  }
205
- throw new Error(`tar failed on ${tgz}: ${(e as Error).message}`, { cause: e });
207
+ const message = e instanceof Error ? e.message : String(e);
208
+ throw new Error(`tar failed on ${tgz}: ${message}`, { cause: e });
206
209
  }
207
210
  }
208
211
 
@@ -329,6 +332,7 @@ function report(label: string, files: string[]): void {
329
332
  try {
330
333
  main();
331
334
  } catch (err) {
332
- console.error(`upgrade failed: ${(err as Error).message}`);
335
+ const message = err instanceof Error ? err.message : String(err);
336
+ console.error(`upgrade failed: ${message}`);
333
337
  process.exit(1);
334
338
  }
@@ -2,9 +2,9 @@
2
2
  // merge stage (start the `merge-loop` process and park the PR in `waiting_deps`) when auto-merge
3
3
  // is on, or (b) close the PR out as `converged` (review-only mode).
4
4
  import type { AppJobHandler } from "@nanobpm/urban";
5
- import { AUTO_MERGE, ensurePr, startMerge } from "../../app/service.ts";
6
5
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
7
6
  import { maybeStartRetro } from "../../app/retro.ts";
7
+ import { AUTO_MERGE, ensurePr, startMerge } from "../../app/service.ts";
8
8
 
9
9
  // Extends Record so the declared fields are typed while the job may still carry
10
10
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -22,6 +22,7 @@ interface In extends Record<string, unknown> {
22
22
 
23
23
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
24
24
  function transcriptOf(vars: Record<string, unknown>): string | null {
25
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
25
26
  const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
26
27
  return typeof env?.output === "string" ? env.output : null;
27
28
  }
@@ -9,11 +9,11 @@
9
9
  // calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
10
10
  // shapes the escalation payload on a block.
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
- import { enqueueViaComment, mergePr } from "../../app/github.ts";
13
- import { MERGE_ADMIN, MERGE_METHOD, ensurePr } from "../../app/service.ts";
14
12
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
15
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
13
  import { checkBaseTarget } from "../../app/baseGuard.ts";
14
+ import { enqueueViaComment, mergePr } from "../../app/github.ts";
15
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
+ import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
17
17
 
18
18
  interface In extends Record<string, unknown> {
19
19
  prKey: string;
@@ -3,8 +3,8 @@
3
3
  // and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
4
4
  // `escalationId` for the UI.
5
5
  import type { AppJobHandler } from "@nanobpm/urban";
6
- import { ensurePr, parsePr } from "../../app/service.ts";
7
6
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
7
+ import { ensurePr, parsePr } from "../../app/service.ts";
8
8
 
9
9
  // Extends Record so the declared fields are typed while the job may still carry
10
10
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -39,6 +39,7 @@ function nonBlank(v: unknown): string | undefined {
39
39
 
40
40
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
41
41
  function transcriptOf(vars: Record<string, unknown>): string | null {
42
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
42
43
  const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
43
44
  return typeof env?.output === "string" ? env.output : null;
44
45
  }
@@ -5,8 +5,8 @@
5
5
  // Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
6
6
  // `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
7
7
  import type { AppJobHandler } from "@nanobpm/urban";
8
- import { ensurePr, parsePr } from "../../app/service.ts";
9
8
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
9
+ import { ensurePr, parsePr } from "../../app/service.ts";
10
10
 
11
11
  // Extends Record so the declared fields are typed while the job may still carry
12
12
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -29,6 +29,7 @@ interface In extends Record<string, unknown> {
29
29
  // for audit so a human can see what the agent did this round.
30
30
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
31
31
  function transcriptOf(vars: Record<string, unknown>): string | null {
32
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
32
33
  const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
33
34
  return typeof env?.output === "string" ? env.output : null;
34
35
  }
@@ -16,8 +16,9 @@
16
16
  // the whole epic complete GREEN having done nothing. The cap still bounds the loop; it now bounds
17
17
  // it into an incident, not a silent proceed. A missing/ambiguous `approved` is treated as NOT
18
18
  // approved (revise until the cap).
19
- import { BpmnError } from "@nanobpm/urban";
19
+
20
20
  import type { AppJobHandler } from "@nanobpm/urban";
21
+ import { BpmnError } from "@nanobpm/urban";
21
22
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
22
23
 
23
24
  interface In extends Record<string, unknown> {
@@ -10,8 +10,9 @@
10
10
  // • zero PRs opened → record a `failed` outcome for observability, then throw the non-retryable
11
11
  // `NO_WORK_DISPATCHED` BpmnError so the engine parks the instance on an incident instead of
12
12
  // completing green (issue #86). The `failed` status is terminal, so `startPlan` can re-plan it.
13
- import { BpmnError } from "@nanobpm/urban";
13
+
14
14
  import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { BpmnError } from "@nanobpm/urban";
15
16
  import { planTasks } from "../../app/plan.ts";
16
17
 
17
18
  interface In extends Record<string, unknown> {
@@ -4,9 +4,9 @@
4
4
  import type { AppJobHandler } from "@nanobpm/urban";
5
5
  import {
6
6
  recordTrialMergeAudit,
7
+ type TrialMergeResult,
7
8
  trialMergeDecision,
8
9
  trialMergeTaskId,
9
- type TrialMergeResult,
10
10
  } from "../../app/trialMerge.ts";
11
11
 
12
12
  interface In extends Record<string, unknown> {
@@ -26,7 +26,6 @@ interface Out extends Record<string, unknown> {
26
26
  summary?: string;
27
27
  }
28
28
 
29
- const RESULTS = new Set<TrialMergeResult>(["clean", "merge-conflict", "suite-failed"]);
30
29
  const str = (v: unknown): string | undefined =>
31
30
  typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
32
31
  const waveNo = (v: unknown): number => {
@@ -43,7 +42,8 @@ const safeJson = (v: unknown): string => {
43
42
 
44
43
  function parseResult(v: unknown): TrialMergeResult {
45
44
  const s = typeof v === "string" ? v.trim() : "";
46
- return RESULTS.has(s as TrialMergeResult) ? (s as TrialMergeResult) : "suite-failed";
45
+ if (s === "clean" || s === "merge-conflict" || s === "suite-failed") return s;
46
+ return "suite-failed";
47
47
  }
48
48
 
49
49
  const handler: AppJobHandler<In, Out> = async (job, app) => {
@@ -52,8 +52,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
52
52
  const result = parseResult(job.variables.result);
53
53
  const summary = str(job.variables.summary) ??
54
54
  (result === "suite-failed" ? "Trial merge suite failed or returned no machine-readable result" : result);
55
- const rawJobKey = (job as { key?: unknown }).key;
56
- const jobKey = rawJobKey == null ? null : String(rawJobKey);
55
+ const legacyJobKey = Reflect.get(job, "key");
56
+ const jobKey = job.jobKey ?? (legacyJobKey == null ? null : String(legacyJobKey));
57
57
 
58
58
  try {
59
59
  await recordTrialMergeAudit(app.data, {
@@ -80,7 +80,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
80
80
  trialMergeRed,
81
81
  summary,
82
82
  task: { id: trialMergeTaskId(wave), title: `Trial merge gate for wave ${wave}` },
83
- question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly \"proceed\" only to override and continue without rerunning.`,
83
+ question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly "proceed" only to override and continue without rerunning.`,
84
84
  };
85
85
  };
86
86
 
@@ -14,19 +14,19 @@
14
14
  // Enrollment lives here (not in the finalizer) so a PR is enrolled the moment its wave lands —
15
15
  // and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
16
16
  import type { AppJobHandler } from "@nanobpm/urban";
17
+ import { appendEntry } from "../../app/blackboard.ts";
18
+ import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
19
+ import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
20
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
17
21
  import {
18
22
  type PlanTask,
19
23
  type PlanTaskStatus,
24
+ plans,
20
25
  planTaskDeps,
21
26
  planTasks,
22
- plans,
23
27
  } from "../../app/plan.ts";
24
28
  import { parsePr, submitPr } from "../../app/service.ts";
25
29
  import { parseTaskDelta, readTaskDeltas, recordTaskDelta } from "../../app/taskDelta.ts";
26
- import { appendEntry } from "../../app/blackboard.ts";
27
- import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
28
- import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
29
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
30
30
  import { shouldRunTrialMerge, type TrialMergeHead } from "../../app/trialMerge.ts";
31
31
 
32
32
  interface Result {
@@ -61,9 +61,8 @@ const str = (v: unknown): string | undefined =>
61
61
  // including a missing status — is treated as `blocked`: we must not assume a PR was opened,
62
62
  // and we only hand off / persist a PR when the status is `opened`.
63
63
  type WaveResultStatus = Extract<PlanTaskStatus, "opened" | "blocked" | "skipped">;
64
- const ALLOWED_STATUSES = new Set<WaveResultStatus>(["opened", "blocked", "skipped"]);
65
64
  const isWaveResultStatus = (s: string): s is WaveResultStatus =>
66
- ALLOWED_STATUSES.has(s as WaveResultStatus);
65
+ s === "opened" || s === "blocked" || s === "skipped";
67
66
 
68
67
  // Coerce a wave index/count to a non-negative integer, falling back to 0. A NaN here would make
69
68
  // `nextWave < waveCount` mis-evaluate and end the loop early, leaving tasks `pending`.
@@ -103,7 +102,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
103
102
  const readyHeadsThisWave: { repo: string; number: number | string }[] = [];
104
103
 
105
104
  for (let i = 0; i < waveTasks.length; i++) {
106
- const taskId = str((waveTasks[i] ?? {}).id);
105
+ const taskId = str(waveTasks[i]?.id);
107
106
  if (!taskId) continue;
108
107
  const res = results[i] ?? {};
109
108
  const rawStatus = str(res.status);
@@ -10,7 +10,6 @@ import type { AppJobHandler } from "@nanobpm/urban";
10
10
  import { recordRetro } from "../../app/retro.ts";
11
11
 
12
12
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
13
- const VALID_STATUSES = new Set(["filed", "skipped", "blocked"]);
14
13
 
15
14
  interface In extends Record<string, unknown> {
16
15
  planKey: string;
@@ -26,9 +25,9 @@ function asStr(v: unknown): string | null {
26
25
 
27
26
  function asStatus(v: unknown, hasPr: boolean): "filed" | "skipped" | "blocked" {
28
27
  const s = asStr(v);
29
- if (s && VALID_STATUSES.has(s)) {
28
+ if (s === "filed" || s === "skipped" || s === "blocked") {
30
29
  if (s === "filed" && !hasPr) return "skipped";
31
- return s as "filed" | "skipped" | "blocked";
30
+ return s;
32
31
  }
33
32
  return hasPr ? "filed" : "skipped";
34
33
  }
@@ -42,6 +41,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
42
41
  const prKey = status === "filed" ? rawPrKey : null;
43
42
  const summary = asStr(job.variables.summary);
44
43
 
44
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
45
45
  const env = job.variables[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
46
46
  const report = typeof env?.output === "string" ? env.output : null;
47
47