@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
@@ -11,7 +11,7 @@ permissions:
11
11
 
12
12
  jobs:
13
13
  typecheck:
14
- name: typecheck (Node + Deno)
14
+ name: typecheck + test (Node)
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
17
  - name: Checkout
@@ -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
 
@@ -38,23 +41,13 @@ jobs:
38
41
  - name: Check BPMN diagram freshness (layout)
39
42
  run: npm run layout:check
40
43
 
41
- - name: Setup Deno
42
- uses: denoland/setup-deno@v2
43
- with:
44
- deno-version: v2.x
45
-
46
- - name: Typecheck (Deno)
47
- run: deno check main.ts 'workers/**/*.ts' 'actions/**/*.ts' 'operations/**/*.ts'
48
-
49
44
  # Deploy-safety gate: every model-authored `{{template}}` agent-prompt header must resolve
50
45
  # to a declared, non-blank prompt (and no agent prompt may ship blank). A broken/blank token
51
46
  # means a prompt-less agent — the root of the empty escalations on nano-bpm #597/#599.
52
47
  - name: Check agent prompt templates
53
- run: deno run -A scripts/check-agent-prompts.ts
54
-
55
- # Runs the app/*.test.ts suite (never exercised by the check globs above)
56
- # AND guards the committed lockfile: --frozen fails the build if deno.lock
57
- # is out of date for the full graph (incl. tests), so lockfile drift — e.g.
58
- # a regenerate that drops the jsr:@std/* test deps — can't be merged.
59
- - name: Test + frozen lockfile guard (Deno)
60
- run: deno test -A --frozen
48
+ run: npm run check:prompts
49
+
50
+ # Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
51
+ # TypeScript types on the fly (Node >= 22.6) no build step.
52
+ - name: Test (Node)
53
+ run: npm test
package/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ path, a second review loop) — extend the canonical one.
51
51
 
52
52
  ### Zero tolerance for warnings, errors, and test failures
53
53
  There are no pre-existing failures or warnings, and you will not allow any to
54
- enter the codebase. `tsc`, `urban check`, `deno check`, and `deno test` must all
54
+ enter the codebase. `tsc`, `urban check`, `biome check`, and `node --test` must all
55
55
  be clean.
56
56
 
57
57
  ### No task without a tracked issue or PR
@@ -138,20 +138,21 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
138
138
 
139
139
  ## Runtime & CI gates
140
140
 
141
- Node-hosted (`node --experimental-strip-types`), but the sources are
142
- **cross-runtime**: workers/actions/`main.ts` also typecheck and run under Deno.
141
+ Node-hosted (`node --experimental-strip-types`); Node is the only runtime. Tests run
142
+ on Node's built-in runner (`node:test`), which strips TypeScript types on the fly.
143
143
  Match CI locally before pushing:
144
144
 
145
145
  ```bash
146
+ npm run lint # biome check (incl. ban-`as` gate)
146
147
  npm run typecheck # tsc --noEmit (Node)
147
148
  npm run check # urban check (manifest validation)
148
149
  npm run layout:check # BPMN diagram freshness (no drift)
149
- deno check main.ts 'workers/**/*.ts' 'actions/**/*.ts' # Deno typecheck
150
- deno test -A # unit tests (run with -A)
150
+ npm run check:prompts # agent-prompt template resolution
151
+ npm test # unit tests (node --test)
151
152
  ```
152
153
 
153
- CI (`.github/workflows/ci.yml`) gates typecheck, `urban check`, `layout:check`,
154
- Deno typecheck, and the Deno test suite. Run `npm run layout <file.bpmn>` after
154
+ CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
155
+ the prompt check, and the Node test suite. Run `npm run layout <file.bpmn>` after
155
156
  any BPMN flow change and commit the regenerated diagram — the `layout:check`
156
157
  gate fails the build otherwise.
157
158
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.37.0](https://github.com/nanobpm/nano-workforce/compare/v0.36.0...v0.37.0) (2026-08-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * remove Deno — Node is the only runtime ([#108](https://github.com/nanobpm/nano-workforce/issues/108)) ([d077e9c](https://github.com/nanobpm/nano-workforce/commit/d077e9c94696e2030a2033ad53a05c86a4c18a3e)), closes [#test-assert](https://github.com/nanobpm/nano-workforce/issues/test-assert)
7
+
8
+ # [0.36.0](https://github.com/nanobpm/nano-workforce/compare/v0.35.2...v0.36.0) (2026-08-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * 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)
14
+
1
15
  ## [0.35.2](https://github.com/nanobpm/nano-workforce/compare/v0.35.1...v0.35.2) (2026-08-10)
2
16
 
3
17
 
package/README.md CHANGED
@@ -84,8 +84,7 @@ it's the same app and the same behaviour.
84
84
  - A running **Nano gateway/engine** (default `http://localhost:8080`) — what the app
85
85
  deploys to and what agents pull jobs from.
86
86
  - **[Node](https://nodejs.org/) >= 22.6** (the app hosts on Node built-ins;
87
- `@nanobpm/urban` declares `engines.node >=22.6`). **[Deno](https://deno.land/)** is
88
- optional (only for `npm run compile`).
87
+ `@nanobpm/urban` declares `engines.node >=22.6`).
89
88
  - The **c8ctl CLI with the `nano` plugin** (to hire/run agents).
90
89
  - On each agent host: the **GitHub CLI** logged in (`gh auth login`) or a
91
90
  `GITHUB_TOKEN`/`GH_TOKEN` in the environment, plus the agent harness itself (e.g.
@@ -277,25 +276,6 @@ per-instance context (e.g. a human's escalation answer) is appended by the harne
277
276
 
278
277
  ---
279
278
 
280
- ## Compile to a native binary
281
-
282
- The app is authored on Node built-ins, so **Deno** can cross-compile it into a native
283
- executable that bundles the runtime, deps, and entry code (the target box needs no
284
- Node/Deno/`node_modules`):
285
-
286
- ```sh
287
- npm run compile # → dist/nano-workforce (via deno compile)
288
- ```
289
-
290
- > **Not yet a single self-contained file.** The binary bundles the runtime + deps +
291
- > entry code, but the Urban runtime loads the app's declarative files
292
- > (`nano.app.json`, `pages/`, `db/migrations/`, `prompts/`, `resources/`, and the
293
- > `actions/`/`workers/` modules) from the **working directory at runtime** — so ship
294
- > the binary alongside those app files and run it from that directory. Add `--target`
295
- > in the `deno.json` `compile` task to cross-compile for another OS/arch.
296
-
297
- ---
298
-
299
279
  ## Architecture & contributing
300
280
 
301
281
  - **[SPEC.md](SPEC.md)** — the behavioural source of truth for the processes.
@@ -1,28 +1,23 @@
1
1
  // Tests for the GET /hooks/abandon endpoint (issue #76).
2
- import { assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import type { AppApi } from "@nanobpm/urban";
4
5
  import handler from "./abandon.ts";
5
6
 
6
- // deno-lint-ignore no-explicit-any
7
7
  function memApp(): { app: AppApi } {
8
- // deno-lint-ignore no-explicit-any
9
8
  const stores: Record<string, any[]> = {};
10
9
  function tbl(name: string) {
11
- // deno-lint-ignore no-explicit-any
12
10
  const rows = (stores[name] ??= [] as any[]);
13
11
  return {
14
- // deno-lint-ignore no-explicit-any require-await
15
12
  async insert(row: any) {
16
13
  rows.push({ ...row });
17
14
  return row.pr_key;
18
15
  },
19
- // deno-lint-ignore no-explicit-any require-await
20
16
  async findOne(where: any = {}) {
21
17
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
22
18
  },
23
19
  };
24
20
  }
25
- // deno-lint-ignore no-explicit-any
26
21
  const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
27
22
  return { app };
28
23
  }
@@ -38,9 +33,7 @@ function req(method: string, query: Record<string, string>) {
38
33
  }
39
34
 
40
35
  async function call(app: AppApi, method: string, query: Record<string, string>) {
41
- // deno-lint-ignore no-explicit-any
42
36
  const res = await handler({ req: req(method, query) as any, body: undefined }, app);
43
- // deno-lint-ignore no-explicit-any
44
37
  return res as any;
45
38
  }
46
39
 
@@ -48,18 +41,18 @@ async function seedPr(app: AppApi, prKey: string, token: string, status: string)
48
41
  await app.data.table("pull_requests", "pr_key").insert({ pr_key: prKey, abandon_token: token, status });
49
42
  }
50
43
 
51
- Deno.test("missing token → 400", async () => {
44
+ test("missing token → 400", async () => {
52
45
  const { app } = memApp();
53
46
  assertEquals((await call(app, "GET", {})).status, 400);
54
47
  });
55
48
 
56
- Deno.test("unknown token → 404 (does not reveal PRs)", async () => {
49
+ test("unknown token → 404 (does not reveal PRs)", async () => {
57
50
  const { app } = memApp();
58
51
  await seedPr(app, "o/r#1", "good", "converging");
59
52
  assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
60
53
  });
61
54
 
62
- Deno.test("a running PR → { abandoned: false }", async () => {
55
+ test("a running PR → { abandoned: false }", async () => {
63
56
  const { app } = memApp();
64
57
  await seedPr(app, "o/r#1", "tok", "converging");
65
58
  const res = await call(app, "GET", { token: "tok" });
@@ -67,7 +60,7 @@ Deno.test("a running PR → { abandoned: false }", async () => {
67
60
  assertEquals(res.body, { prKey: "o/r#1", status: "converging", abandoned: false });
68
61
  });
69
62
 
70
- Deno.test("a cancelled PR → { abandoned: true }", async () => {
63
+ test("a cancelled PR → { abandoned: true }", async () => {
71
64
  const { app } = memApp();
72
65
  await seedPr(app, "o/r#1", "tok", "abandoned");
73
66
  const res = await call(app, "GET", { token: "tok" });
@@ -75,18 +68,17 @@ Deno.test("a cancelled PR → { abandoned: true }", async () => {
75
68
  assertEquals(res.body.abandoned, true);
76
69
  });
77
70
 
78
- Deno.test("token via header is accepted", async () => {
71
+ test("token via header is accepted", async () => {
79
72
  const { app } = memApp();
80
73
  await seedPr(app, "o/r#1", "tok", "abandoned");
81
74
  const r = req("GET", {});
82
75
  r.headers.set("x-abandon-token", "tok");
83
- // deno-lint-ignore no-explicit-any
84
76
  const res = await handler({ req: r as any, body: undefined }, app) as any;
85
77
  assertEquals(res.status, 200);
86
78
  assertEquals(res.body.abandoned, true);
87
79
  });
88
80
 
89
- Deno.test("non-GET → 405", async () => {
81
+ test("non-GET → 405", async () => {
90
82
  const { app } = memApp();
91
83
  await seedPr(app, "o/r#1", "tok", "converging");
92
84
  assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
@@ -1,18 +1,15 @@
1
1
  // Tests for the /hooks/blackboard endpoint (Tier 1, issues #51 / #49 D4).
2
- import { assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import type { AppApi } from "@nanobpm/urban";
4
5
  import handler from "./blackboard.ts";
5
6
 
6
- // deno-lint-ignore no-explicit-any
7
7
  function memApp(): { app: AppApi; stores: Record<string, any[]> } {
8
- // deno-lint-ignore no-explicit-any
9
8
  const stores: Record<string, any[]> = {};
10
9
  const seq: Record<string, number> = {};
11
10
  function tbl(name: string, pk = "id") {
12
- // deno-lint-ignore no-explicit-any
13
11
  const rows = (stores[name] ??= [] as any[]);
14
12
  return {
15
- // deno-lint-ignore no-explicit-any require-await
16
13
  async insert(row: any) {
17
14
  if (pk === "id") {
18
15
  const id = (seq[name] = (seq[name] ?? 0) + 1);
@@ -22,17 +19,14 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
22
19
  rows.push({ ...row });
23
20
  return row[pk];
24
21
  },
25
- // deno-lint-ignore no-explicit-any require-await
26
22
  async find(where: any = {}) {
27
23
  return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
28
24
  },
29
- // deno-lint-ignore no-explicit-any require-await
30
25
  async findOne(where: any = {}) {
31
26
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
27
  },
33
28
  };
34
29
  }
35
- // deno-lint-ignore no-explicit-any
36
30
  const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) } } as any as AppApi;
37
31
  return { app, stores };
38
32
  }
@@ -53,9 +47,7 @@ async function call(
53
47
  query: Record<string, string>,
54
48
  body?: unknown,
55
49
  ) {
56
- // deno-lint-ignore no-explicit-any
57
50
  const res = await handler({ req: req(method, query) as any, body }, app);
58
- // deno-lint-ignore no-explicit-any
59
51
  return res as any;
60
52
  }
61
53
 
@@ -63,18 +55,18 @@ async function seedPlan(app: AppApi, planKey: string, token: string) {
63
55
  await app.data.table("plans", "plan_key").insert({ plan_key: planKey, blackboard_token: token });
64
56
  }
65
57
 
66
- Deno.test("missing token → 400", async () => {
58
+ test("missing token → 400", async () => {
67
59
  const { app } = memApp();
68
60
  assertEquals((await call(app, "GET", {})).status, 400);
69
61
  });
70
62
 
71
- Deno.test("unknown token → 404 (does not reveal plans)", async () => {
63
+ test("unknown token → 404 (does not reveal plans)", async () => {
72
64
  const { app } = memApp();
73
65
  await seedPlan(app, "o/r#1", "good");
74
66
  assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
75
67
  });
76
68
 
77
- Deno.test("POST appends then GET reads back, scoped by the token's plan", async () => {
69
+ test("POST appends then GET reads back, scoped by the token's plan", async () => {
78
70
  const { app } = memApp();
79
71
  await seedPlan(app, "o/r#1", "tok");
80
72
  const post = await call(app, "POST", { token: "tok" }, {
@@ -94,13 +86,13 @@ Deno.test("POST appends then GET reads back, scoped by the token's plan", async
94
86
  assertEquals(get.body.entries[0].kind, "file-claim");
95
87
  });
96
88
 
97
- Deno.test("POST with a blank body → 400", async () => {
89
+ test("POST with a blank body → 400", async () => {
98
90
  const { app } = memApp();
99
91
  await seedPlan(app, "o/r#1", "tok");
100
92
  assertEquals((await call(app, "POST", { token: "tok" }, { body: " " })).status, 400);
101
93
  });
102
94
 
103
- Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
95
+ test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
104
96
  const { app, stores } = memApp();
105
97
  await seedPlan(app, "o/r#1", "tok");
106
98
  const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
@@ -111,7 +103,7 @@ Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", a
111
103
  assertEquals(stores["plan_blackboard"].length, 1);
112
104
  });
113
105
 
114
- Deno.test("GET ?since returns only newer entries", async () => {
106
+ test("GET ?since returns only newer entries", async () => {
115
107
  const { app } = memApp();
116
108
  await seedPlan(app, "o/r#1", "tok");
117
109
  await call(app, "POST", { token: "tok" }, { body: "one" });
@@ -122,7 +114,7 @@ Deno.test("GET ?since returns only newer entries", async () => {
122
114
  assertEquals(tail.body.entries.map((e: { body: string }) => e.body), ["two"]);
123
115
  });
124
116
 
125
- Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
117
+ test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
126
118
  const { app } = memApp();
127
119
  await seedPlan(app, "o/r#1", "tok");
128
120
  await call(app, "POST", { token: "tok" }, { body: "one" });
@@ -135,7 +127,7 @@ Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2
135
127
  assertEquals(caughtUp.body.cursor, all.body.cursor);
136
128
  });
137
129
 
138
- Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
130
+ test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
139
131
  const { app } = memApp();
140
132
  await seedPlan(app, "o/r#1", "tok");
141
133
  const first = await call(app, "POST", { token: "tok" }, {
@@ -158,7 +150,7 @@ Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advis
158
150
  assertEquals(second.body.conflicts[0].file, "engine/state.rs");
159
151
  });
160
152
 
161
- Deno.test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
153
+ test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
162
154
  const { app } = memApp();
163
155
  await seedPlan(app, "o/r#1", "tok");
164
156
  // First claim omits author_task → stored as "system".
@@ -181,14 +173,14 @@ Deno.test("POST file-claim without author_task does not report the caller's own
181
173
  assertEquals(second.body.conflicts, [], "own prior 'system' claim is not a conflict");
182
174
  });
183
175
 
184
- Deno.test("POST a non-file-claim carries no conflicts", async () => {
176
+ test("POST a non-file-claim carries no conflicts", async () => {
185
177
  const { app } = memApp();
186
178
  await seedPlan(app, "o/r#1", "tok");
187
179
  const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
188
180
  assertEquals(res.body.conflicts, []);
189
181
  });
190
182
 
191
- Deno.test("unsupported method → 405", async () => {
183
+ test("unsupported method → 405", async () => {
192
184
  const { app } = memApp();
193
185
  await seedPlan(app, "o/r#1", "tok");
194
186
  assertEquals((await call(app, "DELETE", { token: "tok" })).status, 405);
@@ -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);
@@ -1,5 +1,6 @@
1
1
  // Tests for the cooperative abandon-check helpers (issue #76).
2
- import { assertEquals, assertNotEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals, assertNotEquals } from "#test-assert";
3
4
  import type { DataLayer } from "@nanobpm/urban";
4
5
  import {
5
6
  abandonStatusForToken,
@@ -11,26 +12,20 @@ import {
11
12
  renderAbandonBrief,
12
13
  } from "./abandon.ts";
13
14
 
14
- // deno-lint-ignore no-explicit-any
15
15
  function memData(): DataLayer {
16
- // deno-lint-ignore no-explicit-any
17
16
  const stores: Record<string, any[]> = {};
18
17
  function tbl(name: string) {
19
- // deno-lint-ignore no-explicit-any
20
18
  const rows = (stores[name] ??= [] as any[]);
21
19
  return {
22
- // deno-lint-ignore no-explicit-any require-await
23
20
  async insert(row: any) {
24
21
  rows.push({ ...row });
25
22
  return row.pr_key;
26
23
  },
27
- // deno-lint-ignore no-explicit-any require-await
28
24
  async findOne(where: any = {}) {
29
25
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
30
26
  },
31
27
  };
32
28
  }
33
- // deno-lint-ignore no-explicit-any
34
29
  return { table: (n: string) => tbl(n) } as any as DataLayer;
35
30
  }
36
31
 
@@ -38,7 +33,7 @@ async function seedPr(data: DataLayer, pr_key: string, abandon_token: string, st
38
33
  await data.table("pull_requests", "pr_key").insert({ pr_key, abandon_token, status });
39
34
  }
40
35
 
41
- Deno.test("isAbandoned is true only for the 'abandoned' status", () => {
36
+ test("isAbandoned is true only for the 'abandoned' status", () => {
42
37
  assertEquals(isAbandoned("abandoned"), true);
43
38
  assertEquals(isAbandoned("converging"), false);
44
39
  assertEquals(isAbandoned("converged"), false);
@@ -47,7 +42,7 @@ Deno.test("isAbandoned is true only for the 'abandoned' status", () => {
47
42
  assertEquals(isAbandoned(undefined), false);
48
43
  });
49
44
 
50
- Deno.test("mintAbandonToken is url-safe, unpadded, and unique", () => {
45
+ test("mintAbandonToken is url-safe, unpadded, and unique", () => {
51
46
  const a = mintAbandonToken();
52
47
  const b = mintAbandonToken();
53
48
  assertNotEquals(a, b);
@@ -55,21 +50,21 @@ Deno.test("mintAbandonToken is url-safe, unpadded, and unique", () => {
55
50
  assertEquals(a.includes("="), false);
56
51
  });
57
52
 
58
- Deno.test("abandonUrl carries the token on the query string (url-encoded)", () => {
53
+ test("abandonUrl carries the token on the query string (url-encoded)", () => {
59
54
  assertEquals(
60
55
  abandonUrl("tok+/=", "https://host"),
61
56
  "https://host/hooks/abandon?token=tok%2B%2F%3D",
62
57
  );
63
58
  });
64
59
 
65
- Deno.test("abandonTokenFromUrl round-trips the token minted into an abandonUrl", () => {
60
+ test("abandonTokenFromUrl round-trips the token minted into an abandonUrl", () => {
66
61
  const tok = mintAbandonToken();
67
62
  assertEquals(abandonTokenFromUrl(abandonUrl(tok, "https://host")), tok);
68
63
  // url-special tokens survive the encode/decode round-trip too.
69
64
  assertEquals(abandonTokenFromUrl(abandonUrl("tok+/=", "https://host")), "tok+/=");
70
65
  });
71
66
 
72
- Deno.test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input", () => {
67
+ test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input", () => {
73
68
  assertEquals(abandonTokenFromUrl(undefined), undefined);
74
69
  assertEquals(abandonTokenFromUrl(null), undefined);
75
70
  assertEquals(abandonTokenFromUrl(""), undefined);
@@ -77,7 +72,7 @@ Deno.test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage in
77
72
  assertEquals(abandonTokenFromUrl("not a url"), undefined, "unparseable input never throws");
78
73
  });
79
74
 
80
- Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
75
+ test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
81
76
  const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
82
77
  assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
83
78
  assertEquals(brief.includes("abandoned"), true);
@@ -87,7 +82,7 @@ Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", ()
87
82
  assertEquals(brief.includes("curl -fsS"), true);
88
83
  });
89
84
 
90
- Deno.test("prKeyForAbandonToken resolves a known token and rejects unknowns", async () => {
85
+ test("prKeyForAbandonToken resolves a known token and rejects unknowns", async () => {
91
86
  const data = memData();
92
87
  await seedPr(data, "o/r#1", "tok", "converging");
93
88
  assertEquals(await prKeyForAbandonToken(data, "tok"), "o/r#1");
@@ -95,7 +90,7 @@ Deno.test("prKeyForAbandonToken resolves a known token and rejects unknowns", as
95
90
  assertEquals(await prKeyForAbandonToken(data, ""), undefined);
96
91
  });
97
92
 
98
- Deno.test("abandonStatusForToken derives abandoned from the row status", async () => {
93
+ test("abandonStatusForToken derives abandoned from the row status", async () => {
99
94
  const data = memData();
100
95
  await seedPr(data, "o/r#1", "live", "converging");
101
96
  await seedPr(data, "o/r#2", "dead", "abandoned");
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the dead-end-base guard decision (#60).
2
- import { assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import { type BaseTarget, isDeadEndBase } from "./baseGuard.ts";
4
5
 
5
6
  const t = (base: string, defaultBranch: string, landed: BaseTarget["landed"]): BaseTarget => ({
@@ -8,28 +9,28 @@ const t = (base: string, defaultBranch: string, landed: BaseTarget["landed"]): B
8
9
  landed,
9
10
  });
10
11
 
11
- Deno.test("dead-end: a non-default base that has already landed", () => {
12
+ test("dead-end: a non-default base that has already landed", () => {
12
13
  // The exact #54 case: base 'feat/coordination-blackboard' merged to 'main' but wasn't deleted.
13
14
  assertEquals(isDeadEndBase(t("feat/coordination-blackboard", "main", "landed")), true);
14
15
  });
15
16
 
16
- Deno.test("NOT a dead-end: the base IS the default branch (the common straight-to-main PR)", () => {
17
+ test("NOT a dead-end: the base IS the default branch (the common straight-to-main PR)", () => {
17
18
  // Even if a same-named 'main' somehow reported landed, a PR targeting the default branch is the
18
19
  // normal terminal target — never a dead-end.
19
20
  assertEquals(isDeadEndBase(t("main", "main", "landed")), false);
20
21
  });
21
22
 
22
- Deno.test("NOT a dead-end: a live stacked base whose PR is still open", () => {
23
+ test("NOT a dead-end: a live stacked base whose PR is still open", () => {
23
24
  assertEquals(isDeadEndBase(t("feat/tier2", "main", "open")), false);
24
25
  });
25
26
 
26
- Deno.test("NOT a dead-end on ambiguity: base has no PR / transport couldn't tell (unknown)", () => {
27
+ test("NOT a dead-end on ambiguity: base has no PR / transport couldn't tell (unknown)", () => {
27
28
  // We block only on a positive `landed` signal, so a legitimately-stacked feature branch that
28
29
  // simply has no PR yet is never wrongly held.
29
30
  assertEquals(isDeadEndBase(t("feat/tier2", "main", "unknown")), false);
30
31
  });
31
32
 
32
- Deno.test("unknown-safe: blank base or blank default is never a dead-end", () => {
33
+ test("unknown-safe: blank base or blank default is never a dead-end", () => {
33
34
  assertEquals(isDeadEndBase(t("", "main", "landed")), false);
34
35
  assertEquals(isDeadEndBase(t("feat/x", "", "landed")), false);
35
36
  });