@nanobpm/nano-workforce 0.35.2 → 0.37.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.
Files changed (75) hide show
  1. package/.github/workflows/ci.yml +10 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +1 -21
  5. package/actions/abandon.test.ts +8 -16
  6. package/actions/blackboard.test.ts +13 -21
  7. package/actions/blackboard.ts +1 -0
  8. package/actions/feature-answer-hook.ts +1 -0
  9. package/actions/plan-hook.ts +2 -1
  10. package/actions/webhook-submit.ts +2 -1
  11. package/app/abandon.test.ts +10 -15
  12. package/app/baseGuard.test.ts +7 -6
  13. package/app/blackboard.test.ts +22 -32
  14. package/app/blackboard.ts +7 -6
  15. package/app/ensure-pr.test.ts +10 -12
  16. package/app/github.test.ts +9 -8
  17. package/app/github.ts +24 -22
  18. package/app/instance-tracking.test.ts +8 -6
  19. package/app/mergeExclusion.test.ts +11 -20
  20. package/app/mergeExclusion.ts +8 -3
  21. package/app/mergeProtocol.test.ts +14 -13
  22. package/app/mergeProtocol.ts +1 -0
  23. package/app/mergeRebaseArm.test.ts +8 -6
  24. package/app/mergeTrain.test.ts +10 -9
  25. package/app/persist-escalation.test.ts +9 -30
  26. package/app/persist-round.test.ts +6 -19
  27. package/app/plan.test.ts +19 -42
  28. package/app/plan.ts +1 -1
  29. package/app/record-plan-review.test.ts +8 -7
  30. package/app/retro.test.ts +24 -48
  31. package/app/retro.ts +4 -2
  32. package/app/reviewWait.test.ts +12 -11
  33. package/app/rounds.test.ts +13 -12
  34. package/app/service.test.ts +14 -27
  35. package/app/service.ts +8 -4
  36. package/app/taskDelta.test.ts +8 -17
  37. package/app/taskDelta.ts +1 -0
  38. package/app/trialMerge.test.ts +4 -3
  39. package/app/version.ts +9 -21
  40. package/app/waves.test.ts +17 -16
  41. package/biome.json +143 -0
  42. package/operations/getVersion.test.ts +8 -12
  43. package/operations/getVersion.ts +2 -3
  44. package/operations/listActivePrs.test.ts +8 -14
  45. package/operations/listActivePrs.ts +2 -3
  46. package/operations/postMessage.ts +3 -3
  47. package/operations/startAndMessage.test.ts +6 -12
  48. package/package.json +9 -4
  49. package/plugins/no-unsafe-type-assertion.grit +12 -0
  50. package/scripts/check-agent-prompts.test.ts +15 -11
  51. package/scripts/check-agent-prompts.ts +4 -2
  52. package/scripts/layout-bpmn.ts +6 -26
  53. package/scripts/pages-contract.test.ts +14 -13
  54. package/scripts/purge-db.ts +3 -2
  55. package/scripts/upgrade-from-pack.ts +9 -5
  56. package/test/assert.ts +74 -0
  57. package/tsconfig.json +4 -1
  58. package/workers/finalize/worker.ts +2 -1
  59. package/workers/merge/worker.ts +3 -3
  60. package/workers/persist-escalation/worker.ts +2 -1
  61. package/workers/persist-round/worker.ts +2 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-plan-review/worker.ts +2 -1
  64. package/workers/record-results/worker.test.ts +5 -8
  65. package/workers/record-results/worker.ts +2 -1
  66. package/workers/record-trial-merge/worker.test.ts +7 -18
  67. package/workers/record-trial-merge/worker.ts +6 -6
  68. package/workers/record-wave/worker.test.ts +13 -20
  69. package/workers/record-wave/worker.ts +7 -8
  70. package/workers/retro-gather/worker.test.ts +4 -17
  71. package/workers/retro-record/worker.test.ts +8 -27
  72. package/workers/retro-record/worker.ts +3 -3
  73. package/workers/select-wave/worker.test.ts +5 -8
  74. package/deno.json +0 -24
  75. package/deno.lock +0 -1776
@@ -1,19 +1,17 @@
1
1
  // Tests for GET /app/api/status → operation `listActivePrs` (ADR 0058 OpenAPI surface).
2
2
  // Covers the happy path (count/prs projection) and the optional shared-secret guard. A minimal
3
3
  // in-memory DataLayer backs `activePrs` (it reads the `pull_requests` table via `.all()`).
4
- import { assert, assertEquals } from "jsr:@std/assert@1";
4
+ import { test } from "node:test";
5
+ import { assert, assertEquals } from "#test-assert";
5
6
  import type { AppApi } from "@nanobpm/urban";
6
7
  import handler from "./listActivePrs.ts";
7
8
 
8
- // deno-lint-ignore no-explicit-any
9
9
  function memApp(rows: any[]): AppApi {
10
10
  const tbl = {
11
- // deno-lint-ignore require-await
12
11
  async all() {
13
12
  return rows;
14
13
  },
15
14
  };
16
- // deno-lint-ignore no-explicit-any
17
15
  return { data: { table: () => tbl } } as any as AppApi;
18
16
  }
19
17
 
@@ -25,7 +23,6 @@ function input(headers: Record<string, string> = {}) {
25
23
  query: new URLSearchParams(),
26
24
  headers: new Headers(headers),
27
25
  text: async () => "",
28
- // deno-lint-ignore no-explicit-any
29
26
  } as any,
30
27
  params: {},
31
28
  query: {},
@@ -33,13 +30,12 @@ function input(headers: Record<string, string> = {}) {
33
30
  };
34
31
  }
35
32
 
36
- Deno.test("returns 200 with a count + projected active PRs", async () => {
33
+ test("returns 200 with a count + projected active PRs", async () => {
37
34
  const app = memApp([
38
35
  { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", title: "t", status: "converging", current_round: 2, process_key: "9", updated_at: "2026-01-02" },
39
36
  { pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", title: null, status: "converged", current_round: 1, process_key: null, updated_at: "2026-01-01" },
40
37
  ]);
41
38
  const res = await handler(input(), app);
42
- // deno-lint-ignore no-explicit-any
43
39
  const r = res as any;
44
40
  assertEquals(r.status, 200);
45
41
  // `converged` is terminal → filtered out, leaving one active PR.
@@ -49,22 +45,20 @@ Deno.test("returns 200 with a count + projected active PRs", async () => {
49
45
  assertEquals(r.body.prs[0].processKey, "9");
50
46
  });
51
47
 
52
- Deno.test("shared-secret guard rejects a missing secret when configured", async () => {
53
- const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
54
- Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
48
+ test("shared-secret guard rejects a missing secret when configured", async () => {
49
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
50
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
55
51
  try {
56
52
  const mod = await import(`./listActivePrs.ts?guard=${Date.now()}`);
57
53
  const guarded = mod.default as typeof handler;
58
54
  const app = memApp([]);
59
- // deno-lint-ignore no-explicit-any
60
55
  const bad = (await guarded(input(), app)) as any;
61
56
  assertEquals(bad.status, 401);
62
- // deno-lint-ignore no-explicit-any
63
57
  const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
64
58
  assertEquals(ok.status, 200);
65
59
  assert("count" in ok.body);
66
60
  } finally {
67
- if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
68
- else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
61
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
62
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
69
63
  }
70
64
  });
@@ -10,9 +10,8 @@ import { defineOperation } from "@nanobpm/urban";
10
10
  import { type ActivePr, activePrs } from "../app/service.ts";
11
11
  import { envVar } from "../app/version.ts";
12
12
 
13
- // Cross-runtime env read (Node `process.env` OR Deno `Deno.env`): reading via `process.env` alone
14
- // would silently disable the guard under Deno/compiled runtimes where `process` is absent, leaving
15
- // the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
13
+ // The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
14
+ // the x-hook-secret header. Captured once, at module load.
16
15
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
17
16
 
18
17
  type Res = { count: number; prs: ActivePr[] } | { error: string };
@@ -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);
@@ -2,19 +2,17 @@
2
2
  // These cover the app-logic guards the JSON schema can't express (reference parsing, message-name
3
3
  // dispatch); the runtime's schema validation (required `variables`/`name`) is exercised by urban's
4
4
  // own api runtime tests.
5
- import { assertEquals } from "jsr:@std/assert@1";
5
+ import { test } from "node:test";
6
+ import { assertEquals } from "#test-assert";
6
7
  import type { AppApi } from "@nanobpm/urban";
7
8
  import startConvergenceLoop from "./startConvergenceLoop.ts";
8
9
  import startPlanFanout from "./startPlanFanout.ts";
9
10
  import postMessage from "./postMessage.ts";
10
11
 
11
- // deno-lint-ignore no-explicit-any
12
12
  const app = {} as any as AppApi;
13
13
 
14
- // deno-lint-ignore no-explicit-any
15
14
  function input(body: any) {
16
15
  return {
17
- // deno-lint-ignore no-explicit-any
18
16
  req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
19
17
  params: {},
20
18
  query: {},
@@ -22,33 +20,29 @@ function input(body: any) {
22
20
  };
23
21
  }
24
22
 
25
- Deno.test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
23
+ test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
26
24
  const res = await startConvergenceLoop(input({ variables: { pr: "not a pr" } }), app);
27
- // deno-lint-ignore no-explicit-any
28
25
  const r = res as any;
29
26
  assertEquals(r.status, 400);
30
27
  assertEquals(typeof r.body.error, "string");
31
28
  });
32
29
 
33
- Deno.test("startPlanFanout → 400 on an unparseable issue reference", async () => {
30
+ test("startPlanFanout → 400 on an unparseable issue reference", async () => {
34
31
  const res = await startPlanFanout(input({ variables: { issue: "" } }), app);
35
- // deno-lint-ignore no-explicit-any
36
32
  const r = res as any;
37
33
  assertEquals(r.status, 400);
38
34
  assertEquals(typeof r.body.error, "string");
39
35
  });
40
36
 
41
- Deno.test("postMessage → 400 when name is blank", async () => {
37
+ test("postMessage → 400 when name is blank", async () => {
42
38
  const res = await postMessage(input({ name: "" }), app);
43
- // deno-lint-ignore no-explicit-any
44
39
  const r = res as any;
45
40
  assertEquals(r.status, 400);
46
41
  assertEquals(r.body.error, "name is required");
47
42
  });
48
43
 
49
- Deno.test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
44
+ test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
50
45
  const res = await postMessage(input({ name: "escalation-answered", variables: { answer: "yes" } }), app);
51
- // deno-lint-ignore no-explicit-any
52
46
  const r = res as any;
53
47
  assertEquals(r.status, 400);
54
48
  assertEquals(r.body.error, "correlationKey is required");
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.35.2",
3
+ "version": "0.37.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",
7
+ "imports": {
8
+ "#test-assert": "./test/assert.ts"
9
+ },
7
10
  "engines": {
8
11
  "node": ">=22.6"
9
12
  },
@@ -30,19 +33,21 @@
30
33
  "upgrade": "node --experimental-strip-types scripts/upgrade-from-pack.ts",
31
34
  "check": "urban check",
32
35
  "typecheck": "tsc --noEmit",
33
- "check:prompts": "deno run -A scripts/check-agent-prompts.ts",
36
+ "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
34
37
  "gen": "urban gen",
35
38
  "gen:check": "urban gen --check",
36
39
  "layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
37
40
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
38
41
  "dev": "urban dev",
39
- "compile": "deno task compile",
40
- "test": "deno test -A"
42
+ "test": "node --experimental-strip-types --test",
43
+ "lint": "biome check app operations actions workers pages components scripts main.ts",
44
+ "lint:fix": "biome check --write app operations actions workers pages components scripts main.ts"
41
45
  },
42
46
  "dependencies": {
43
47
  "@nanobpm/urban": "^0.33.0"
44
48
  },
45
49
  "devDependencies": {
50
+ "@biomejs/biome": "^2.4.11",
46
51
  "@semantic-release/changelog": "^6.0.3",
47
52
  "@semantic-release/git": "^10.0.1",
48
53
  "@semantic-release/npm": "^13.1.5",
@@ -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
+ }
@@ -4,7 +4,11 @@
4
4
  // blank agent-prompt header — ships an effectively prompt-less agent (the root of the empty
5
5
  // "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). These cases assert it
6
6
  // fails on each of those shapes and passes on a well-formed app.
7
- import { assert, assertEquals } from "jsr:@std/assert@1";
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { dirname, join } from "node:path";
8
12
  import { checkAgentPrompts } from "./check-agent-prompts.ts";
9
13
 
10
14
  const MANIFEST = JSON.stringify({
@@ -16,17 +20,17 @@ function header(value: string): string {
16
20
  }
17
21
 
18
22
  // Build a throwaway app tree and return its root. Each entry maps a repo-relative path to content.
19
- async function fixture(files: Record<string, string>): Promise<string> {
20
- const root = await Deno.makeTempDir({ prefix: "agent-prompts-" });
23
+ function fixture(files: Record<string, string>): string {
24
+ const root = mkdtempSync(join(tmpdir(), "agent-prompts-"));
21
25
  for (const [rel, content] of Object.entries(files)) {
22
- const abs = `${root}/${rel}`;
23
- await Deno.mkdir(abs.slice(0, abs.lastIndexOf("/")), { recursive: true });
24
- await Deno.writeTextFile(abs, content);
26
+ const abs = join(root, rel);
27
+ mkdirSync(dirname(abs), { recursive: true });
28
+ writeFileSync(abs, content);
25
29
  }
26
30
  return root;
27
31
  }
28
32
 
29
- Deno.test("passes when every {{token}} resolves to a non-blank template", async () => {
33
+ test("passes when every {{token}} resolves to a non-blank template", async () => {
30
34
  const root = await fixture({
31
35
  "nano.app.json": MANIFEST,
32
36
  "resources/processes/loop.bpmn": header("{{review-round}}"),
@@ -38,7 +42,7 @@ Deno.test("passes when every {{token}} resolves to a non-blank template", async
38
42
  assertEquals(res.resolved, ["review-round"]);
39
43
  });
40
44
 
41
- Deno.test("fails when a header references an undeclared template", async () => {
45
+ test("fails when a header references an undeclared template", async () => {
42
46
  const root = await fixture({
43
47
  "nano.app.json": MANIFEST,
44
48
  "resources/processes/loop.bpmn": header("{{does-not-exist}}"),
@@ -49,7 +53,7 @@ Deno.test("fails when a header references an undeclared template", async () => {
49
53
  assert(res.errors.some((e) => e.includes("{{does-not-exist}}") && e.includes("no such template")));
50
54
  });
51
55
 
52
- Deno.test("fails when the referenced template file is blank (would substitute to nothing)", async () => {
56
+ test("fails when the referenced template file is blank (would substitute to nothing)", async () => {
53
57
  const root = await fixture({
54
58
  "nano.app.json": MANIFEST,
55
59
  "resources/processes/loop.bpmn": header("{{review-round}}"),
@@ -60,7 +64,7 @@ Deno.test("fails when the referenced template file is blank (would substitute to
60
64
  assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round")));
61
65
  });
62
66
 
63
- Deno.test("fails when a reserved agent-prompt header is blank", async () => {
67
+ test("fails when a reserved agent-prompt header is blank", async () => {
64
68
  const root = await fixture({
65
69
  "nano.app.json": MANIFEST,
66
70
  "resources/processes/loop.bpmn": header(""),
@@ -71,7 +75,7 @@ Deno.test("fails when a reserved agent-prompt header is blank", async () => {
71
75
  assert(res.errors.some((e) => e.includes("is empty")));
72
76
  });
73
77
 
74
- Deno.test("checks the real repo: all committed agent prompts resolve", () => {
78
+ test("checks the real repo: all committed agent prompts resolve", () => {
75
79
  // The guard must be green against the actual app it protects — this is the case CI relies on.
76
80
  const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
77
81
  const res = checkAgentPrompts(repoRoot);
@@ -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 ?? []);
@@ -1,4 +1,4 @@
1
- // npm run layout <file.bpmn ...> (or `deno task layout <file.bpmn ...>`) — (re)generate the
1
+ // npm run layout <file.bpmn ...> — (re)generate the
2
2
  // bpmndi:BPMNDiagram for one or more BPMN models using the urban toolkit's `layoutBpmn`
3
3
  // (bpmn-auto-layout). The semantic model stays authoritative: author the process elements
4
4
  // (tasks, gateways, flows, zeebe extensions) and run this to derive an auto-laid-out diagram,
@@ -8,37 +8,18 @@
8
8
  // `--check` (npm run layout:check) regenerates the DI in memory and fails with a non-zero exit
9
9
  // if any committed diagram is stale, WITHOUT rewriting files — the CI freshness gate that stops
10
10
  // a BPMN flow change from merging with an un-regenerated diagram.
11
+ import { readdir, readFile, writeFile } from "node:fs/promises";
11
12
  import { layoutBpmn } from "@nanobpm/urban";
12
13
 
13
- // Host-agnostic file I/O: Deno inside a compiled binary, else node:fs under Node — mirrors
14
- // app/plan.ts's readAsset seam so this runs the same under `npm run` and `deno task`.
15
- const g = globalThis as {
16
- Deno?: {
17
- args: string[];
18
- readDir(p: string): AsyncIterable<{ name: string; isFile: boolean }>;
19
- readTextFile(p: string): Promise<string>;
20
- writeTextFile(p: string, s: string): Promise<void>;
21
- };
22
- };
23
-
24
14
  async function readText(path: string): Promise<string> {
25
- return g.Deno?.readTextFile
26
- ? await g.Deno.readTextFile(path)
27
- : await (await import("node:fs/promises")).readFile(path, "utf8");
15
+ return await readFile(path, "utf8");
28
16
  }
29
17
  async function writeText(path: string, text: string): Promise<void> {
30
- if (g.Deno?.writeTextFile) return await g.Deno.writeTextFile(path, text);
31
- await (await import("node:fs/promises")).writeFile(path, text, "utf8");
18
+ await writeFile(path, text, "utf8");
32
19
  }
33
20
  async function defaultProcessFiles(): Promise<string[]> {
34
21
  const dir = "resources/processes";
35
- if (g.Deno?.readDir) {
36
- const files: string[] = [];
37
- for await (const e of g.Deno.readDir(dir)) if (e.isFile && e.name.endsWith(".bpmn")) files.push(`${dir}/${e.name}`);
38
- return files.sort();
39
- }
40
- const fs = await import("node:fs/promises");
41
- return (await fs.readdir(dir, { withFileTypes: true }))
22
+ return (await readdir(dir, { withFileTypes: true }))
42
23
  .filter((e) => e.isFile() && e.name.endsWith(".bpmn"))
43
24
  .map((e) => `${dir}/${e.name}`)
44
25
  .sort();
@@ -52,12 +33,11 @@ const countDi = (xml: string) => ({
52
33
  });
53
34
 
54
35
  function exit(code: number): never {
55
- if (g.Deno) return (globalThis as { Deno?: { exit(c: number): never } }).Deno!.exit(code);
56
36
  process.exit(code);
57
37
  }
58
38
 
59
39
  async function main() {
60
- const argv = g.Deno?.args ?? process.argv.slice(2);
40
+ const argv = process.argv.slice(2);
61
41
  // `--check` mode: regenerate the DI in memory and fail (non-zero) if it differs from what's
62
42
  // committed, WITHOUT rewriting any file. This is the CI freshness gate — it catches a BPMN
63
43
  // flow change whose author forgot to re-run `npm run layout`, so a stale diagram can't merge.
@@ -10,7 +10,9 @@
10
10
  // It also pins the issue #87 surfaces: the plan-review audit log (`plan_reviews`) — which is
11
11
  // persisted but was surfaced on no page — must appear on the epic page (flat grid) and inside the
12
12
  // home page's plan detail (child grid). Feature coverage so the trace can't silently regress out.
13
- import { assert } from "jsr:@std/assert@1";
13
+ import { test } from "node:test";
14
+ import { assert } from "#test-assert";
15
+ import { readdirSync, readFileSync } from "node:fs";
14
16
 
15
17
  // Percent-decode the pathname: `new URL(..).pathname` can contain encoded characters (e.g. a space
16
18
  // as `%20`), which `Deno.readDir`/`readTextFile` would fail to resolve. Matches the repo convention
@@ -79,22 +81,21 @@ function splitTopLevel(body: string): string[] {
79
81
  return out;
80
82
  }
81
83
 
82
- async function loadSchema(): Promise<Map<string, Set<string>>> {
84
+ function loadSchema(): Map<string, Set<string>> {
83
85
  const schema = new Map<string, Set<string>>();
84
86
  const files: string[] = [];
85
- for await (const e of Deno.readDir(`${ROOT}db/migrations`)) {
86
- if (e.isFile && e.name.endsWith(".sql")) files.push(e.name);
87
+ for (const e of readdirSync(`${ROOT}db/migrations`, { withFileTypes: true })) {
88
+ if (e.isFile() && e.name.endsWith(".sql")) files.push(e.name);
87
89
  }
88
90
  files.sort(); // migration order doesn't matter for the union, but keep it deterministic
89
91
  for (const f of files) {
90
- parseSchema(await Deno.readTextFile(`${ROOT}db/migrations/${f}`), schema);
92
+ parseSchema(readFileSync(`${ROOT}db/migrations/${f}`, "utf8"), schema);
91
93
  }
92
94
  return schema;
93
95
  }
94
96
 
95
97
  // ---- pages -> datasource references -------------------------------------------------------------
96
98
 
97
- // deno-lint-ignore no-explicit-any
98
99
  type Json = any;
99
100
 
100
101
  interface Ref {
@@ -164,11 +165,11 @@ function collectRefs(page: string, node: Json, out: Ref[]): void {
164
165
  for (const v of Object.values(node)) collectRefs(page, v, out);
165
166
  }
166
167
 
167
- async function loadRefs(): Promise<Ref[]> {
168
+ function loadRefs(): Ref[] {
168
169
  const refs: Ref[] = [];
169
- for await (const e of Deno.readDir(`${ROOT}pages`)) {
170
- if (!e.isFile || !e.name.endsWith(".page.json")) continue;
171
- const page = JSON.parse(await Deno.readTextFile(`${ROOT}pages/${e.name}`));
170
+ for (const e of readdirSync(`${ROOT}pages`, { withFileTypes: true })) {
171
+ if (!e.isFile() || !e.name.endsWith(".page.json")) continue;
172
+ const page = JSON.parse(readFileSync(`${ROOT}pages/${e.name}`, "utf8"));
172
173
  collectRefs(e.name, page, refs);
173
174
  }
174
175
  return refs;
@@ -176,7 +177,7 @@ async function loadRefs(): Promise<Ref[]> {
176
177
 
177
178
  // ---- guards -------------------------------------------------------------------------------------
178
179
 
179
- Deno.test("every page datasource table exists in the migrations", async () => {
180
+ test("every page datasource table exists in the migrations", async () => {
180
181
  const schema = await loadSchema();
181
182
  const refs = await loadRefs();
182
183
  assert(refs.length > 0, "no datasource references found — collector or pages are broken");
@@ -190,7 +191,7 @@ Deno.test("every page datasource table exists in the migrations", async () => {
190
191
  }
191
192
  });
192
193
 
193
- Deno.test("every page datasource column exists on its table", async () => {
194
+ test("every page datasource column exists on its table", async () => {
194
195
  const schema = await loadSchema();
195
196
  const refs = await loadRefs();
196
197
  for (const r of refs) {
@@ -206,7 +207,7 @@ Deno.test("every page datasource column exists on its table", async () => {
206
207
  }
207
208
  });
208
209
 
209
- Deno.test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
210
+ test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
210
211
  const refs = await loadRefs();
211
212
  const onEpic = refs.some((r) => r.page === "epic.page.json" && r.table === "plan_reviews");
212
213
  const onHome = refs.some((r) => r.page === "home.page.json" && r.table === "plan_reviews");
@@ -1,4 +1,4 @@
1
- // npm run purge (or `deno task purge`) — wipe the app's sqlite datasource so `npm start`
1
+ // npm run purge — wipe the app's sqlite datasource so `npm start`
2
2
  // comes up against a fresh schema (the runtime re-applies db/migrations on boot). Deletes the
3
3
  // sqlite file and its WAL/SHM sidecars for the `app` source declared in nano.app.json.
4
4
  import { rmSync } from "node:fs";
@@ -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");
@@ -1,4 +1,4 @@
1
- // npm run upgrade (or `deno task upgrade`) — refresh THIS app's source from a newer
1
+ // npm run upgrade — refresh THIS app's source from a newer
2
2
  // published pack of @nanobpm/nano-workforce, WITHOUT touching your data.
3
3
  //
4
4
  // Why this exists: a Console project stamped from the example pack is a one-time
@@ -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
  }
package/test/assert.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Node-native test assertions, presenting the small `@std/assert` surface this repo's suite used
2
+ // under Deno so the 35 ported `*.test.ts` files keep their call sites unchanged (only their import
3
+ // line moved to `#test-assert`). Backed by `node:assert/strict`. Semantics intentionally mirror
4
+ // Deno's std/assert (deep structural equality; the throw helpers return the caught error).
5
+ import nodeAssert from "node:assert/strict";
6
+
7
+ /** Deep structural equality (Deno `assertEquals`). */
8
+ export function assertEquals<T>(actual: T, expected: T, msg?: string): void {
9
+ nodeAssert.deepStrictEqual(actual, expected, msg);
10
+ }
11
+
12
+ /** Deep structural inequality (Deno `assertNotEquals`). */
13
+ export function assertNotEquals<T>(actual: T, expected: T, msg?: string): void {
14
+ nodeAssert.notDeepStrictEqual(actual, expected, msg);
15
+ }
16
+
17
+ /** Truthiness (Deno `assert`). */
18
+ export function assert(expr: unknown, msg?: string): asserts expr {
19
+ nodeAssert.ok(expr, msg);
20
+ }
21
+
22
+ /** Substring containment (Deno `assertStringIncludes`). */
23
+ export function assertStringIncludes(actual: string, expected: string, msg?: string): void {
24
+ nodeAssert.ok(
25
+ actual.includes(expected),
26
+ msg ?? `expected string to contain "${expected}" but got "${actual}"`,
27
+ );
28
+ }
29
+
30
+ // The constructor rest is `any[]` so error subclasses with required args (e.g. WaveError(message))
31
+ // satisfy the type — param contravariance rejects a narrower `unknown[]`/`never[]` here.
32
+ type ErrorClass = new (...args: any[]) => Error;
33
+
34
+ function checkError(error: unknown, ErrorClass?: ErrorClass, msgIncludes?: string): Error {
35
+ const err = error instanceof Error ? error : new Error(String(error));
36
+ const gotName = err.constructor.name;
37
+ if (ErrorClass && !(err instanceof ErrorClass)) {
38
+ nodeAssert.fail(`expected error to be instance of ${ErrorClass.name}, got ${gotName}`);
39
+ }
40
+ if (msgIncludes && !err.message.includes(msgIncludes)) {
41
+ nodeAssert.fail(`expected error message to include "${msgIncludes}", got "${err.message}"`);
42
+ }
43
+ return err;
44
+ }
45
+
46
+ /** Assert a sync fn throws; optionally check the error type/message. Returns the caught error
47
+ * (Deno `assertThrows`). */
48
+ export function assertThrows(
49
+ fn: () => unknown,
50
+ ErrorClass?: ErrorClass,
51
+ msgIncludes?: string,
52
+ ): Error {
53
+ try {
54
+ fn();
55
+ } catch (error) {
56
+ return checkError(error, ErrorClass, msgIncludes);
57
+ }
58
+ nodeAssert.fail("expected function to throw, but it did not");
59
+ }
60
+
61
+ /** Assert an async fn rejects; optionally check the error type/message. Returns the caught error
62
+ * (Deno `assertRejects`). */
63
+ export async function assertRejects(
64
+ fn: () => Promise<unknown>,
65
+ ErrorClass?: ErrorClass,
66
+ msgIncludes?: string,
67
+ ): Promise<Error> {
68
+ try {
69
+ await fn();
70
+ } catch (error) {
71
+ return checkError(error, ErrorClass, msgIncludes);
72
+ }
73
+ nodeAssert.fail("expected promise to reject, but it resolved");
74
+ }
package/tsconfig.json CHANGED
@@ -37,11 +37,14 @@
37
37
  },
38
38
  "include": [
39
39
  "main.ts",
40
+ "app/**/*.ts",
41
+ "operations/**/*.ts",
40
42
  "workers/**/*.ts",
41
43
  "lib/**/*.ts",
42
44
  "src/**/*.ts",
43
45
  "scripts/**/*.ts",
44
- "actions/**/*.ts"
46
+ "actions/**/*.ts",
47
+ "test/**/*.ts"
45
48
  ],
46
49
  "exclude": [
47
50
  "node_modules",
@@ -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
  }