@nanobpm/nano-workforce 0.187.1 → 0.187.3

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,15 @@
1
+ ## [0.187.3](https://github.com/nanobpm/nano-workforce/compare/v0.187.2...v0.187.3) (2026-09-14)
2
+
3
+ ### Bug Fixes
4
+
5
+ * pr-escalation Tasks form no longer renders a blank question field ([#785](https://github.com/nanobpm/nano-workforce/issues/785)) ([eb6bc0e](https://github.com/nanobpm/nano-workforce/commit/eb6bc0e9a677effed0e4d93a3c37033f7819d943)), closes [#767](https://github.com/nanobpm/nano-workforce/issues/767)
6
+
7
+ ## [0.187.2](https://github.com/nanobpm/nano-workforce/compare/v0.187.1...v0.187.2) (2026-09-13)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **convergence:** key suppressed-advisory acks on line-stable identity ([#788](https://github.com/nanobpm/nano-workforce/issues/788)) ([9b9d124](https://github.com/nanobpm/nano-workforce/commit/9b9d1241dbd725cfe9b8ac88f5a9f5b949dc4fc4)), closes [#787](https://github.com/nanobpm/nano-workforce/issues/787) [#787](https://github.com/nanobpm/nano-workforce/issues/787) [pre-#787](https://github.com/nanobpm/pre-/issues/787) [#787](https://github.com/nanobpm/nano-workforce/issues/787)
12
+
1
13
  ## [0.187.1](https://github.com/nanobpm/nano-workforce/compare/v0.187.0...v0.187.1) (2026-09-12)
2
14
 
3
15
  ### Bug Fixes
@@ -11,9 +11,10 @@
11
11
  // thread, escalating to the human `wait-answer` task instead of finalizing.
12
12
  import { readFileSync } from "node:fs";
13
13
  import { test } from "node:test";
14
- import { assert, assertEquals, assertStringIncludes } from "#test-assert";
14
+ import { assert, assertEquals, assertNotEquals, assertStringIncludes } from "#test-assert";
15
15
  import { evaluateConvergeGate } from "./convergeGate.ts";
16
16
  import {
17
+ advisoryStableKey,
17
18
  parseAckedAdvisories,
18
19
  parseReviewThreadsPage,
19
20
  parseSuppressedAdvisories,
@@ -24,13 +25,13 @@ import {
24
25
  // ── The canonical router ────────────────────────────────────────────────────
25
26
 
26
27
  test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) converges", () => {
27
- const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedKeys: [], acknowledgedKeys: [] });
28
+ const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: [], acknowledgedKeys: [] });
28
29
  assertEquals(r.convergeBlocked, false);
29
30
  assertEquals(r.convergeBlockReason, "");
30
31
  });
31
32
 
32
33
  test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
33
- const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedKeys: [], acknowledgedKeys: [] });
34
+ const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedAdvisories: [], acknowledgedKeys: [] });
34
35
  assertEquals(r.convergeBlocked, true);
35
36
  assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
36
37
  });
@@ -38,7 +39,7 @@ test("evaluateConvergeGate: an unresolved review thread blocks convergence", ()
38
39
  test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
39
40
  const r = evaluateConvergeGate({
40
41
  unresolvedThreadCount: 0,
41
- suppressedKeys: ["spec/a.json:613"],
42
+ suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
42
43
  acknowledgedKeys: [],
43
44
  });
44
45
  assertEquals(r.convergeBlocked, true);
@@ -50,16 +51,34 @@ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks converg
50
51
  test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
51
52
  const r = evaluateConvergeGate({
52
53
  unresolvedThreadCount: 0,
53
- suppressedKeys: ["spec/a.json:613"],
54
- acknowledgedKeys: ["spec/a.json:613"],
54
+ suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
55
+ acknowledgedKeys: ["spec/a.json#deadbeef"],
55
56
  });
56
57
  assertEquals(r.convergeBlocked, false);
57
58
  });
58
59
 
60
+ test("evaluateConvergeGate: a prose-blind legacy `path:line` ack does NOT acknowledge an advisory (no false-OPEN)", () => {
61
+ // A resolved `nano-ack: spec/a.json:613` for a PRIOR advisory would yield only the `path:line`
62
+ // string — never a stable `<path>#<fp>` key. A genuinely new advisory at that same line carries a
63
+ // different stable key, so a bare-line ack can never satisfy it: the gate stays blocked. This is
64
+ // the guard for issue #787's re-review finding — honouring `path:line` let a resolved ack for
65
+ // advisory A silently converge a NEW advisory B re-emitted at the same line.
66
+ const r = evaluateConvergeGate({
67
+ unresolvedThreadCount: 0,
68
+ suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
69
+ acknowledgedKeys: ["spec/a.json:613"],
70
+ });
71
+ assertEquals(r.convergeBlocked, true);
72
+ assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
73
+ });
74
+
59
75
  test("evaluateConvergeGate: multiple unacknowledged advisories use the plural noun", () => {
60
76
  const r = evaluateConvergeGate({
61
77
  unresolvedThreadCount: 0,
62
- suppressedKeys: ["x.ts:10", "y.ts:20"],
78
+ suppressedAdvisories: [
79
+ { key: "x.ts#a", label: "x.ts:10" },
80
+ { key: "y.ts#b", label: "y.ts:20" },
81
+ ],
63
82
  acknowledgedKeys: [],
64
83
  });
65
84
  assertEquals(r.convergeBlocked, true);
@@ -69,8 +88,11 @@ test("evaluateConvergeGate: multiple unacknowledged advisories use the plural no
69
88
  test("evaluateConvergeGate: reports both a thread and an advisory when both are outstanding", () => {
70
89
  const r = evaluateConvergeGate({
71
90
  unresolvedThreadCount: 1,
72
- suppressedKeys: ["x.ts:10", "y.ts:20"],
73
- acknowledgedKeys: ["x.ts:10"],
91
+ suppressedAdvisories: [
92
+ { key: "x.ts#a", label: "x.ts:10" },
93
+ { key: "y.ts#b", label: "y.ts:20" },
94
+ ],
95
+ acknowledgedKeys: ["x.ts#a"],
74
96
  });
75
97
  assertEquals(r.convergeBlocked, true);
76
98
  assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
@@ -95,9 +117,19 @@ const SAMPLE_REVIEW_BODY = [
95
117
  "</details>",
96
118
  ].join("\n");
97
119
 
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"]);
120
+ test("parseSuppressedAdvisories: extracts advisories inside the Suppressed comments block", () => {
121
+ const advisories = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
122
+ assertEquals(
123
+ advisories.map((a) => a.label),
124
+ ["spec-app/nano-app.schema.json:613", "server/src/main.rs:42"],
125
+ );
126
+ // The line-stable key is `<path>#<fingerprint>` of the prose; `label` remains the human `path:line`.
127
+ assertEquals(advisories[0].label, "spec-app/nano-app.schema.json:613");
128
+ assertEquals(
129
+ advisories[0].key,
130
+ advisoryStableKey("spec-app/nano-app.schema.json", "The description could be clearer about the loopback default."),
131
+ );
132
+ assert(advisories[0].key.startsWith("spec-app/nano-app.schema.json#"), "stable key is path#fingerprint");
101
133
  });
102
134
 
103
135
  test("parseSuppressedAdvisories: returns [] when there is no suppressed block", () => {
@@ -108,12 +140,198 @@ test("parseSuppressedAdvisories: returns [] when there is no suppressed block",
108
140
 
109
141
  test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
110
142
  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
143
+ {
144
+ isResolved: true,
145
+ path: "a.ts",
146
+ bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json :: Clarify the loopback default."],
147
+ },
148
+ { isResolved: false, path: "b.ts", bodies: ["nano-ack: server/src/main.rs :: Narrow this type."] }, // open -> ignored
113
149
  { isResolved: true, path: "c.ts", bodies: ["unrelated resolved comment"] },
114
150
  ];
115
151
  const acked = parseAckedAdvisories(threads);
116
- assertEquals(acked, ["spec-app/nano-app.schema.json:613"]);
152
+ assertEquals(acked, [advisoryStableKey("spec-app/nano-app.schema.json", "Clarify the loopback default.")]);
153
+ });
154
+
155
+ // A resolved bare `<path>:<line>` ack (the retired legacy form) must NOT count: it carries no
156
+ // advisory prose, so it cannot identify WHICH advisory it acknowledged. Honouring it would false-OPEN
157
+ // a genuinely new advisory re-emitted at that same line (issue #787's re-review finding).
158
+ test("parseAckedAdvisories: a bare legacy `<path>:<line>` marker is NOT honoured (no prose-blind ack)", () => {
159
+ const threads: ReviewThread[] = [
160
+ { isResolved: true, path: "a.ts", bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json:613"] },
161
+ ];
162
+ assertEquals(parseAckedAdvisories(threads), []);
163
+ });
164
+
165
+ test("parseAckedAdvisories: the new `<path> :: <text>` form yields the line-stable key", () => {
166
+ const threads: ReviewThread[] = [
167
+ { isResolved: true, path: "a.ts", bodies: ["Declined. nano-ack: server/src/main.rs :: Consider narrowing this type."] },
168
+ ];
169
+ const acked = parseAckedAdvisories(threads);
170
+ assertEquals(acked, [advisoryStableKey("server/src/main.rs", "Consider narrowing this type.")]);
171
+ });
172
+
173
+ // A valid GitHub path can contain spaces; the ack form must not reject it (regression for the
174
+ // critical review finding — `\S+`/`[^\s]` path groups silently dropped a spaced-path ack, so the
175
+ // gate could never observe the acknowledgement and escalated forever).
176
+ test("parseAckedAdvisories: a path containing spaces is honoured in the new `<path> :: <text>` form", () => {
177
+ const newForm: ReviewThread[] = [
178
+ { isResolved: true, path: "d.md", bodies: ["Applied. nano-ack: docs/my file.md :: Clarify the loopback default."] },
179
+ ];
180
+ assertEquals(parseAckedAdvisories(newForm), [advisoryStableKey("docs/my file.md", "Clarify the loopback default.")]);
181
+ });
182
+
183
+ // A valid GitHub path can itself contain `::` (e.g. `src/a::b.ts`); the ` :: ` separator must be
184
+ // whitespace-delimited so a bare `::` inside the path is not mistaken for the delimiter (regression
185
+ // for the suppressed finding: `\s*::\s*` split `src/a::b.ts :: text` at the wrong `::`, mangling the
186
+ // path and producing a key that could never match the advisory).
187
+ // The `<path> :: <text>` separator is whitespace-delimited, so a path containing a bare `::` is not
188
+ // split at the interior `::` (a legacy `<path>:<line>` interior-colon test was retired with the
189
+ // prose-blind legacy form).
190
+ test("parseAckedAdvisories: a path containing `::` is not split at the interior `::`", () => {
191
+ const threads: ReviewThread[] = [
192
+ { isResolved: true, path: "a.ts", bodies: ["Applied. nano-ack: src/a::b.ts :: Narrow the return type here."] },
193
+ ];
194
+ assertEquals(parseAckedAdvisories(threads), [advisoryStableKey("src/a::b.ts", "Narrow the return type here.")]);
195
+ });
196
+
197
+ // Normalization must preserve word boundaries, punctuation, and Unicode so distinct advisories on
198
+ // one path do not alias to the same key (regression for the suppressed findings: deleting every
199
+ // separator made `foo-bar`/`foobar` collide and non-ASCII-only prose normalized to an empty key;
200
+ // collapsing punctuation-into-space aliased `Use foo() here` with `Use foo here` — a false-OPEN).
201
+ test("advisoryStableKey: word boundaries, punctuation and Unicode are preserved (distinct prose -> distinct keys)", () => {
202
+ const p = "app/x.ts";
203
+ assertNotEquals(advisoryStableKey(p, "foo-bar"), advisoryStableKey(p, "foobar"));
204
+ // Two different non-ASCII-only advisories must not both collapse to the empty-string key.
205
+ assertNotEquals(advisoryStableKey(p, "café"), advisoryStableKey(p, "naïve"));
206
+ // Punctuation must NOT collapse into whitespace: prose differing only by punctuation-vs-space
207
+ // stays distinct, so a resolved ack for one cannot silently acknowledge the other (false-OPEN).
208
+ assertNotEquals(advisoryStableKey(p, "Use foo() here"), advisoryStableKey(p, "Use foo here"));
209
+ assertNotEquals(advisoryStableKey(p, "foo/bar"), advisoryStableKey(p, "foo bar"));
210
+ // Only case and whitespace runs are normalized (verbatim copy modulo reflow -> same key).
211
+ assertEquals(advisoryStableKey(p, "Foo bar, baz."), advisoryStableKey(p, "foo bar, baz."));
212
+ // NFC (not NFKC): compatibility variants must stay DISTINCT, or acking one false-OPENs the other.
213
+ // Full-width `!` vs ASCII `!` (NFKC would fold them together); ligature `fi` vs `fi`.
214
+ assertNotEquals(advisoryStableKey(p, "Use foo\uFF01"), advisoryStableKey(p, "Use foo!"));
215
+ assertNotEquals(advisoryStableKey(p, "The \uFB01le"), advisoryStableKey(p, "The file"));
216
+ // A precomposed vs decomposed accent IS canonically equivalent (NFC unifies) -> same key.
217
+ assertEquals(advisoryStableKey(p, "caf\u00E9"), advisoryStableKey(p, "cafe\u0301"));
218
+ });
219
+
220
+ // The advisory side strips a leading markdown bullet (Copilot renders suppressed prose as `* …`),
221
+ // and the prompt tells the agent to copy that first line VERBATIM — so an ack marker legitimately
222
+ // carries the `* ` bullet. Keying must therefore be bullet-insensitive on BOTH sides, else the ack
223
+ // key never matches the advisory key and the gate livelocks (fail-CLOSED). Regression for the
224
+ // suppressed finding that `parseSuppressedAdvisories` stripped the bullet but the ack path did not.
225
+ test("advisoryStableKey: a leading markdown bullet is stripped so bulleted ack text matches", () => {
226
+ const p = "app/x.ts";
227
+ assertEquals(advisoryStableKey(p, "* Consider narrowing this type."), advisoryStableKey(p, "Consider narrowing this type."));
228
+ assertEquals(advisoryStableKey(p, "- Consider narrowing this type."), advisoryStableKey(p, "Consider narrowing this type."));
229
+ // But a leading `-`/`*` with NO trailing whitespace is NOT a bullet: it is preserved, so distinct
230
+ // first-line prose keeps a distinct key (else `-foo` false-acks `foo`). Regression for the finding
231
+ // that the greedy `[-*]\s*` stripped a non-bullet leading punctuation char.
232
+ assertNotEquals(advisoryStableKey(p, "-foo"), advisoryStableKey(p, "foo"));
233
+ assertNotEquals(advisoryStableKey(p, "*foo"), advisoryStableKey(p, "foo"));
234
+ });
235
+
236
+ // End-to-end: an ack whose marker copies Copilot's bulleted first line verbatim acknowledges the
237
+ // advisory parsed from that same rendered bullet (the ack key == the parsed advisory key).
238
+ test("parseAckedAdvisories: an ack copying the rendered `* ` bullet verbatim matches the advisory key", () => {
239
+ const advisories = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
240
+ const bulleted = advisories.map((a) => a.label); // ["…schema.json:613", "…main.rs:42"]
241
+ assert(bulleted.length === 2);
242
+ const threads: ReviewThread[] = [
243
+ {
244
+ isResolved: true,
245
+ path: "a.ts",
246
+ // Verbatim copy of Copilot's rendered first line, bullet included.
247
+ bodies: ["Declined. nano-ack: server/src/main.rs :: - Consider narrowing this type."],
248
+ },
249
+ ];
250
+ const acked = parseAckedAdvisories(threads);
251
+ const mainRs = advisories.find((a) => a.path === "server/src/main.rs");
252
+ assert(mainRs !== undefined);
253
+ assert(acked.includes(mainRs.key), "bulleted verbatim ack resolves to the advisory's stable key");
254
+ });
255
+
256
+ // A collision in the advisory fingerprint would let a NEWER, unacknowledged advisory on the same
257
+ // path pass the gate on a resolved ack for a DIFFERENT advisory — a false-OPEN. The former 32-bit
258
+ // FNV-1a digest was cheaply collidable; the key now carries a 128-bit (32-hex) SHA-256 slice.
259
+ test("advisoryStableKey: digest is a collision-resistant 128-bit (32-hex) SHA-256 slice", () => {
260
+ const p = "app/x.ts";
261
+ const hash = advisoryStableKey(p, "Some advisory prose.").split("#")[1];
262
+ assertEquals(hash.length, 32, "digest is 32 hex chars (128 bits)");
263
+ assert(/^[0-9a-f]{32}$/.test(hash), "digest is lowercase hex");
264
+ // Distinct prose on the same path yields distinct keys (no cheap collision surface).
265
+ assertNotEquals(
266
+ advisoryStableKey(p, "Narrow this return type."),
267
+ advisoryStableKey(p, "Guard against a null argument here."),
268
+ );
269
+ });
270
+
271
+ // ── Issue #787: a DECLINED advisory must not livelock the gate when its line drifts ──────────
272
+ //
273
+ // A declined advisory is re-emitted by Copilot every round; any unrelated edit shifts its line, so
274
+ // Copilot re-anchors it to a new line. Keying the ack on the line-stable prose fingerprint (not
275
+ // path:line) keeps a prior-round ack matching the re-emitted advisory across the drift.
276
+ test("converge gate #787: a stable-key ack survives a line drift and keeps the advisory acknowledged", () => {
277
+ const proseText = "Consider narrowing this type.";
278
+ // Round 1: Copilot suppressed the advisory at line 360; the agent acked it with the new form.
279
+ const round1Body = [
280
+ "<details>",
281
+ "<summary>Suppressed comments (1)</summary>",
282
+ "",
283
+ "**app/deliveryRunner.ts:360**",
284
+ `- ${proseText}`,
285
+ "</details>",
286
+ ].join("\n");
287
+ // Round 2: an unrelated edit shifted the SAME advisory to line 369; Copilot re-emitted it there.
288
+ const round2Body = round1Body.replace("app/deliveryRunner.ts:360", "app/deliveryRunner.ts:369");
289
+ // The resolved ack thread from round 1 persists (its marker text is line-independent).
290
+ const ackThreads: ReviewThread[] = [
291
+ { isResolved: true, path: "app/deliveryRunner.ts", bodies: [`Declined, false positive. nano-ack: app/deliveryRunner.ts :: ${proseText}`] },
292
+ ];
293
+ const acknowledgedKeys = parseAckedAdvisories(ackThreads);
294
+
295
+ const round1 = evaluateConvergeGate({
296
+ unresolvedThreadCount: 0,
297
+ suppressedAdvisories: parseSuppressedAdvisories(round1Body),
298
+ acknowledgedKeys,
299
+ });
300
+ assertEquals(round1.convergeBlocked, false);
301
+
302
+ // The drift MUST NOT re-block: the round-1 ack still acknowledges the round-2 re-emission.
303
+ const round2 = evaluateConvergeGate({
304
+ unresolvedThreadCount: 0,
305
+ suppressedAdvisories: parseSuppressedAdvisories(round2Body),
306
+ acknowledgedKeys,
307
+ });
308
+ assertEquals(round2.convergeBlocked, false);
309
+ });
310
+
311
+ test("converge gate #787: a genuinely new, never-acked advisory still blocks (no false-open)", () => {
312
+ const body = [
313
+ "<details>",
314
+ "<summary>Suppressed comments (2)</summary>",
315
+ "",
316
+ "**app/x.ts:10**",
317
+ "- The declined advisory that was acknowledged.",
318
+ "",
319
+ "**app/x.ts:20**", // SAME path, DIFFERENT advisory — never acknowledged.
320
+ "- A brand-new concern that was never triaged.",
321
+ "</details>",
322
+ ].join("\n");
323
+ const ackThreads: ReviewThread[] = [
324
+ { isResolved: true, path: "app/x.ts", bodies: ["Declined. nano-ack: app/x.ts :: The declined advisory that was acknowledged."] },
325
+ ];
326
+ const r = evaluateConvergeGate({
327
+ unresolvedThreadCount: 0,
328
+ suppressedAdvisories: parseSuppressedAdvisories(body),
329
+ acknowledgedKeys: parseAckedAdvisories(ackThreads),
330
+ });
331
+ assertEquals(r.convergeBlocked, true);
332
+ // Only the un-acked advisory on the same path is reported; the acked one is not.
333
+ assertStringIncludes(r.convergeBlockReason, "app/x.ts:20");
334
+ assert(!r.convergeBlockReason.includes("app/x.ts:10"), "the acknowledged advisory must not be listed");
117
335
  });
118
336
 
119
337
  test("parseReviewThreadsPage: maps nodes and reports a complete (final) page", () => {
@@ -254,8 +472,18 @@ test("converge-gate: an unacknowledged suppressed advisory blocks convergence",
254
472
  test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
255
473
  const handler = await makeUnderTest({
256
474
  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"] },
475
+ {
476
+ isResolved: true,
477
+ path: "spec-app/nano-app.schema.json",
478
+ bodies: [
479
+ "Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
480
+ ],
481
+ },
482
+ {
483
+ isResolved: true,
484
+ path: "server/src/main.rs",
485
+ bodies: ["Declined, false positive. nano-ack: server/src/main.rs :: Consider narrowing this type."],
486
+ },
259
487
  ],
260
488
  readReviewBody: async () => SAMPLE_REVIEW_BODY,
261
489
  });
@@ -6,16 +6,19 @@
6
6
  // the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
7
7
  // while either:
8
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).
9
+ // • any SUPPRESSED advisory (in the latest Copilot review body) lacks a matching
10
+ // RESOLVED ack thread (a thread carrying a line-stable `nano-ack: <path> :: <text>` marker; the
11
+ // bare `nano-ack: <path>:<line>` form is NOT honoured — its `path:line` key is prose-blind and
12
+ // would false-OPEN a new advisory re-emitted at a previously-acked line).
11
13
  // A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
12
14
 
13
15
  export interface ConvergeGateInput {
14
16
  /** Count of review threads with `isResolved === false`. */
15
17
  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. */
18
+ /** Copilot's suppressed advisories (latest review body), each with its line-stable key + label. */
19
+ suppressedAdvisories: { key: string; label: string }[];
20
+ /** Acknowledged line-stable keys (`<path>#<fp>`) from RESOLVED `nano-ack:` threads. An advisory is
21
+ * acked iff its stable key appears here. */
19
22
  acknowledgedKeys: string[];
20
23
  }
21
24
 
@@ -28,7 +31,11 @@ export interface ConvergeGateResult {
28
31
  * feeds it live GitHub state and fails CLOSED (blocks) when that state cannot be read. */
29
32
  export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResult {
30
33
  const acked = new Set(input.acknowledgedKeys);
31
- const unacked = input.suppressedKeys.filter((k) => !acked.has(k));
34
+ // An advisory is acknowledged iff its line-stable prose key was acked — the stable key survives a
35
+ // line drift across rounds (issue #787). The prose-blind legacy `path:line` key is intentionally
36
+ // NOT consulted: it would let a resolved ack for one advisory silently acknowledge a genuinely new
37
+ // advisory re-emitted at the same line (a false-OPEN this gate exists to prevent).
38
+ const unacked = input.suppressedAdvisories.filter((a) => !acked.has(a.key));
32
39
  const reasons: string[] = [];
33
40
  if (input.unresolvedThreadCount > 0) {
34
41
  const n = input.unresolvedThreadCount;
@@ -36,13 +43,13 @@ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResu
36
43
  }
37
44
  if (unacked.length > 0) {
38
45
  const noun = unacked.length === 1 ? "advisory" : "advisories";
39
- reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.join(", ")})`);
46
+ reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.map((a) => a.label).join(", ")})`);
40
47
  }
41
48
  if (reasons.length === 0) {
42
49
  return { convergeBlocked: false, convergeBlockReason: "" };
43
50
  }
44
51
  return {
45
52
  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.`,
53
+ convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path> :: <verbatim advisory text>) for each suppressed advisory before converging.`,
47
54
  };
48
55
  }
package/app/github.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  // The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
12
12
  // Cross-runtime: runs under Node (`node:child_process`).
13
13
 
14
+ import { createHash } from "node:crypto";
15
+
14
16
  // Type-only import (erased at runtime, so no runtime cycle with mergeProtocol.ts, which imports
15
17
  // `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate
16
18
  // a merge independently of GitHub branch protection.
@@ -90,9 +92,23 @@ export async function fetchPrReviews(
90
92
  // • SUPPRESSED / low-confidence advisories — Copilot folds these into the review BODY under a
91
93
  // "Suppressed comments (N)" block; they are NOT threads, cannot be resolved, and are re-listed
92
94
  // every round. To make "acknowledged" trackable, the review-round agent must post a RESOLVED
93
- // review thread carrying a `nano-ack: <path>:<line>` marker (the exact key from Copilot's
94
- // `**path:line**` header) for each advisory it applies or declines. The gate then treats an
95
- // advisory as addressed iff a resolved thread carries its ack marker.
95
+ // review thread carrying a `nano-ack:` marker for each advisory it applies or declines. The gate
96
+ // then treats an advisory as addressed iff a resolved thread carries a matching ack marker.
97
+ //
98
+ // The ack key must be LINE-STABLE. Keying it on `path:line` (issue #787) livelocks a DECLINED
99
+ // advisory: Copilot re-emits a declined advisory every round, but any unrelated edit in the PR
100
+ // shifts its line, so Copilot re-anchors it to a new line. A prior-round `nano-ack: path:OLD` no
101
+ // longer matches the re-emitted `path:NEW`, the gate sees a freshly "unacknowledged" advisory, and
102
+ // escalates to a human every round. The fix keys acknowledgement on a line-independent identity —
103
+ // `<path>#<fingerprint>` of the advisory's PROSE — so a drifted line still matches. The resolved
104
+ // ack thread is itself the durable store: its marker text survives across rounds regardless of the
105
+ // line, so a decline stays acknowledged without the agent re-acking each round. The new marker is
106
+ // `nano-ack: <path> :: <verbatim advisory text>`. This line-stable prose key is the SOLE ack
107
+ // identity. A bare `nano-ack: <path>:<line>` form is NOT honoured: keyed only on `path:line`, it is
108
+ // blind to the advisory's prose, so a resolved legacy ack for advisory A at a line would silently
109
+ // acknowledge a genuinely NEW advisory B re-emitted at that same line — a false-OPEN this gate exists
110
+ // to prevent. (Issue #787 introduces the ack mechanism itself in this change, so there is no pre-#787
111
+ // legacy-ack corpus to protect by honouring the prose-blind form.)
96
112
 
97
113
  /** One PR review thread, narrowed to what the convergence gate needs. */
98
114
  export interface ReviewThread {
@@ -101,30 +117,141 @@ export interface ReviewThread {
101
117
  bodies: string[];
102
118
  }
103
119
 
104
- /** The `nano-ack:` acknowledgement marker the review-round agent stamps into the resolved thread
105
- * it opens per suppressed advisory. The captured group is the advisory key (`path:line`). */
106
- const ACK_MARKER = /nano-ack:\s*([^\s)>*]+:\d+)/gi;
107
-
108
- /** Parse the `path:line` keys of Copilot's suppressed / low-confidence advisories out of a review
109
- * body. Copilot renders them under a `<summary>Suppressed comments (N)</summary>` block, each as a
110
- * bold `**path:line**` header. Returns the de-duplicated keys (empty when there is no such block). */
111
- export function parseSuppressedAdvisories(reviewBody: string | null | undefined): string[] {
120
+ /** A suppressed / low-confidence Copilot advisory parsed out of a review body. Its `key` is the
121
+ * line-stable identity (survives a line drift); `label` is the human-facing `path:line` shown in
122
+ * block reasons. */
123
+ export interface SuppressedAdvisory {
124
+ path: string;
125
+ line: number;
126
+ /** The advisory prose (first non-empty line after the header), used for the stable fingerprint. */
127
+ text: string;
128
+ /** Line-stable identity: `<path>#<fingerprint>` of the normalized prose. Survives line drift. */
129
+ key: string;
130
+ /** Human-facing `path:line` label for block-reason messages. */
131
+ label: string;
132
+ }
133
+
134
+ /** Any `nano-ack:` marker — captures the rest of the marker's line (path + optional `:: text`). */
135
+ const ACK_MARKER = /nano-ack:\s*([^\n\r]+)/gi;
136
+ /** The ONLY honoured ack form: line-stable `<path> :: <advisory text>`. The delimiter is ` :: ` with
137
+ * REQUIRED surrounding whitespace (matching the canonical marker the agent authors), so a bare `::`
138
+ * inside a valid GitHub path (e.g. `src/a::b.ts`) is NOT mistaken for the separator — the path group
139
+ * parses non-greedily up to the first *whitespace-delimited* ` :: `, so a path containing spaces
140
+ * (e.g. `docs/my file.md`) is still honoured. A bare `<path>:<line>` marker is intentionally not
141
+ * parsed: keyed only on `path:line`, it is blind to the advisory prose and would false-OPEN a new
142
+ * advisory re-emitted at a previously-acked line. */
143
+ const NEW_ACK = /^(.+?)\s+::\s+(.+)$/s;
144
+
145
+ /** Normalize advisory prose to a line-/format-independent form before fingerprinting: strip a
146
+ * leading markdown bullet, NFC-normalize, lowercase, and collapse runs of WHITESPACE to a single
147
+ * space. Punctuation is PRESERVED, NOT collapsed: the prompt requires the agent to copy the
148
+ * advisory's first line VERBATIM, so whitespace/case tolerance is all that is needed to absorb
149
+ * trivial markdown/whitespace reflow. Collapsing every non-word run into a space (as an earlier
150
+ * revision did) instead ALIASES genuinely-distinct advisories whose prose differs only by
151
+ * punctuation-vs-space — e.g. `Use foo() here` vs `Use foo here`, or `foo/bar` vs `foo bar` — so a
152
+ * resolved ack for advisory A would silently acknowledge a DIFFERENT advisory B that normalizes to
153
+ * the same key: a false-OPEN this gate exists to prevent. Preserving punctuation errs toward a
154
+ * stricter match, which is fail-CLOSED: a benign punctuation mismatch merely re-escalates to a
155
+ * human, and never converges an unacknowledged advisory.
156
+ *
157
+ * NFC — canonical composition — is used deliberately in preference to NFKC. NFKC additionally folds
158
+ * COMPATIBILITY variants (full-width `!` → ASCII `!`, ligatures, super/subscripts, …), which would
159
+ * ALIAS genuinely-distinct advisories such as `Use foo!` and `Use foo!` to one key — the very
160
+ * false-OPEN this fingerprint exists to prevent, and a contradiction with "punctuation is
161
+ * preserved". NFC only unifies sequences that are canonically equivalent (visually and semantically
162
+ * identical, e.g. a precomposed `é` vs `e`+combining-acute), so verbatim copies still match while
163
+ * distinct compatibility forms stay distinct (fail-CLOSED). Unicode letters/digits are preserved
164
+ * rather than stripped, so non-ASCII-only prose still yields a non-empty, distinct key.
165
+ *
166
+ * The leading-bullet strip keeps the ADVISORY side (Copilot renders suppressed prose as `* …`, which
167
+ * `parseSuppressedAdvisories` also strips for display) and the ACK side SYMMETRIC: the prompt tells
168
+ * the agent to copy the advisory's first line verbatim, so an ack marker legitimately carries the
169
+ * `* ` bullet — without stripping it here the ack key would differ from the advisory key and the
170
+ * gate would never converge (fail-CLOSED livelock). Applying it in this shared canonicaliser is the
171
+ * SINGLE source of truth for both sides.
172
+ *
173
+ * The bullet marker REQUIRES trailing whitespace (`[-*]\s+`): a genuine markdown bullet is always
174
+ * `- ` / `* ` followed by a space, so `-foo` / `*foo` (leading punctuation, no separator) is NOT a
175
+ * bullet and its leading char is PRESERVED. A greedy `\s*` there would strip the `-`/`*` off such
176
+ * prose too, collapsing distinct first lines like `-foo` and `foo` to one key — a false-ACK
177
+ * (false-OPEN) where acking one silently satisfies the other. */
178
+ function normalizeAdvisoryText(text: string): string {
179
+ return text
180
+ .normalize("NFC")
181
+ .replace(/^\s*[-*]\s+/u, "")
182
+ .toLowerCase()
183
+ .replace(/\s+/gu, " ")
184
+ .trim();
185
+ }
186
+
187
+ /** COLLISION-RESISTANT fingerprint of a string → 32-hex-char (128-bit) digest, the leading half of
188
+ * a SHA-256 hash. Deterministic and dependency-free (Node's built-in `node:crypto`, no npm dep).
189
+ *
190
+ * The gate treats an advisory whose key `<path>#<fingerprint>` matches a resolved ack as addressed,
191
+ * so a *collision* would let a NEWER, unacknowledged advisory on the same path pass without its own
192
+ * ack — a false-OPEN that violates the gate's no-false-open guarantee. The former 32-bit FNV-1a
193
+ * digest was cheap to collide (birthday bound ~2^16); a 128-bit SHA-256 slice makes an accidental
194
+ * collision (~2^-64 for realistic advisory counts) infeasible. The digest is INTERNAL to the key —
195
+ * it never appears in a human-authored `nano-ack:` marker (those carry the verbatim prose, which is
196
+ * re-fingerprinted at read time), so widening it neither lengthens any marker nor breaks a
197
+ * previously-issued one: both the advisory side and the ack side recompute with this same function. */
198
+ function fingerprint(s: string): string {
199
+ return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 32);
200
+ }
201
+
202
+ /** The line-stable acknowledgement key for an advisory: `<path>#<fingerprint(normalized prose)>`.
203
+ * Exported so the review-round agent's contract and tests share one canonical implementation. */
204
+ export function advisoryStableKey(path: string, text: string): string {
205
+ return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`;
206
+ }
207
+
208
+ /** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
209
+ * under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
210
+ * followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */
211
+ export function parseSuppressedAdvisories(reviewBody: string | null | undefined): SuppressedAdvisory[] {
112
212
  const body = reviewBody ?? "";
113
213
  const idx = body.search(/Suppressed comments\s*\(/i);
114
214
  if (idx < 0) return [];
115
215
  // Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the
116
216
  // overview prose can never be mistaken for an advisory.
117
217
  const region = body.slice(idx);
118
- const keys = new Set<string>();
119
- const re = /\*\*([^*]+?:\d+)\*\*/g;
120
- let m: RegExpExecArray | null;
121
- // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
122
- while ((m = re.exec(region)) !== null) keys.add(m[1].trim());
123
- return [...keys];
218
+ const lines = region.split(/\r?\n/);
219
+ const headerRe = /\*\*([^*]+?):(\d+)\*\*/;
220
+ const out: SuppressedAdvisory[] = [];
221
+ const seen = new Set<string>();
222
+ for (let i = 0; i < lines.length; i++) {
223
+ const h = headerRe.exec(lines[i]);
224
+ if (!h) continue;
225
+ const path = h[1].trim();
226
+ const line = Number(h[2]);
227
+ const label = `${path}:${line}`;
228
+ // The advisory prose is the first non-empty line after the header (up to the next header). A
229
+ // single bullet is the common shape; strip a leading markdown bullet marker for the display
230
+ // `text`. (Keying is bullet-insensitive regardless: `normalizeAdvisoryText` strips a leading
231
+ // bullet too, so the ack side — which copies the bulleted first line verbatim — keys the same.)
232
+ let text = "";
233
+ for (let j = i + 1; j < lines.length; j++) {
234
+ if (headerRe.test(lines[j])) break;
235
+ const t = lines[j].replace(/^\s*[-*]\s+/, "").trim();
236
+ if (t) {
237
+ text = t;
238
+ break;
239
+ }
240
+ }
241
+ const key = advisoryStableKey(path, text);
242
+ if (seen.has(key)) continue;
243
+ seen.add(key);
244
+ out.push({ path, line, text, key, label });
245
+ }
246
+ return out;
124
247
  }
125
248
 
126
249
  /** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
127
- * count — an open ack thread is not yet an acknowledgement). */
250
+ * count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`<path>#<fp>`)
251
+ * parsed from the `nano-ack: <path> :: <text>` form ONLY. A bare `nano-ack: <path>:<line>` marker is
252
+ * intentionally NOT honoured: its `path:line` key is blind to the advisory prose and would false-OPEN
253
+ * a genuinely new advisory re-emitted at a previously-acked line. The gate treats an advisory as
254
+ * acked iff its stable key appears here. */
128
255
  export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
129
256
  const acked = new Set<string>();
130
257
  for (const t of threads) {
@@ -133,7 +260,10 @@ export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
133
260
  ACK_MARKER.lastIndex = 0;
134
261
  let m: RegExpExecArray | null;
135
262
  // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
136
- while ((m = ACK_MARKER.exec(body)) !== null) acked.add(m[1].trim());
263
+ while ((m = ACK_MARKER.exec(body)) !== null) {
264
+ const nw = NEW_ACK.exec(m[1].trim());
265
+ if (nw) acked.add(advisoryStableKey(nw[1], nw[2]));
266
+ }
137
267
  }
138
268
  }
139
269
  return [...acked];
@@ -0,0 +1,84 @@
1
+ // Regression guard for issue #767 — the shared PR-escalation Tasks form must not render a blank
2
+ // read-only "Escalation question" field. The Tasks surface (`detail.engineForm`) seeds NO form
3
+ // variables, so the form's `question` control could never be populated at render time: it showed up
4
+ // blank while the actual request was already rendered separately as "Decision context" (the
5
+ // `user_tasks.question` read-model column, sourced from `latestOpenEscalationQuestion`). This mirrors
6
+ // the fix for the sibling `delivery-human-generic.form` (#773): make the form static / input-only —
7
+ // no `{{tokens}}`, no data-dependent `conditional`, and exactly one editable, required `answer` — and
8
+ // rely on the already-populated Decision context as the read-only prompt.
9
+ //
10
+ // This is the task/form-boundary guard the issue asks for, distinct from the durable `user_tasks`
11
+ // projection coverage in `pollUserTasks.test.ts`: it ties the committed `.form`, the BPMN
12
+ // `formDefinition` linkage, and the completer's typed contract together so none can drift back to a
13
+ // blank-question render.
14
+
15
+ import { test } from "node:test";
16
+ import { readFileSync } from "node:fs";
17
+ import { assert, assertEquals } from "#test-assert";
18
+ import { validateEscalationVariables } from "./agentCompletion.ts";
19
+
20
+ // biome-ignore lint/suspicious/noExplicitAny: form-js component shape is untyped JSON.
21
+ type FormComponent = { type: string; key?: string; readonly?: boolean; conditional?: unknown; validate?: any };
22
+ type Form = { id: string; components: FormComponent[] };
23
+
24
+ const formText = readFileSync("resources/forms/pr-escalation.form", "utf8");
25
+ const form = JSON.parse(formText) as Form;
26
+
27
+ test("pr-escalation.form: has exactly one editable, required input — `answer` — and no blank readonly question field", () => {
28
+ assertEquals(form.id, "pr-escalation");
29
+ // Only ONE keyed (data-bearing) control, and it is the editable required `answer`.
30
+ const inputs = form.components.filter((c) => typeof c.key === "string");
31
+ assertEquals(
32
+ inputs.map((c) => c.key),
33
+ ["answer"],
34
+ "the form must expose exactly one input control, keyed `answer`",
35
+ );
36
+ const answer = inputs[0];
37
+ assert(answer.readonly !== true, "the `answer` control must be editable, not read-only");
38
+ assertEquals(answer.validate?.required, true, "the `answer` control must be required");
39
+ // The blank read-only `question` field the operator could never fill (issue #767) must be gone —
40
+ // Decision context is the canonical read-only prompt now.
41
+ assert(
42
+ !form.components.some((c) => c.key === "question"),
43
+ "the blank readonly `question` field must be removed (Decision context is the prompt)",
44
+ );
45
+ });
46
+
47
+ test("pr-escalation.form: is static — no `{{token}}` templating and no data-dependent `conditional`", () => {
48
+ // Deploy-time `{{token}}` templating is removed repo-wide (AGENTS.md), and the Tasks `engineForm`
49
+ // seeds no variables, so any data-dependent gate would never resolve — the exact failure mode #773
50
+ // fixed for the sibling form. Guard against a reappearance on this one.
51
+ assert(!/\{\{[^}]*\}\}/.test(formText), "the form must not contain `{{token}}` templating");
52
+ assert(
53
+ !form.components.some((c) => c.conditional !== undefined),
54
+ "the form must not use a data-dependent `conditional` the Tasks surface can never resolve",
55
+ );
56
+ });
57
+
58
+ test("pr-escalation.form: both PR escalation elements still complete with the `{ answer }` contract", () => {
59
+ // Preserve the existing typed completion contract: `wait-answer` and `wait-merge-answer` both map to
60
+ // this form and require a non-blank `answer` (validated via `validateEscalationVariables`). A missing
61
+ // answer is rejected; a present one accepted — proving the input-only rewrite kept the contract.
62
+ for (const element of ["wait-answer", "wait-merge-answer"]) {
63
+ assert(
64
+ validateEscalationVariables(element, {}) !== null,
65
+ `${element} must reject a missing answer (pr-escalation form contract)`,
66
+ );
67
+ assert(
68
+ validateEscalationVariables(element, { answer: "rerun the round" }) === null,
69
+ `${element} must accept a valid { answer }`,
70
+ );
71
+ }
72
+ });
73
+
74
+ test("drift guard: both PR escalation user tasks link the pr-escalation form", () => {
75
+ // The form the code validates against must be the SAME one both models render, or the operator sees
76
+ // a different (blank-question) form than the contract guards.
77
+ for (const model of ["convergence-loop", "merge-loop"]) {
78
+ const bpmn = readFileSync(`resources/processes/${model}.bpmn`, "utf8");
79
+ assert(
80
+ /formId="pr-escalation"/.test(bpmn),
81
+ `${model}.bpmn must link the pr-escalation form`,
82
+ );
83
+ }
84
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.187.1",
3
+ "version": "0.187.3",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -4,17 +4,15 @@
4
4
  "type": "default",
5
5
  "components": [
6
6
  {
7
- "type": "textarea",
8
- "key": "question",
9
- "label": "Escalation question",
10
- "description": "A human decision is needed to resume the PR review-convergence loop.",
11
- "readonly": true
7
+ "type": "text",
8
+ "label": "Respond to this escalation",
9
+ "text": "A human decision is needed to resume the PR process. The requested decision is shown as **Decision context** above. Read it, then enter your reply below."
12
10
  },
13
11
  {
14
12
  "type": "textarea",
15
13
  "key": "answer",
16
14
  "label": "Your answer",
17
- "description": "Your reply resumes the review loop and is handed to the next review round.",
15
+ "description": "Your reply resumes the PR process and is handed to its next step. Answer the request shown in Decision context above.",
18
16
  "validate": {
19
17
  "required": true
20
18
  }
@@ -119,19 +119,43 @@ Because several agents may run on the same host at once:
119
119
  carries a **resolved** acknowledgement, so a decision you only wrote into your
120
120
  `summary` is invisible to the gate. For each suppressed advisory you applied or
121
121
  declined (step 2), post a **new review comment thread** whose body contains the
122
- verbatim marker **`nano-ack: <path>:<line>`** — copied exactly from Copilot's
123
- bold `**<path>:<line>**` header for that advisory then **resolve** that thread.
124
- The gate matches on the marker **text**, so the thread may sit on any valid diff
125
- line; only the exact `path:line` string must match. Example:
122
+ verbatim marker **`nano-ack: <path> :: <advisory text>`** — the `<path>` from
123
+ Copilot's bold `**<path>:<line>**` header and `<advisory text>` copied
124
+ **verbatim** from the first line of that advisory's prose then **resolve** that
125
+ thread. The gate keys the acknowledgement on the advisory **text** (a
126
+ line-independent fingerprint of `<path> + <advisory text>`), *not* on the line
127
+ number: this is deliberate. A **declined** advisory is re-emitted every round,
128
+ and any unrelated edit you make shifts its line, so Copilot re-anchors it to a
129
+ new line — a line-based ack would go stale and the gate would escalate to a human
130
+ every round (issue #787). Because the ack is keyed on the prose, a decline you
131
+ made in an earlier round stays acknowledged across the drift and you need **not**
132
+ re-ack it. The ack thread may sit on any valid diff line. Example:
126
133
 
127
134
  ```sh
128
135
  # Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
129
136
  # SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
130
137
  CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
131
- gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path=PATH -F line=LINE -f side=RIGHT \
132
- -f body='Applied. nano-ack: <path>:<line>' # or: 'Declined, false positive <reason>. nano-ack: <path>:<line>'
138
+ # Build the body via a QUOTED heredoc so the verbatim advisory prose is never re-interpreted by
139
+ # the shell — a single-quoted `-f body='...'` breaks the moment the prose contains a `'` (e.g.
140
+ # "doesn't handle ..."), and a double-quoted one breaks on `$`/backticks. `<<'EOF'` (quoted
141
+ # delimiter) disables ALL expansion, so any advisory text is safe:
142
+ BODY=$(cat <<'EOF'
143
+ Applied. nano-ack: <path> :: <verbatim advisory text>
144
+ EOF
145
+ ) # to DECLINE instead, build the body the same quoted-heredoc way (never a single-quoted
146
+ # `-f body='...'`, which breaks the moment the reason or advisory prose contains a `'`):
147
+ # BODY=$(cat <<'EOF'
148
+ # Declined, false positive — <reason>. nano-ack: <path> :: <verbatim advisory text>
149
+ # EOF
150
+ # )
151
+ gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path="PATH" -F line=LINE -f side=RIGHT -f body="$BODY"
133
152
  # Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
134
153
  ```
154
+ Only the `nano-ack: <path> :: <text>` (prose-keyed) form is honoured. A bare
155
+ `nano-ack: <path>:<line>` marker is **not** an acknowledgement: keyed only on
156
+ `path:line`, it is blind to the advisory's prose, so it would let a resolved ack
157
+ for one advisory silently acknowledge a genuinely new advisory re-emitted at that
158
+ same line. Always use the `<path> :: <text>` form.
135
159
  6. **Do NOT request, re-request, or remove the reviewer yourself.** Keeping
136
160
  Copilot attached is the **process's** job: a deterministic poller ensures a
137
161
  Copilot review is requested (idempotently) whenever this PR is waiting, and it
@@ -8,7 +8,11 @@
8
8
  // comments:
9
9
  // • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
10
10
  // • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
11
- // thread (a `nano-ack: <path>:<line>` marker copied from Copilot's `**path:line**` header).
11
+ // thread (a `nano-ack: <path> :: <verbatim advisory text>` marker whose line-stable prose
12
+ // fingerprint matches Copilot's advisory). The bare legacy `nano-ack: <path>:<line>` form is
13
+ // NOT honoured: keyed only on `path:line`, it is blind to the advisory prose and would let a
14
+ // resolved ack for one advisory silently acknowledge a genuinely new advisory re-emitted at
15
+ // that same line (a false-OPEN). Only the prose-keyed `<path> :: <text>` form acknowledges.
12
16
  // A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
13
17
  // the human `wait-answer` escalation (recoverable), never a hard wedge.
14
18
  //
@@ -87,9 +91,10 @@ export function makeHandler(deps: {
87
91
  return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
88
92
  }
89
93
  const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
94
+ const advisories = parseSuppressedAdvisories(reviewBody);
90
95
  result = evaluateConvergeGate({
91
96
  unresolvedThreadCount,
92
- suppressedKeys: parseSuppressedAdvisories(reviewBody),
97
+ suppressedAdvisories: advisories.map((a) => ({ key: a.key, label: a.label })),
93
98
  acknowledgedKeys: parseAckedAdvisories(threads),
94
99
  });
95
100
  } catch {