@nanobpm/nano-workforce 0.36.0 → 0.38.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 +7 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +6 -27
  5. package/SPEC.md +15 -13
  6. package/app/abandon.test.ts +14 -19
  7. package/app/abandon.ts +2 -2
  8. package/app/baseGuard.test.ts +7 -6
  9. package/app/blackboard.test.ts +25 -35
  10. package/app/blackboard.ts +2 -2
  11. package/app/ensure-pr.test.ts +10 -12
  12. package/app/github.test.ts +9 -8
  13. package/app/github.ts +1 -21
  14. package/app/instance-tracking.test.ts +8 -6
  15. package/app/mergeExclusion.test.ts +11 -20
  16. package/app/mergeProtocol.test.ts +14 -13
  17. package/app/mergeRebaseArm.test.ts +8 -6
  18. package/app/mergeTrain.test.ts +10 -9
  19. package/app/persist-escalation.test.ts +10 -31
  20. package/app/persist-round.test.ts +7 -20
  21. package/app/plan.test.ts +19 -42
  22. package/app/record-plan-review.test.ts +8 -7
  23. package/app/retro.test.ts +24 -48
  24. package/app/reviewWait.test.ts +12 -11
  25. package/app/rounds.test.ts +13 -12
  26. package/app/service.test.ts +14 -27
  27. package/app/service.ts +1 -1
  28. package/app/taskDelta.test.ts +8 -17
  29. package/app/trialMerge.test.ts +4 -3
  30. package/app/version.ts +6 -21
  31. package/app/waves.test.ts +17 -16
  32. package/biome.json +0 -2
  33. package/main.ts +3 -3
  34. package/nano.app.json +2 -25
  35. package/openapi.yaml +649 -0
  36. package/operations/answerFeatureEscalation.test.ts +100 -0
  37. package/operations/answerFeatureEscalation.ts +49 -0
  38. package/operations/appendBlackboard.ts +61 -0
  39. package/{actions → operations}/blackboard.test.ts +28 -30
  40. package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
  41. package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
  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 +3 -4
  46. package/operations/postMessage.ts +1 -1
  47. package/operations/readBlackboard.ts +29 -0
  48. package/operations/startAndMessage.test.ts +11 -15
  49. package/operations/startConvergenceLoop.ts +17 -11
  50. package/operations/startPlanFanout.ts +13 -7
  51. package/package.json +9 -7
  52. package/pages/epic.page.json +1 -1
  53. package/pages/home.page.json +1 -1
  54. package/scripts/check-agent-prompts.test.ts +15 -11
  55. package/scripts/layout-bpmn.ts +6 -28
  56. package/scripts/pages-contract.test.ts +14 -13
  57. package/scripts/purge-db.ts +1 -1
  58. package/scripts/upgrade-from-pack.ts +1 -1
  59. package/test/assert.ts +74 -0
  60. package/tsconfig.json +4 -1
  61. package/workers/persist-task-escalation/worker.ts +1 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-results/worker.test.ts +5 -8
  64. package/workers/record-trial-merge/worker.test.ts +7 -18
  65. package/workers/record-wave/worker.test.ts +13 -20
  66. package/workers/retro-gather/worker.test.ts +4 -17
  67. package/workers/retro-record/worker.test.ts +8 -27
  68. package/workers/select-wave/worker.test.ts +5 -8
  69. package/actions/blackboard.ts +0 -77
  70. package/actions/feature-answer-hook.ts +0 -45
  71. package/actions/plan-hook.ts +0 -20
  72. package/actions/webhook-submit.ts +0 -22
  73. package/deno.json +0 -24
  74. package/deno.lock +0 -1777
  75. package/openapi.json +0 -248
package/app/blackboard.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // stable `dedupe_key` is a no-op (backed by a unique index; we also short-circuit here).
14
14
  // - CAPABILITY URL. The per-plan token IS the credential; the agent curls the exact URL it was
15
15
  // handed (delivered in `appendPrompt`). Delivery is in-band (rides the prompt the harness
16
- // already forwards); use is out-of-band (a direct side-channel to `/hooks/blackboard`).
16
+ // already forwards); use is out-of-band (a direct side-channel to `/app/api/hooks/blackboard`).
17
17
  //
18
18
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
19
19
  // app/service.ts and app/plan.ts.
@@ -87,7 +87,7 @@ export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBL
87
87
  /** The capability URL for a plan's blackboard: the token rides the query string, so the agent can
88
88
  * GET/POST the exact string it was handed with no header assembly. */
89
89
  export function blackboardUrl(token: string, base: string = publicBaseUrl()): string {
90
- return `${base}/hooks/blackboard?token=${encodeURIComponent(token)}`;
90
+ return `${base}/app/api/hooks/blackboard?token=${encodeURIComponent(token)}`;
91
91
  }
92
92
 
93
93
  /** The coordination-protocol block appended (verbatim, via `appendPrompt`) to each implementer
@@ -2,7 +2,8 @@
2
2
  // `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
3
3
  // engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
4
4
  // (observed on convergence-loop instance 94).
5
- import { assert, assertEquals } from "jsr:@std/assert@1";
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
6
7
  import type { DataLayer } from "@nanobpm/urban";
7
8
  import { canonicalPrUrl, ensurePr } from "./service.ts";
8
9
 
@@ -18,11 +19,9 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
18
19
  let insertCalls = 0;
19
20
  function tbl(name: string, key: string) {
20
21
  return {
21
- // deno-lint-ignore require-await
22
22
  async get(id: string) {
23
23
  return rows.get(id);
24
24
  },
25
- // deno-lint-ignore no-explicit-any require-await
26
25
  async insert(row: any) {
27
26
  insertCalls++;
28
27
  if (opts.throwOnInsert) {
@@ -34,14 +33,13 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
34
33
  },
35
34
  };
36
35
  }
37
- // deno-lint-ignore no-explicit-any
38
36
  const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
39
37
  return { data, rows, get insertCalls() {
40
38
  return insertCalls;
41
39
  } };
42
40
  }
43
41
 
44
- Deno.test("ensurePr is a no-op when the parent already exists", async () => {
42
+ test("ensurePr is a no-op when the parent already exists", async () => {
45
43
  const mem = memData();
46
44
  const { data, rows } = mem;
47
45
  rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
@@ -52,7 +50,7 @@ Deno.test("ensurePr is a no-op when the parent already exists", async () => {
52
50
  assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
53
51
  });
54
52
 
55
- Deno.test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
53
+ test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
56
54
  const { data, rows } = memData();
57
55
  await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
58
56
  const row = rows.get("o/r#2")!;
@@ -65,7 +63,7 @@ Deno.test("ensurePr reconstructs a minimal converging row when the parent is abs
65
63
  assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
66
64
  });
67
65
 
68
- Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
66
+ test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
69
67
  const { data, rows } = memData();
70
68
  await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
71
69
  assertEquals(
@@ -75,7 +73,7 @@ Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none i
75
73
  );
76
74
  });
77
75
 
78
- Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
76
+ test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
79
77
  const { data, rows } = memData();
80
78
  await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
81
79
  assertEquals(
@@ -85,27 +83,27 @@ Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one
85
83
  );
86
84
  });
87
85
 
88
- Deno.test("ensurePr mints a token when none is supplied", async () => {
86
+ test("ensurePr mints a token when none is supplied", async () => {
89
87
  const { data, rows } = memData();
90
88
  await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
91
89
  const tok = rows.get("o/r#8")!.abandon_token;
92
90
  assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
93
91
  });
94
92
 
95
- Deno.test("ensurePr prefers an explicit url over the canonical one", async () => {
93
+ test("ensurePr prefers an explicit url over the canonical one", async () => {
96
94
  const { data, rows } = memData();
97
95
  const url = "https://github.com/o/r/pull/9";
98
96
  await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
99
97
  assertEquals(rows.get("o/r#9")!.url, url);
100
98
  });
101
99
 
102
- Deno.test("ensurePr swallows an insert race when the row appears anyway", async () => {
100
+ test("ensurePr swallows an insert race when the row appears anyway", async () => {
103
101
  // insert throws (unique-violation / concurrent writer) but the row is now present → healed.
104
102
  const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
105
103
  await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
106
104
  });
107
105
 
108
- Deno.test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
106
+ test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
109
107
  const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
110
108
  let threw = false;
111
109
  try {
@@ -1,7 +1,8 @@
1
1
  // Unit tests for `fetchPrFiles` token-transport paging (issue #58): the D2 conflict-scan must get
2
2
  // a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
- import { assertEquals, assertRejects } from "jsr:@std/assert@1";
4
+ import { test } from "node:test";
5
+ import { assertEquals, assertRejects } from "#test-assert";
5
6
  import { fetchPrFiles } from "./github.ts";
6
7
 
7
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -25,31 +26,31 @@ function stubFetch(pages: number[]) {
25
26
  }
26
27
 
27
28
  async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
28
- const prevMode = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
29
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
29
30
  const prevFetch = globalThis.fetch;
30
- Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token");
31
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
31
32
  globalThis.fetch = stubFetch(pages) as typeof fetch;
32
33
  try {
33
34
  return await fn();
34
35
  } finally {
35
36
  globalThis.fetch = prevFetch;
36
- if (prevMode === undefined) Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
37
- else Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
37
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
38
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
38
39
  }
39
40
  }
40
41
 
41
- Deno.test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
42
+ test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
42
43
  const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
43
44
  assertEquals(files?.length, 142);
44
45
  });
45
46
 
46
- Deno.test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
47
+ test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
47
48
  // 5 full pages, but no `rel="next"` on the last → the list is exactly complete at the cap.
48
49
  const files = await withTokenTransport([100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 2, "tok"));
49
50
  assertEquals(files?.length, 500);
50
51
  });
51
52
 
52
- Deno.test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
53
+ test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
53
54
  // 6 pages available but only 5 fetched → the 5th page still advertises `rel="next"`.
54
55
  await assertRejects(
55
56
  () => withTokenTransport([100, 100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 3, "tok")),
package/app/github.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // • auto — prefer `gh` when the binary is present; otherwise fall back to `token`.
10
10
  //
11
11
  // The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
12
- // Cross-runtime: runs under Node (`node:child_process`) and Deno (`Deno.Command`).
12
+ // Cross-runtime: runs under Node (`node:child_process`).
13
13
 
14
14
  /** A GitHub pull-request review, narrowed to the fields the poller needs. */
15
15
  export interface GhReview {
@@ -26,30 +26,10 @@ export function githubTransport(): GithubTransport {
26
26
  return t === "gh" || t === "token" ? t : "auto";
27
27
  }
28
28
 
29
- interface DenoCommandCtor {
30
- new (
31
- command: string,
32
- options: { args: string[]; stdout: "piped"; stderr: "piped" },
33
- ): { output(): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> };
34
- }
35
-
36
29
  /** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a
37
30
  * `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
38
31
  * non-zero exit with stderr as the message. */
39
32
  async function runGh(args: string[]): Promise<string> {
40
- // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
41
- const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
42
- if (g.Deno?.Command) {
43
- const { code, stdout, stderr } = await new g.Deno.Command("gh", {
44
- args,
45
- stdout: "piped",
46
- stderr: "piped",
47
- }).output();
48
- if (code !== 0) {
49
- throw new Error(new TextDecoder().decode(stderr).trim() || `gh exited ${code}`);
50
- }
51
- return new TextDecoder().decode(stdout);
52
- }
53
33
  const { execFile } = await import("node:child_process");
54
34
  return await new Promise<string>((resolve, reject) => {
55
35
  execFile(
@@ -4,7 +4,9 @@
4
4
  // crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
5
5
  // manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
6
6
  // STATUSES) so the two can't diverge silently.
7
- import { assert, assertEquals } from "jsr:@std/assert@1";
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import { readFileSync } from "node:fs";
8
10
  import { TERMINAL_STATUSES } from "./service.ts";
9
11
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
10
12
 
@@ -16,7 +18,7 @@ interface Binding {
16
18
  }
17
19
 
18
20
  async function bindings(): Promise<Binding[]> {
19
- const manifest = JSON.parse(await Deno.readTextFile(new URL("../nano.app.json", import.meta.url)));
21
+ const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
20
22
  return manifest.instanceTracking as Binding[];
21
23
  }
22
24
 
@@ -26,7 +28,7 @@ function bindingFor(all: Binding[], table: string): Binding {
26
28
  return b;
27
29
  }
28
30
 
29
- Deno.test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
31
+ test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
30
32
  const b = bindingFor(await bindings(), "pull_requests");
31
33
  for (const terminal of TERMINAL_STATUSES) {
32
34
  assert(
@@ -40,7 +42,7 @@ Deno.test("instanceTracking: pull_requests activeStatuses excludes every termina
40
42
  // pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
41
43
  // poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
42
44
  // waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
43
- Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
45
+ test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
44
46
  const inFlight = [
45
47
  "converging",
46
48
  "waiting_review",
@@ -59,14 +61,14 @@ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight
59
61
  for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
60
62
  });
61
63
 
62
- Deno.test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
64
+ test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
63
65
  const b = bindingFor(await bindings(), "plans");
64
66
  for (const terminal of PLAN_TERMINAL_STATUSES) {
65
67
  assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
66
68
  }
67
69
  });
68
70
 
69
- Deno.test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
71
+ test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
70
72
  const inFlight = ["planning", "dispatched"];
71
73
  const b = bindingFor(await bindings(), "plans");
72
74
  assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the merge-exclusion graph + conflict-scan (D1/D2, issues #57 #58 / #49).
2
- import { assert, assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals } from "#test-assert";
3
4
  import type { DataLayer } from "@nanobpm/urban";
4
5
  import {
5
6
  clearExclusions,
@@ -13,58 +14,48 @@ import {
13
14
  import { computeWaves } from "./waves.ts";
14
15
 
15
16
  // In-memory record-gateway fake (insert/find/findOne/update/delete), mirroring the app tests.
16
- // deno-lint-ignore no-explicit-any
17
17
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
18
- // deno-lint-ignore no-explicit-any
19
18
  const stores: Record<string, any[]> = {};
20
19
  const seq: Record<string, number> = {};
21
20
  function tbl(name: string, pk = "id") {
22
- // deno-lint-ignore no-explicit-any
23
21
  const rows = (stores[name] ??= [] as any[]);
24
- // deno-lint-ignore no-explicit-any
25
22
  const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
26
23
  return {
27
- // deno-lint-ignore no-explicit-any require-await
28
24
  async insert(row: any) {
29
25
  const id = (seq[name] = (seq[name] ?? 0) + 1);
30
26
  rows.push({ id, ...row });
31
27
  return id;
32
28
  },
33
- // deno-lint-ignore no-explicit-any require-await
34
29
  async find(where: any = {}) {
35
30
  return rows.filter((r) => match(r, where));
36
31
  },
37
- // deno-lint-ignore no-explicit-any require-await
38
32
  async findOne(where: any = {}) {
39
33
  return rows.find((r) => match(r, where));
40
34
  },
41
- // deno-lint-ignore no-explicit-any require-await
42
35
  async update(id: any, patch: any) {
43
36
  const r = rows.find((row) => row.id === id);
44
37
  if (r) Object.assign(r, patch);
45
38
  },
46
- // deno-lint-ignore no-explicit-any require-await
47
39
  async delete(id: any) {
48
40
  const i = rows.findIndex((row) => row.id === id);
49
41
  if (i >= 0) rows.splice(i, 1);
50
42
  },
51
43
  };
52
44
  }
53
- // deno-lint-ignore no-explicit-any
54
45
  const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
55
46
  return { data, stores };
56
47
  }
57
48
 
58
49
  const files = (e: ExclusionEdge) => e.files;
59
50
 
60
- Deno.test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
51
+ test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
61
52
  assertEquals(normalizePair("b", "a"), ["a", "b"]);
62
53
  assertEquals(normalizePair("a", "b"), ["a", "b"]);
63
54
  assertEquals(normalizePair("a", "a"), null, "a task never excludes itself");
64
55
  assertEquals(normalizePair("", "a"), null);
65
56
  });
66
57
 
67
- Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
58
+ test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
68
59
  const edges = deriveExclusions(
69
60
  new Map([
70
61
  ["gap-2", ["engine/tests.rs", "engine/state.rs"]],
@@ -83,14 +74,14 @@ Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sor
83
74
  assert(!edges.some((e) => e.taskA === "gap-5" || e.taskB === "gap-5"), "gap-5 excluded (no overlap)");
84
75
  });
85
76
 
86
- Deno.test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
77
+ test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
87
78
  assertEquals(
88
79
  deriveExclusions(new Map([["a", ["x.rs"]], ["b", ["y.rs"]], ["c", ["", " "]]])),
89
80
  [],
90
81
  );
91
82
  });
92
83
 
93
- Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
84
+ test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
94
85
  const { data, stores } = memData();
95
86
  const first = await recordExclusions(data, "p", deriveExclusions(
96
87
  new Map([["gap-2", ["a.rs"]], ["gap-8", ["a.rs"]]]),
@@ -109,7 +100,7 @@ Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes
109
100
  assertEquals(edge.files, ["a.rs", "b.rs"], "files refreshed in place");
110
101
  });
111
102
 
112
- Deno.test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
103
+ test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
113
104
  const { data, stores } = memData();
114
105
  // The same unordered pair appears twice in one call (second given in the other order + more files).
115
106
  // The in-memory map must fold the newly inserted id back so the second occurrence updates in place.
@@ -123,7 +114,7 @@ Deno.test("recordExclusions: a duplicate pair within one batch folds into an upd
123
114
  assertEquals(edge.files, ["x.rs", "y.rs"], "later occurrence refreshed files in place");
124
115
  });
125
116
 
126
- Deno.test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
117
+ test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
127
118
  const { data } = memData();
128
119
  await recordExclusions(data, "p", deriveExclusions(new Map([["a", ["x"]], ["b", ["x"]]])));
129
120
  await recordExclusions(data, "q", deriveExclusions(new Map([["c", ["y"]], ["d", ["y"]]])));
@@ -132,7 +123,7 @@ Deno.test("clearExclusions: drops one plan's graph, leaving others intact", asyn
132
123
  assertEquals((await readExclusions(data, "q")).length, 1, "the other plan survives");
133
124
  });
134
125
 
135
- Deno.test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
126
+ test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
136
127
  // gap-2—gap-8—gap-9 form one chain (transitive shared surface); gap-5 stands alone.
137
128
  const edges = deriveExclusions(
138
129
  new Map([
@@ -146,11 +137,11 @@ Deno.test("mergeLanes: connected components are serial landing lanes; singletons
146
137
  assertEquals(lanes, [["gap-2", "gap-8", "gap-9"], ["gap-5"]]);
147
138
  });
148
139
 
149
- Deno.test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
140
+ test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
150
141
  assertEquals(mergeLanes([], ["b", "a", "c"]), [["a"], ["b"], ["c"]]);
151
142
  });
152
143
 
153
- Deno.test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
144
+ test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
154
145
  // Two tasks that collide on a shared file but declare no build-on dependency.
155
146
  const overlap = new Map([["gap-2", ["engine/tests.rs"]], ["gap-8", ["engine/tests.rs"]]]);
156
147
  const edges = deriveExclusions(overlap);
@@ -3,8 +3,9 @@
3
3
  // extractor must find the fenced ```merge-protocol JSON, and the fresh-head-run decision must fire
4
4
  // exactly once per landing attempt — only in the frugal-CI stuck state (no head run + waiting) —
5
5
  // so a converged PR is nudged into a fresh CI run without disturbing a run already in flight.
6
- // Run with `deno test -A`.
7
- import { assertEquals } from "jsr:@std/assert@1";
6
+ // Run with `node --test`.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
8
9
  import {
9
10
  DEFAULT_MERGE_PROTOCOL,
10
11
  extractProtocolBlock,
@@ -13,14 +14,14 @@ import {
13
14
  parseMergeProtocol,
14
15
  } from "./mergeProtocol.ts";
15
16
 
16
- Deno.test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
17
+ test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
17
18
  assertEquals(parseMergeProtocol(undefined), { ...DEFAULT_MERGE_PROTOCOL });
18
19
  assertEquals(parseMergeProtocol(null), { ...DEFAULT_MERGE_PROTOCOL });
19
20
  assertEquals(parseMergeProtocol("nope"), { ...DEFAULT_MERGE_PROTOCOL });
20
21
  assertEquals(parseMergeProtocol([1, 2]), { ...DEFAULT_MERGE_PROTOCOL });
21
22
  });
22
23
 
23
- Deno.test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
24
+ test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
24
25
  const got = parseMergeProtocol({
25
26
  autoMerge: false,
26
27
  freshHeadRun: "ready-or-reopen",
@@ -37,7 +38,7 @@ Deno.test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
37
38
  assertEquals(got.doc, "AGENTS.md#merging-prs");
38
39
  });
39
40
 
40
- Deno.test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
41
+ test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
41
42
  const got = parseMergeProtocol({
42
43
  autoMerge: "yes", // not a boolean → default
43
44
  freshHeadRun: "sometimes", // not in the enum → default (none)
@@ -50,12 +51,12 @@ Deno.test("parseMergeProtocol: invalid enums / wrong types fall back per-field",
50
51
  assertEquals(got.requiredChecks, ["ok"]);
51
52
  });
52
53
 
53
- Deno.test("parseMergeProtocol: comment dropped when absent", () => {
54
+ test("parseMergeProtocol: comment dropped when absent", () => {
54
55
  const got = parseMergeProtocol({ land: { method: "admin" } });
55
56
  assertEquals(got.land, { method: "admin" });
56
57
  });
57
58
 
58
- Deno.test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info word)", () => {
59
+ test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info word)", () => {
59
60
  const md = [
60
61
  "## Merging PRs",
61
62
  "Some prose about how to merge.",
@@ -73,7 +74,7 @@ Deno.test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info
73
74
  assertEquals(p.land.method, "mergify-queue");
74
75
  });
75
76
 
76
- Deno.test("extractProtocolBlock: none present → null", () => {
77
+ test("extractProtocolBlock: none present → null", () => {
77
78
  assertEquals(extractProtocolBlock("# Doc\n```json\n{}\n```\n"), null);
78
79
  });
79
80
 
@@ -82,14 +83,14 @@ const NANO: MergeProtocol = parseMergeProtocol({
82
83
  land: { method: "mergify-queue" },
83
84
  });
84
85
 
85
- Deno.test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)", () => {
86
+ test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)", () => {
86
87
  // ready PR, no head run at all → reopen (ready-or-reopen, not a draft)
87
88
  assertEquals(freshHeadRunAction(NANO, "waiting", 0, false), "reopen");
88
89
  // draft PR, no head run → mark ready
89
90
  assertEquals(freshHeadRunAction(NANO, "waiting", 0, true), "ready");
90
91
  });
91
92
 
92
- Deno.test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
93
+ test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
93
94
  assertEquals(
94
95
  freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h1", lastActionHeadRefOid: null }),
95
96
  "reopen",
@@ -104,7 +105,7 @@ Deno.test("freshHeadRunAction: fires once per landing-attempt head, then re-fire
104
105
  );
105
106
  });
106
107
 
107
- Deno.test("freshHeadRunAction: never fires once a run exists, or when not waiting", () => {
108
+ test("freshHeadRunAction: never fires once a run exists, or when not waiting", () => {
108
109
  assertEquals(freshHeadRunAction(NANO, "waiting", 1, false), null); // run already in flight
109
110
  assertEquals(freshHeadRunAction(NANO, "waiting", -1, false), null); // token mode (unknown) → conservative
110
111
  assertEquals(freshHeadRunAction(NANO, "ready", 0, false), null); // already landable
@@ -113,11 +114,11 @@ Deno.test("freshHeadRunAction: never fires once a run exists, or when not waitin
113
114
  assertEquals(freshHeadRunAction(NANO, "conflict", 0, false), null); // conflict → rebase arm (#42)
114
115
  });
115
116
 
116
- Deno.test("freshHeadRunAction: protocol.freshHeadRun=none is a no-op (default repos unchanged)", () => {
117
+ test("freshHeadRunAction: protocol.freshHeadRun=none is a no-op (default repos unchanged)", () => {
117
118
  assertEquals(freshHeadRunAction(DEFAULT_MERGE_PROTOCOL, "waiting", 0, false), null);
118
119
  });
119
120
 
120
- Deno.test("freshHeadRunAction: mode=ready only acts on drafts", () => {
121
+ test("freshHeadRunAction: mode=ready only acts on drafts", () => {
121
122
  const readyOnly = parseMergeProtocol({ freshHeadRun: "ready", land: { method: "gh-merge" } });
122
123
  assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, true), "ready");
123
124
  assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, false), null); // not a draft → nothing to ready
@@ -10,9 +10,11 @@
10
10
  // way it originally shipped. It is a pure text assertion over the BPMN (no engine), matching the
11
11
  // repo's lightweight model-guard style.
12
12
 
13
- import { assert, assertStringIncludes } from "jsr:@std/assert@1";
13
+ import { test } from "node:test";
14
+ import { assert, assertStringIncludes } from "#test-assert";
15
+ import { readFileSync } from "node:fs";
14
16
 
15
- const bpmn = await Deno.readTextFile("resources/processes/merge-loop.bpmn");
17
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
16
18
 
17
19
  // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
18
20
  const flat = bpmn.replace(/\s+/g, " ");
@@ -27,14 +29,14 @@ function hasFlow(source: string, target: string): boolean {
27
29
  return re.test(flat);
28
30
  }
29
31
 
30
- Deno.test("conflict routes to the rebase budget gate, not straight to a human", () => {
32
+ test("conflict routes to the rebase budget gate, not straight to a human", () => {
31
33
  // The conflict verdict must reach the auto-rebase gate…
32
34
  assert(hasFlow("gw-mergeable", "gw-rebase"), "gw-mergeable → gw-rebase (conflict) missing");
33
35
  // …guarded by the exact conflict condition (DIRTY → mergeState = "conflict").
34
36
  assertStringIncludes(flat, 'mergeState = "conflict"');
35
37
  });
36
38
 
37
- Deno.test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
39
+ test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
38
40
  // Budget gate: within budget → the agent; exhausted → the (existing) conflict escalation.
39
41
  assert(hasFlow("gw-rebase", "rebase"), "gw-rebase → rebase (within budget) missing");
40
42
  assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
@@ -49,7 +51,7 @@ Deno.test("rebase arm mirrors the fix-ci arm: budget gate → agent → result g
49
51
  assertStringIncludes(flat, "=rebaseRound + 1");
50
52
  });
51
53
 
52
- Deno.test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
54
+ test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
53
55
  // Success loops back to re-attempt the merge — forward progress, no human needed.
54
56
  assert(hasFlow("gw-rebase-result", "arm-merge"), "gw-rebase-result → arm-merge (rebased) missing");
55
57
  assertStringIncludes(flat, 'status = "rebased"');
@@ -60,7 +62,7 @@ Deno.test("rebase result: success re-arms the poller; unresolved escalates to a
60
62
  );
61
63
  });
62
64
 
63
- Deno.test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
65
+ test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
64
66
  // The original #42 livelock had the conflict verdict escalate to a human with no remediation
65
67
  // actor. The primary target of the conflict verdict must now be the rebase gate.
66
68
  assert(
@@ -1,5 +1,6 @@
1
1
  // Red/green regression for D6's pure merge-train lane planner.
2
- import { assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import { planLane, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
4
5
 
5
6
  const taskToPr = new Map([
@@ -9,7 +10,7 @@ const taskToPr = new Map([
9
10
  ["gap-10", "o/r#10"],
10
11
  ]);
11
12
 
12
- Deno.test("planLane: dependency depth then task id picks the single lane head", () => {
13
+ test("planLane: dependency depth then task id picks the single lane head", () => {
13
14
  const depth = new Map([["gap-8", 2], ["gap-2", 0], ["gap-9", 0]]);
14
15
  assertEquals(planLane(["gap-8", "gap-9", "gap-2"], taskToPr, new Set(), depth), {
15
16
  headTaskId: "gap-2",
@@ -19,7 +20,7 @@ Deno.test("planLane: dependency depth then task id picks the single lane head",
19
20
  });
20
21
  });
21
22
 
22
- Deno.test("taskDependencyDepths: longest dependency path determines landing order depth", () => {
23
+ test("taskDependencyDepths: longest dependency path determines landing order depth", () => {
23
24
  const depths = taskDependencyDepths([
24
25
  { task_id: "b", depends_on_task_id: "a" },
25
26
  { task_id: "c", depends_on_task_id: "b" },
@@ -36,7 +37,7 @@ Deno.test("taskDependencyDepths: longest dependency path determines landing orde
36
37
  ]);
37
38
  });
38
39
 
39
- Deno.test("planLane: task id is the deterministic tiebreaker", () => {
40
+ test("planLane: task id is the deterministic tiebreaker", () => {
40
41
  assertEquals(planLane(["gap-9", "gap-2", "gap-8"], taskToPr, new Set()), {
41
42
  headTaskId: "gap-2",
42
43
  headPrKey: "o/r#2",
@@ -45,7 +46,7 @@ Deno.test("planLane: task id is the deterministic tiebreaker", () => {
45
46
  });
46
47
  });
47
48
 
48
- Deno.test("planLane: already-merged head advances to the next unmerged member", () => {
49
+ test("planLane: already-merged head advances to the next unmerged member", () => {
49
50
  assertEquals(planLane(["gap-2", "gap-8", "gap-9"], taskToPr, new Set(["o/r#2"])), {
50
51
  headTaskId: "gap-8",
51
52
  headPrKey: "o/r#8",
@@ -54,7 +55,7 @@ Deno.test("planLane: already-merged head advances to the next unmerged member",
54
55
  });
55
56
  });
56
57
 
57
- Deno.test("planLane: single-member lanes never hold their PR", () => {
58
+ test("planLane: single-member lanes never hold their PR", () => {
58
59
  assertEquals(planLane(["gap-10"], taskToPr, new Set()), {
59
60
  headTaskId: null,
60
61
  headPrKey: null,
@@ -63,7 +64,7 @@ Deno.test("planLane: single-member lanes never hold their PR", () => {
63
64
  });
64
65
  });
65
66
 
66
- Deno.test("planLane: tasks without PR keys do not block PR-backed members", () => {
67
+ test("planLane: tasks without PR keys do not block PR-backed members", () => {
67
68
  assertEquals(planLane(["scaffold", "gap-2", "gap-8"], taskToPr, new Set()), {
68
69
  headTaskId: "gap-2",
69
70
  headPrKey: "o/r#2",
@@ -72,7 +73,7 @@ Deno.test("planLane: tasks without PR keys do not block PR-backed members", () =
72
73
  });
73
74
  });
74
75
 
75
- Deno.test("planPrLane: held PRs point at the current lane head", () => {
76
+ test("planPrLane: held PRs point at the current lane head", () => {
76
77
  assertEquals(
77
78
  planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#8"),
78
79
  {
@@ -84,7 +85,7 @@ Deno.test("planPrLane: held PRs point at the current lane head", () => {
84
85
  );
85
86
  });
86
87
 
87
- Deno.test("planPrLane: lane head and PRs outside exclusion lanes are not held", () => {
88
+ test("planPrLane: lane head and PRs outside exclusion lanes are not held", () => {
88
89
  assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#2").isHeld, false);
89
90
  assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#10").isHeld, false);
90
91
  assertEquals(planPrLane([["gap-2", "gap-8"]], taskToPr, new Set(), "o/r#404").isHeld, false);