@nanobpm/nano-workforce 0.187.0 → 0.187.2

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.2](https://github.com/nanobpm/nano-workforce/compare/v0.187.1...v0.187.2) (2026-09-13)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **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)
6
+
7
+ ## [0.187.1](https://github.com/nanobpm/nano-workforce/compare/v0.187.0...v0.187.1) (2026-09-12)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery-graph:** emit a deterministic branch.create so agent cells never commit on the base branch ([#781](https://github.com/nanobpm/nano-workforce/issues/781)) ([56f6488](https://github.com/nanobpm/nano-workforce/commit/56f6488344b439a5ac12f5069567facd9519bf66)), closes [#776](https://github.com/nanobpm/nano-workforce/issues/776) [pre-#776](https://github.com/nanobpm/pre-/issues/776) [#776](https://github.com/nanobpm/nano-workforce/issues/776)
12
+
1
13
  ## [0.187.0](https://github.com/nanobpm/nano-workforce/compare/v0.186.4...v0.187.0) (2026-09-12)
2
14
 
3
15
  ### Features
package/app/contracts.ts CHANGED
@@ -399,7 +399,7 @@ export const WIRE_CONTRACTS = {
399
399
  name: "io.nanobpm.agentTask.repository",
400
400
  owner: "app/repoEnvelope.ts",
401
401
  semantics:
402
- "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
402
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted for a single-task feature run AND per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231).",
403
403
  shape:
404
404
  'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }, task: { allowPr: true } }',
405
405
  },
@@ -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
  }
@@ -1057,15 +1057,19 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
1057
1057
 
1058
1058
  /** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
1059
1059
  * `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
1060
- * DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY agent service
1061
- * task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the runner has a
1062
- * single, uniform anchor to replace with the effective envelope on every cell. Digest-stable and
1063
- * env-free only the declared values (pure graph content) appear here; the run-level fallback and the
1064
- * env-dependent `cloneTimeoutMs` are injected by the runner POST-digest. Declared values pass the
1065
- * `owner/repo` + branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile chars. */
1060
+ * `id` and its DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY
1061
+ * agent service task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the
1062
+ * runner has a single, uniform anchor to replace with the effective envelope on every cell. The node
1063
+ * `id` is carried here (issue #776) so the runner can emit the deterministic per-node `feat/<node.id>`
1064
+ * `branch.create` on the injected envelope the identity is not otherwise recoverable from the isolated
1065
+ * `<zeebe:taskHeaders>` block the runner rewrites. Digest-stable and env-free only the node id and
1066
+ * declared values (pure graph content) appear here; the run-level fallback and the env-dependent
1067
+ * `cloneTimeoutMs` are injected by the runner POST-digest. The id + declared values pass the
1068
+ * `owner/repo` + node-id/branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile
1069
+ * chars. */
1066
1070
  function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
1067
1071
  const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
1068
- const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
1072
+ const spec = JSON.stringify({ nodeId: node.id, repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
1069
1073
  return [
1070
1074
  " <zeebe:taskHeaders>",
1071
1075
  ` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
@@ -13,7 +13,7 @@ import { test } from "node:test";
13
13
  import { assert, assertEquals, assertRejects } from "#test-assert";
14
14
  import { AGENT_TERMINAL_SUCCESS_STATUSES } from "./deliveryGraphCompiler.ts";
15
15
  import { prepareDeliveryGraph, renderEmitContract, renderIdempotencyPreamble, renderProducerContract, runDeliveryGraph } from "./deliveryRunner.ts";
16
- import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
16
+ import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError, agentNodeRepoEnvelope } from "./repoEnvelope.ts";
17
17
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
18
18
 
19
19
  const GRAPH: DeliveryGraph = {
@@ -488,6 +488,9 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
488
488
  // Repo-provisioning auth gate (issue #770): the per-cell envelope carries `task.allowPr:true` so the
489
489
  // c8ctl harness resolves the git credential for the clone instead of failing on password prompt.
490
490
  assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
491
+ // The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself,
492
+ // so the agent can never be left committing on the base branch (a non-ff push that strands the run).
493
+ assert(headers.some((h) => h.includes('repository.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
491
494
  // No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
492
495
  assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
493
496
  // No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
@@ -542,6 +545,68 @@ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harn
542
545
  assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/one.git"')), "url present");
543
546
  assert(!headers.some((h) => h.includes("repository.ref")), "no ref → clone the repo default branch");
544
547
  assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
548
+ // Even without a known base, the deterministic isolation branch is still emitted (issue #776): the
549
+ // harness cuts `feat/<node.id>` off the cloned default tip, so the agent never commits on the default.
550
+ assert(headers.some((h) => h.includes('repository.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
551
+ });
552
+
553
+ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create — never a shared branch (#776)", async () => {
554
+ const graph: DeliveryGraph = {
555
+ name: "deterministic isolation branches",
556
+ nodes: [
557
+ { id: "impl-a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
558
+ { id: "impl-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
559
+ ],
560
+ edges: [{ from: "impl-a", to: "impl-b" }],
561
+ };
562
+ const p = await prepareOk(graph, {});
563
+ const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
564
+ // Two distinct cells → two distinct per-node branches, so no two agents can collide on one branch.
565
+ assert(branches.some((h) => h.includes('value="feat/impl-a"')), `expected feat/impl-a, got ${JSON.stringify(branches)}`);
566
+ assert(branches.some((h) => h.includes('value="feat/impl-b"')), `expected feat/impl-b, got ${JSON.stringify(branches)}`);
567
+ assertEquals(branches.length, 2, "exactly one branch.create per agent cell");
568
+ });
569
+
570
+ test("an explicit repoless run emits NO branch.create — no envelope at all (#776/#729)", async () => {
571
+ const p = await prepareOk(GRAPH, { repoless: true });
572
+ assert(!p.bpmn.includes("repository.branch.create"), "a repoless run carries no isolation branch either");
573
+ });
574
+
575
+ test("a node id that derives an invalid git ref degrades to NO branch.create — never emits an unusable feat/... (#776)", async () => {
576
+ // Node ids are only constrained by `^[A-Za-z_][A-Za-z0-9_.-]*$`, laxer than git's ref rules: `a..b` and
577
+ // `a.lock` pass id validation but produce ill-formed `feat/...` refs the harness cannot create. The
578
+ // runner must degrade to the pre-#776 agent-cuts-its-own-branch behaviour (no branch.create) for those,
579
+ // not emit a branch the harness will choke on — while a sibling with a valid id still gets its branch.
580
+ const graph: DeliveryGraph = {
581
+ name: "invalid-ref node ids degrade",
582
+ nodes: [
583
+ { id: "a..b", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
584
+ { id: "a.lock", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
585
+ { id: "ok", kind: "agent", agent: { jobType: "senior:feature", prompt: "c", repository: "acme/three" } },
586
+ ],
587
+ edges: [{ from: "a..b", to: "a.lock" }, { from: "a.lock", to: "ok" }],
588
+ };
589
+ const p = await prepareOk(graph, {});
590
+ const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
591
+ // Only the valid-id cell keeps a branch.create; the two invalid-ref ids emit none (their agents still
592
+ // provision an isolated clone, they just cut their own branch inside it — the pre-#776 fallback).
593
+ assertEquals(branches.length, 1, `only the valid id keeps a branch.create, got ${JSON.stringify(branches)}`);
594
+ assert(branches[0].includes('value="feat/ok"'), `expected feat/ok, got ${JSON.stringify(branches)}`);
595
+ });
596
+
597
+ test("agentNodeRepoEnvelope emits branch.create off the base when known, off the default when not, and omits it when blank (#776)", () => {
598
+ const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"].repository;
599
+ assertEquals(withBase.ref, "main", "known base → checked-out ref");
600
+ assertEquals(withBase.branch.create, "feat/n1", "the harness cuts feat/<node.id> off the base");
601
+ const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"].repository;
602
+ assertEquals("ref" in noBase, false, "no base → clone the default branch");
603
+ assertEquals(noBase.branch.create, "feat/n2", "branch.create is still emitted off the default tip");
604
+ // A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key (pre-#776 behaviour).
605
+ assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].repository.branch.create, "feat/n3");
606
+ for (const blank of [null, undefined, "", " "]) {
607
+ const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"].repository;
608
+ assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
609
+ }
545
610
  });
546
611
 
547
612
  test("prepareDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
@@ -18,6 +18,7 @@ import { createHash, randomUUID } from "node:crypto";
18
18
  import type { EngineClient } from "@nanobpm/urban";
19
19
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
+ import { isPlausibleBranchName } from "./baseBranch.ts";
21
22
  import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
22
23
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
23
24
  import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
@@ -80,9 +81,10 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
80
81
  repository?: string | null;
81
82
  /** OPTIONAL run-level base-branch DEFAULT for `agent` nodes that declare a `repository` but no
82
83
  * `baseBranch` — the `ref` the harness checks out in the isolated clone (the PRE-PR shape: no PR head
83
- * exists yet, so the agent cuts its own `feat/<node.id>` branch off this base inside the clone). A
84
- * node with a resolvable repository but no base clones the repo's default branch, so this is a
85
- * convenience default, not a hard requirement. */
84
+ * exists yet). Per issue #776 the harness itself cuts the deterministic `feat/<node.id>` branch off
85
+ * this base (emitted as `branch.create`) so a forgetful agent can never be left committing on the base
86
+ * branch; a node with a resolvable repository but no base clones the repo's default branch, so this is
87
+ * a convenience default, not a hard requirement. */
86
88
  baseBranch?: string | null;
87
89
  /** EXPLICIT opt-out of repository provisioning (issue #729). `true` → the run is dispatched with NO
88
90
  * isolation envelope on ANY node (the per-node headers are stripped, the legacy launch-dir behaviour),
@@ -345,13 +347,28 @@ function injectAgentRepoEnvelopes(bpmn: string, graph: DeliveryGraph, options: D
345
347
  );
346
348
  return bpmn.replace(blockRe, (_full, indent: string, sq: string | undefined, dq: string | undefined, tail: string) => {
347
349
  const raw = sq ?? (dq ?? "").replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
348
- const declared: { repository: string | null; baseBranch: string | null } = JSON.parse(raw);
350
+ const declared: { nodeId?: string; repository: string | null; baseBranch: string | null } = JSON.parse(raw);
349
351
  // `repoless` → strip the block entirely (no isolation envelope, the launch-dir fallback).
350
352
  if (repoless) return "";
351
353
  const effRepo = trimOrNull(declared.repository) ?? runRepo;
352
354
  const effBase = trimOrNull(declared.baseBranch) ?? runBase;
355
+ // The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself
356
+ // so a cell can never leave its agent committing on the checked-out base branch (a non-ff push that
357
+ // strands the run — merlin job 20974). The node id rides the marker so it survives into this
358
+ // per-block rewrite; delivery-graph agent cells are single-instance, so the static per-node branch
359
+ // never collides across siblings. Omitted when the marker predates the id (a blank id degrades to the
360
+ // pre-#776 agent-cuts-its-own-branch behaviour rather than an ill-formed `feat/`).
361
+ // A graph node id is only constrained by `^[A-Za-z_][A-Za-z0-9_.-]*$` (deliveryGraph.ts), which is
362
+ // laxer than git's ref rules: ids like `a..b`, `a.`, or `a.lock` pass id validation yet produce an
363
+ // ill-formed `feat/...` ref the harness cannot create — turning the isolation guarantee into a
364
+ // launch/checkout failure. Validate the DERIVED branch with the same `isPlausibleBranchName` gate the
365
+ // dispatch doors use and degrade to null (the pre-#776 agent-cuts-its-own-branch behaviour) rather
366
+ // than emit an unusable branch.create.
367
+ const nodeId = trimOrNull(declared.nodeId);
368
+ const derivedBranch = nodeId ? `feat/${nodeId}` : null;
369
+ const branchCreate = derivedBranch !== null && isPlausibleBranchName(derivedBranch) ? derivedBranch : null;
353
370
  // The unresolved invariant above guarantees a resolvable repo here on a non-repoless run.
354
- const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase);
371
+ const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase, branchCreate);
355
372
  const flat = flattenAgentTaskEnvelope(envelope);
356
373
  const headerLines = Object.entries(flat).map(([k, v]) => `${indent} <zeebe:header key="${xmlAttr(k)}" value="${xmlAttr(v)}" />`);
357
374
  if (headerLines.length === 0) return "";
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];
@@ -211,13 +211,26 @@ export function repoEnvelopeVars(
211
211
  * `ref`): a cross-repo graph node carries only its `owner/repo` (an issue ref names no branch), so when
212
212
  * neither the node nor the run declares a base the cell must still provision an isolated clone — of the
213
213
  * repository's DEFAULT branch. The harness `provisionRepo` omits `--branch` when `ref` is blank, so a
214
- * `ref`-less envelope clones the default branch (each agent then cuts its own `feat/<node.id>` branch
215
- * inside the isolated clone, unchanged). Returns `{}` for a `repo` that is not a plain `owner/repo` (an
216
- * unresolved cell the runner's invariant rejects the run before this degrades to a launch-dir share).
217
- * Blobless single-branch shaping + `cloneTimeoutMs` (issues #287/#694) are emitted on every cell. */
218
- export function agentNodeRepoEnvelope(repo: string, base: string | null): Record<string, unknown> {
214
+ * `ref`-less envelope clones the default branch. Returns `{}` for a `repo` that is not a plain
215
+ * `owner/repo` (an unresolved cell the runner's invariant rejects the run before this degrades to a
216
+ * launch-dir share). Blobless single-branch shaping + `cloneTimeoutMs` (issues #287/#694) are emitted on
217
+ * every cell.
218
+ *
219
+ * Deterministic isolation branch (issue #776): `branchCreate` (the per-node `feat/<node.id>`) is emitted
220
+ * as `branch.create` so the harness cuts the isolation branch ITSELF — mirroring the single-feature path
221
+ * (`repoEnvelopeVars`/`feature.ts`) — instead of delegating branch-cutting to agent prompt discipline. A
222
+ * forgetful agent that never branched used to commit on the checked-out BASE branch; its `git push
223
+ * origin <base>` was then rejected non-fast-forward and the whole run's work was stranded (merlin job
224
+ * 20974; harness counterpart jwulf/c8ctl-plugin-nano#231). Emitting `branch.create` makes it impossible
225
+ * for a cell to leave its agent committing on the base. Emitted independently of `ref`: when the base is
226
+ * unknown the harness cuts the branch off the cloned default tip; when known, off `ref`. Omitted for a
227
+ * blank `branchCreate` (falls back to the pre-#776 agent-cuts-its-own-branch behaviour). Delivery-graph
228
+ * agent cells are single-instance, so a static per-node `feat/<node.id>` never collides across siblings
229
+ * (the MI-child collision the issue flags does not arise in the current single-instance cells). */
230
+ export function agentNodeRepoEnvelope(repo: string, base: string | null, branchCreate: string | null = null): Record<string, unknown> {
219
231
  if (!isPlainOwnerRepo(repo)) return {};
220
232
  const ref = typeof base === "string" && base.trim() !== "" ? base.trim() : null;
233
+ const create = typeof branchCreate === "string" && branchCreate.trim() !== "" ? branchCreate.trim() : null;
221
234
  return {
222
235
  [AGENT_TASK_NS]: {
223
236
  repository: {
@@ -226,9 +239,12 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null): Record
226
239
  singleBranch: true,
227
240
  filter: "blob:none",
228
241
  cloneTimeoutMs: cloneTimeoutMs(),
229
- // A per-node base (declared, or defaulted from the run level) — the branch the agent cuts its
242
+ // A per-node base (declared, or defaulted from the run level) — the branch the harness cuts its
230
243
  // `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
231
244
  ...(ref ? { ref, baseRef: ref } : {}),
245
+ // The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
246
+ // left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
247
+ ...(create ? { branch: { create } } : {}),
232
248
  },
233
249
  // Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
234
250
  // opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
package/openapi.yaml CHANGED
@@ -2112,8 +2112,9 @@ components:
2112
2112
  pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
2113
2113
  description: >-
2114
2114
  OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the
2115
- harness checks out in the isolated clone (the pre-PR shape: the agent cuts its own
2116
- `feat/<node.id>` branch off this base). Absent the run-level dispatch `baseBranch`
2115
+ harness checks out in the isolated clone (the pre-PR shape: the harness cuts the
2116
+ deterministic `feat/<node.id>` branch off this base itself, per #776, so a forgetful
2117
+ agent can never be left committing on the base branch). Absent → the run-level dispatch `baseBranch`
2117
2118
  (else the node's repository default branch). A value that is not a plausible git
2118
2119
  branch name is rejected at submit; the pattern mirrors the authoritative server-side
2119
2120
  gate (`isPlausibleBranchName`, app/baseBranch.ts).
@@ -5005,7 +5006,7 @@ paths:
5005
5006
  type: string
5006
5007
  maxLength: 255
5007
5008
  pattern: ^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$
5008
- description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the agent cuts its own `feat/<node.id>` branch off this base). Absent → the run-level dispatch `baseBranch` (else the node's repository default branch). A value that is not a plausible git branch name is rejected at submit; the pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts)."
5009
+ description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the harness cuts the deterministic `feat/<node.id>` branch off this base itself, per #776, so a forgetful agent can never be left committing on the base branch). Absent → the run-level dispatch `baseBranch` (else the node's repository default branch). A value that is not a plausible git branch name is rejected at submit; the pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts)."
5009
5010
  converge:
5010
5011
  type: boolean
5011
5012
  description: 'OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler- validated completion-policy flag on this cell node. It declares that the node''s opened PR is to be driven through the review-convergence loop to green as an edge-gated completion policy; this slice adds and validates the flag, with the delivery-graph execution wiring that consumes it landing in a follow-up slice. It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to smuggle. Converge and merge are SEPARABLE phases; a node may converge without merging (stop at green and gate the landing behind a downstream node).'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.187.0",
3
+ "version": "0.187.2",
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",
@@ -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 {