@nanobpm/nano-workforce 0.70.0 → 0.70.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.70.1](https://github.com/nanobpm/nano-workforce/compare/v0.70.0...v0.70.1) (2026-08-15)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **convergence:** don't re-review a no-progress round ([#230](https://github.com/nanobpm/nano-workforce/issues/230)) ([a8ea844](https://github.com/nanobpm/nano-workforce/commit/a8ea84444b351989f716124d0b485b42e1ec2a35)), closes [#231](https://github.com/nanobpm/nano-workforce/issues/231) [Magikcraft/nano-bpm#770](https://github.com/Magikcraft/nano-bpm/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770) [nanobpm/nano-workforce#225](https://github.com/nanobpm/nano-workforce/issues/225) [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#770](https://github.com/nanobpm/nano-workforce/issues/770)
7
+
1
8
  # [0.70.0](https://github.com/nanobpm/nano-workforce/compare/v0.69.1...v0.70.0) (2026-08-15)
2
9
 
3
10
 
@@ -0,0 +1,406 @@
1
+ // Convergence comment-gate — unit tests for the canonical router (app/convergeGate.ts), the
2
+ // suppressed-advisory / ack-marker parsers + review-thread fetch helpers (app/github.ts), the
3
+ // pr.converge-gate worker (fail-closed, with injected GitHub readers), and a structural guard over
4
+ // the committed convergence-loop BPMN.
5
+ //
6
+ // The loop used to declare convergence on the agent's self-reported `status = "converged"` with no
7
+ // deterministic check that Copilot's comments were addressed. On Magikcraft/nano-bpm#770 a
8
+ // suppressed advisory was never applied across 20 rounds, yet the PR converged and auto-merged. The
9
+ // fix inserts a deterministic `pr.converge-gate` step on the converged path that blocks convergence
10
+ // while any review thread is unresolved OR any suppressed advisory lacks a RESOLVED `nano-ack:`
11
+ // thread, escalating to the human `wait-answer` task instead of finalizing.
12
+ import { readFileSync } from "node:fs";
13
+ import { test } from "node:test";
14
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
15
+ import { evaluateConvergeGate } from "./convergeGate.ts";
16
+ import {
17
+ parseAckedAdvisories,
18
+ parseReviewThreadsPage,
19
+ parseSuppressedAdvisories,
20
+ pickLatestCopilotReviewBody,
21
+ type ReviewThread,
22
+ } from "./github.ts";
23
+
24
+ // ── The canonical router ────────────────────────────────────────────────────
25
+
26
+ test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) converges", () => {
27
+ const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedKeys: [], acknowledgedKeys: [] });
28
+ assertEquals(r.convergeBlocked, false);
29
+ assertEquals(r.convergeBlockReason, "");
30
+ });
31
+
32
+ test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
33
+ const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedKeys: [], acknowledgedKeys: [] });
34
+ assertEquals(r.convergeBlocked, true);
35
+ assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
36
+ });
37
+
38
+ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
39
+ const r = evaluateConvergeGate({
40
+ unresolvedThreadCount: 0,
41
+ suppressedKeys: ["spec/a.json:613"],
42
+ acknowledgedKeys: [],
43
+ });
44
+ assertEquals(r.convergeBlocked, true);
45
+ assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
46
+ // Singular noun for exactly one advisory (explicit, not "advisor" + "y/ies" concatenation).
47
+ assertStringIncludes(r.convergeBlockReason, "1 unacknowledged suppressed advisory (");
48
+ });
49
+
50
+ test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
51
+ const r = evaluateConvergeGate({
52
+ unresolvedThreadCount: 0,
53
+ suppressedKeys: ["spec/a.json:613"],
54
+ acknowledgedKeys: ["spec/a.json:613"],
55
+ });
56
+ assertEquals(r.convergeBlocked, false);
57
+ });
58
+
59
+ test("evaluateConvergeGate: multiple unacknowledged advisories use the plural noun", () => {
60
+ const r = evaluateConvergeGate({
61
+ unresolvedThreadCount: 0,
62
+ suppressedKeys: ["x.ts:10", "y.ts:20"],
63
+ acknowledgedKeys: [],
64
+ });
65
+ assertEquals(r.convergeBlocked, true);
66
+ assertStringIncludes(r.convergeBlockReason, "2 unacknowledged suppressed advisories (");
67
+ });
68
+
69
+ test("evaluateConvergeGate: reports both a thread and an advisory when both are outstanding", () => {
70
+ const r = evaluateConvergeGate({
71
+ unresolvedThreadCount: 1,
72
+ suppressedKeys: ["x.ts:10", "y.ts:20"],
73
+ acknowledgedKeys: ["x.ts:10"],
74
+ });
75
+ assertEquals(r.convergeBlocked, true);
76
+ assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
77
+ assertStringIncludes(r.convergeBlockReason, "y.ts:20");
78
+ assert(!r.convergeBlockReason.includes("x.ts:10"), "an acknowledged advisory must not be listed");
79
+ });
80
+
81
+ // ── The parsers (app/github.ts) ─────────────────────────────────────────────
82
+
83
+ const SAMPLE_REVIEW_BODY = [
84
+ "## Pull Request Overview",
85
+ "Some prose that mentions **not/an/advisory:1** in passing.",
86
+ "",
87
+ "<details>",
88
+ "<summary>Suppressed comments (2)</summary>",
89
+ "",
90
+ "**spec-app/nano-app.schema.json:613**",
91
+ "- The description could be clearer about the loopback default.",
92
+ "",
93
+ "**server/src/main.rs:42**",
94
+ "- Consider narrowing this type.",
95
+ "</details>",
96
+ ].join("\n");
97
+
98
+ test("parseSuppressedAdvisories: extracts only the keys inside the Suppressed comments block", () => {
99
+ const keys = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
100
+ assertEquals(keys, ["spec-app/nano-app.schema.json:613", "server/src/main.rs:42"]);
101
+ });
102
+
103
+ test("parseSuppressedAdvisories: returns [] when there is no suppressed block", () => {
104
+ assertEquals(parseSuppressedAdvisories("## Overview\nLooks good, **file.ts:1** is fine."), []);
105
+ assertEquals(parseSuppressedAdvisories(null), []);
106
+ assertEquals(parseSuppressedAdvisories(undefined), []);
107
+ });
108
+
109
+ test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
110
+ const threads: ReviewThread[] = [
111
+ { isResolved: true, path: "a.ts", bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json:613"] },
112
+ { isResolved: false, path: "b.ts", bodies: ["nano-ack: server/src/main.rs:42"] }, // open -> ignored
113
+ { isResolved: true, path: "c.ts", bodies: ["unrelated resolved comment"] },
114
+ ];
115
+ const acked = parseAckedAdvisories(threads);
116
+ assertEquals(acked, ["spec-app/nano-app.schema.json:613"]);
117
+ });
118
+
119
+ test("parseReviewThreadsPage: maps nodes and reports a complete (final) page", () => {
120
+ const page = parseReviewThreadsPage({
121
+ data: {
122
+ repository: {
123
+ pullRequest: {
124
+ reviewThreads: {
125
+ pageInfo: { hasNextPage: false, endCursor: null },
126
+ nodes: [{ isResolved: false, path: "a.ts", comments: { nodes: [{ body: "please fix" }] } }],
127
+ },
128
+ },
129
+ },
130
+ },
131
+ });
132
+ assertEquals(page, {
133
+ threads: [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
134
+ hasNextPage: false,
135
+ endCursor: null,
136
+ });
137
+ });
138
+
139
+ test("parseReviewThreadsPage: a TRUNCATED page reports hasNextPage + its cursor (caller pages on)", () => {
140
+ // >100 threads: the first:100 page cannot see thread 101+, so instead of silently dropping the
141
+ // overflow the mapper surfaces `hasNextPage`/`endCursor` and `fetchReviewThreads` pages to
142
+ // completeness (or fails closed once its bounded page cap is exhausted).
143
+ const page = parseReviewThreadsPage({
144
+ data: {
145
+ repository: {
146
+ pullRequest: {
147
+ reviewThreads: {
148
+ pageInfo: { hasNextPage: true, endCursor: "CURSOR123" },
149
+ nodes: [{ isResolved: true, path: "a.ts", comments: { nodes: [{ body: "ok" }] } }],
150
+ },
151
+ },
152
+ },
153
+ },
154
+ });
155
+ assertEquals(page, {
156
+ threads: [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
157
+ hasNextPage: true,
158
+ endCursor: "CURSOR123",
159
+ });
160
+ });
161
+
162
+ test("parseReviewThreadsPage: FAILS CLOSED (null) when the reviewThreads block is MISSING", () => {
163
+ // GraphQL errors, permission issues, or a malformed payload can omit `reviewThreads`. Treating that
164
+ // as "no threads" (empty array) is a fail-OPEN — an unverifiable read must return null so the worker
165
+ // blocks/escalates rather than converging on a read that never happened.
166
+ assertEquals(parseReviewThreadsPage({}), null);
167
+ assertEquals(parseReviewThreadsPage({ data: { repository: { pullRequest: {} } } }), null);
168
+ });
169
+
170
+ test("parseReviewThreadsPage: FAILS CLOSED (null) when the completeness signal is UNREADABLE", () => {
171
+ // A present block whose `pageInfo.hasNextPage` is not a readable boolean is unverifiable — we cannot
172
+ // tell whether more pages exist, so we cannot safely page or map it.
173
+ const page = parseReviewThreadsPage({
174
+ data: {
175
+ repository: {
176
+ pullRequest: {
177
+ reviewThreads: {
178
+ nodes: [{ isResolved: true, path: "a.ts", comments: { nodes: [{ body: "ok" }] } }],
179
+ },
180
+ },
181
+ },
182
+ },
183
+ });
184
+ assertEquals(page, null);
185
+ });
186
+
187
+ test("pickLatestCopilotReviewBody: picks the NEWEST Copilot review body (oldest\u2192newest order)", () => {
188
+ const body = pickLatestCopilotReviewBody(
189
+ [
190
+ { user: { login: "human" }, body: "human review" },
191
+ { user: { login: "Copilot" }, body: "old copilot review" },
192
+ { user: { login: "Copilot" }, body: "newest copilot review" },
193
+ ],
194
+ false,
195
+ );
196
+ assertEquals(body, "newest copilot review");
197
+ });
198
+
199
+ test('pickLatestCopilotReviewBody: a complete read with NO Copilot review is verified empty ("")', () => {
200
+ assertEquals(pickLatestCopilotReviewBody([{ user: { login: "human" }, body: "hi" }], false), "");
201
+ assertEquals(pickLatestCopilotReviewBody([], false), "");
202
+ });
203
+
204
+ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was TRUNCATED", () => {
205
+ // >100 reviews (a long convergence loop): a first-page-only read returns the OLDEST 100 and misses
206
+ // the genuinely newest Copilot review, so an unverifiable (truncated) read must block, never return
207
+ // a stale page's body \u2014 a fail-OPEN on the advisory dimension is the class this gate prevents.
208
+ assertEquals(
209
+ pickLatestCopilotReviewBody(
210
+ [{ user: { login: "Copilot" }, body: "possibly stale" }],
211
+ true,
212
+ ),
213
+ null,
214
+ );
215
+ });
216
+
217
+ async function makeUnderTest(deps: {
218
+ readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
219
+ readReviewBody: (repo: string, n: number) => Promise<string | null>;
220
+ }) {
221
+ const { makeHandler } = await import("../workers/converge-gate/worker.ts");
222
+ return makeHandler(deps);
223
+ }
224
+
225
+ test("converge-gate: a clean PR is allowed to converge", async () => {
226
+ const handler = await makeUnderTest({
227
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
228
+ readReviewBody: async () => "## Overview\nNo suppressed block.",
229
+ });
230
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
231
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
232
+ });
233
+
234
+ test("converge-gate: an unresolved thread blocks convergence", async () => {
235
+ const handler = await makeUnderTest({
236
+ readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
237
+ readReviewBody: async () => "",
238
+ });
239
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
240
+ assertEquals(out.convergeBlocked, true);
241
+ assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
242
+ });
243
+
244
+ test("converge-gate: an unacknowledged suppressed advisory blocks convergence", async () => {
245
+ const handler = await makeUnderTest({
246
+ readThreads: async () => [],
247
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
248
+ });
249
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
250
+ assertEquals(out.convergeBlocked, true);
251
+ assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
252
+ });
253
+
254
+ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
255
+ const handler = await makeUnderTest({
256
+ readThreads: async () => [
257
+ { isResolved: true, path: "spec-app/nano-app.schema.json", bodies: ["Applied. nano-ack: spec-app/nano-app.schema.json:613"] },
258
+ { isResolved: true, path: "server/src/main.rs", bodies: ["Declined, false positive. nano-ack: server/src/main.rs:42"] },
259
+ ],
260
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
261
+ });
262
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
263
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
264
+ });
265
+
266
+ test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
267
+ const handler = await makeUnderTest({
268
+ readThreads: async () => null,
269
+ readReviewBody: async () => "",
270
+ });
271
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
272
+ assertEquals(out.convergeBlocked, true);
273
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
274
+ });
275
+
276
+ test("converge-gate: FAILS CLOSED when the review-body read returns null (no transport)", async () => {
277
+ // A null review body is unverifiable, not "no advisories" — the gate must block, not fail open on
278
+ // the suppressed-advisory dimension while the threads read happened to succeed.
279
+ const handler = await makeUnderTest({
280
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
281
+ readReviewBody: async () => null,
282
+ });
283
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
284
+ assertEquals(out.convergeBlocked, true);
285
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
286
+ });
287
+
288
+ test("converge-gate: FAILS CLOSED when a reader throws", async () => {
289
+ const handler = await makeUnderTest({
290
+ readThreads: async () => {
291
+ throw new Error("boom");
292
+ },
293
+ readReviewBody: async () => "",
294
+ });
295
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
296
+ assertEquals(out.convergeBlocked, true);
297
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
298
+ });
299
+
300
+ test("converge-gate: FAILS CLOSED when the target cannot be resolved", async () => {
301
+ const handler = await makeUnderTest({
302
+ readThreads: async () => [],
303
+ readReviewBody: async () => "",
304
+ });
305
+ const out = await handler({ variables: { prKey: "not-a-pr-key" } } as any, {} as any);
306
+ assertEquals(out.convergeBlocked, true);
307
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
308
+ });
309
+
310
+ test("converge-gate: a non-string prKey does not throw — resolves from repo/prNumber vars", async () => {
311
+ // `parsePr` calls `.trim()`, so a missing/non-string prKey must not reach it: otherwise the job
312
+ // throws and retries instead of running the fail-closed gate. A well-formed job carrying valid
313
+ // repo + prNumber but no prKey must still evaluate normally.
314
+ const handler = await makeUnderTest({
315
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
316
+ readReviewBody: async () => "",
317
+ });
318
+ const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
319
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
320
+ });
321
+
322
+ test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
323
+ const handler = await makeUnderTest({
324
+ readThreads: async () => [],
325
+ readReviewBody: async () => "",
326
+ });
327
+ const out = await handler({ variables: { prKey: 123 } } as any, {} as any);
328
+ assertEquals(out.convergeBlocked, true);
329
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
330
+ });
331
+
332
+ test("converge-gate: resolves repo/prNumber from the prKey when the vars are absent", async () => {
333
+ let seen: [string, number] | null = null;
334
+ const handler = await makeUnderTest({
335
+ readThreads: async (repo, n) => {
336
+ seen = [repo, n];
337
+ return [];
338
+ },
339
+ readReviewBody: async () => "",
340
+ });
341
+ await handler({ variables: { prKey: "o/r#7" } } as any, {} as any);
342
+ assertEquals(seen, ["o/r", 7]);
343
+ });
344
+
345
+ // ── Structural guard over the committed BPMN (no engine) ─────────────────────
346
+
347
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
348
+ const flat = bpmn.replace(/\s+/g, " ");
349
+
350
+ function flowElement(id: string): string | null {
351
+ const re = new RegExp(
352
+ `<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
353
+ );
354
+ const m = flat.match(re);
355
+ return m ? m[0] : null;
356
+ }
357
+
358
+ test("the converged status arm routes through the check-converge gate, not straight to finalize", () => {
359
+ const f = flowElement("f_converged");
360
+ assert(f, "f_converged flow missing");
361
+ assertStringIncludes(f, 'sourceRef="gw-status"');
362
+ assertStringIncludes(f, 'targetRef="check-converge"');
363
+ assertStringIncludes(f, 'status = "converged"');
364
+ });
365
+
366
+ test("check-converge runs the deterministic converge-gate job and feeds gw-converge-gate", () => {
367
+ const f = flowElement("f_toConvergeGate");
368
+ assert(f, "f_toConvergeGate flow missing");
369
+ assertStringIncludes(f, 'sourceRef="check-converge"');
370
+ assertStringIncludes(f, 'targetRef="gw-converge-gate"');
371
+ assertStringIncludes(flat, 'type="pr.converge-gate"');
372
+ });
373
+
374
+ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition", () => {
375
+ const f = flowElement("f_convergeBlocked");
376
+ assert(f, "f_convergeBlocked flow missing");
377
+ assertStringIncludes(f, 'targetRef="persist-escalation-blockedcomments"');
378
+ assertStringIncludes(f, "convergeBlocked = true");
379
+ });
380
+
381
+ test("gw-converge-gate default arm finalizes with no condition", () => {
382
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
383
+ assert(gw, "gw-converge-gate gateway missing");
384
+ assertStringIncludes(gw[0], 'default="f_convergeOk"');
385
+ const ok = flowElement("f_convergeOk");
386
+ assert(ok, "f_convergeOk flow missing");
387
+ assertStringIncludes(ok, 'targetRef="persist-converged"');
388
+ assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
389
+ });
390
+
391
+ test("the blocked-comments escalation lands on the human wait-answer task with an answerable escalation", () => {
392
+ const f = flowElement("f_blockedWait");
393
+ assert(f, "f_blockedWait flow missing");
394
+ assertStringIncludes(f, 'sourceRef="persist-escalation-blockedcomments"');
395
+ assertStringIncludes(f, 'targetRef="wait-answer"');
396
+ const task = flat.match(
397
+ /<bpmn:serviceTask\b[^>]*\bid="persist-escalation-blockedcomments"[^>]*>.*?<\/bpmn:serviceTask>/,
398
+ );
399
+ assert(task, "persist-escalation-blockedcomments task missing");
400
+ assertStringIncludes(task[0], 'type="pr.persist-escalation"');
401
+ assertStringIncludes(task[0], 'target="status"');
402
+ assertStringIncludes(task[0], 'target="question"');
403
+ assertStringIncludes(task[0], 'target="recordRound"');
404
+ // The human sees the gate's own reason (the unresolved threads / unacknowledged advisories).
405
+ assertStringIncludes(task[0], "convergeBlockReason");
406
+ });
@@ -0,0 +1,48 @@
1
+ // Convergence comment-gate (issue: don't converge with unaddressed review comments).
2
+ //
3
+ // The review loop declares convergence on the AGENT's self-reported status. That trusts the agent
4
+ // to only say "converged" once every Copilot comment is addressed — which failed on
5
+ // Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
6
+ // the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
7
+ // while either:
8
+ // • any review THREAD is still unresolved, or
9
+ // • any SUPPRESSED advisory (`path:line` in the latest Copilot review body) lacks a matching
10
+ // RESOLVED ack thread (a thread carrying a `nano-ack: <path>:<line>` marker).
11
+ // A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
12
+
13
+ export interface ConvergeGateInput {
14
+ /** Count of review threads with `isResolved === false`. */
15
+ unresolvedThreadCount: number;
16
+ /** `path:line` keys of Copilot's suppressed advisories (latest review body). */
17
+ suppressedKeys: string[];
18
+ /** `path:line` keys acknowledged by RESOLVED `nano-ack:` threads. */
19
+ acknowledgedKeys: string[];
20
+ }
21
+
22
+ export interface ConvergeGateResult {
23
+ convergeBlocked: boolean;
24
+ convergeBlockReason: string;
25
+ }
26
+
27
+ /** Decide whether a self-reported "converged" round may proceed to finalize. Pure; the worker
28
+ * feeds it live GitHub state and fails CLOSED (blocks) when that state cannot be read. */
29
+ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResult {
30
+ const acked = new Set(input.acknowledgedKeys);
31
+ const unacked = input.suppressedKeys.filter((k) => !acked.has(k));
32
+ const reasons: string[] = [];
33
+ if (input.unresolvedThreadCount > 0) {
34
+ const n = input.unresolvedThreadCount;
35
+ reasons.push(`${n} unresolved review thread${n === 1 ? "" : "s"}`);
36
+ }
37
+ if (unacked.length > 0) {
38
+ const noun = unacked.length === 1 ? "advisory" : "advisories";
39
+ reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.join(", ")})`);
40
+ }
41
+ if (reasons.length === 0) {
42
+ return { convergeBlocked: false, convergeBlockReason: "" };
43
+ }
44
+ return {
45
+ convergeBlocked: true,
46
+ convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path>:<line>) for each suppressed advisory before converging.`,
47
+ };
48
+ }