@nanobpm/nano-workforce 0.200.8 → 0.201.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +35 -0
  3. package/app/deliveryGraph.test.ts +10 -0
  4. package/app/deliveryGraphCompiler.test.ts +354 -10
  5. package/app/deliveryGraphCompiler.ts +296 -40
  6. package/app/deliveryRunner.ts +14 -5
  7. package/app/implementEscalationReason.test.ts +216 -0
  8. package/app/implementEscalationReason.ts +213 -0
  9. package/app/implementReconcile.test.ts +168 -11
  10. package/app/implementReconcile.ts +112 -18
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/roundChurn.test.ts +388 -0
  13. package/app/roundChurn.ts +265 -0
  14. package/app/roundProgress.test.ts +224 -1
  15. package/app/service.test.ts +7 -0
  16. package/app/service.ts +12 -0
  17. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  18. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  19. package/docs/agent-guide.md +10 -7
  20. package/e2e/delivery-graph.e2e.ts +570 -1
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/package.json +1 -1
  24. package/resources/processes/implement-cell.bpmn +42 -17
  25. package/resources/processes/plan-fanout.bpmn +236 -211
  26. package/resources/prompts/feature.md +16 -1
  27. package/workers/progress-check/worker.ts +98 -1
  28. package/workers/readiness-probe/worker.test.ts +72 -1
  29. package/workers/readiness-probe/worker.ts +39 -0
  30. package/workers/reconcile-implement/worker.test.ts +31 -3
  31. package/workers/reconcile-implement/worker.ts +5 -0
  32. package/workers/record-feature-escalation/worker.test.ts +58 -3
  33. package/workers/record-feature-escalation/worker.ts +24 -10
@@ -0,0 +1,388 @@
1
+ // Non-converging churn detector — unit tests for the canonical detector (app/roundChurn.ts) used by
2
+ // the pr.progress-check worker to escalate a review loop that keeps "progressing" (head advances
3
+ // every round) yet never reaches a fixed point because the findings keep landing in the same file
4
+ // (issue #870).
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
7
+ import { CHURN_WINDOW, type ChurnRound, churnQuestion, detectChurn, extractFiles } from "./roundChurn.ts";
8
+
9
+ // ── extractFiles ─────────────────────────────────────────────────────────────
10
+
11
+ test("extractFiles: mines backticked and bare repo-relative paths from a summary", () => {
12
+ const files = extractFiles("Fixed `hooks/post/720-pure-zod-schemas.ts` and src/analyze.ts for the IIFE case.");
13
+ assert(files.has("hooks/post/720-pure-zod-schemas.ts"));
14
+ assert(files.has("src/analyze.ts"));
15
+ assertEquals(files.size, 2);
16
+ });
17
+
18
+ test("extractFiles: strips trailing punctuation and closing brackets", () => {
19
+ for (const s of ["see hooks/post/720.ts.", "see (hooks/post/720.ts)", "see hooks/post/720.ts,", "`hooks/post/720.ts`"]) {
20
+ assert(extractFiles(s).has("hooks/post/720.ts"), `failed on ${JSON.stringify(s)}`);
21
+ }
22
+ });
23
+
24
+ test("extractFiles: mines root-level files (no directory segment) so same-root-file churn escalates (#870)", () => {
25
+ // Root repository files are a legitimate churn surface: a loop repeatedly editing `package.json` or
26
+ // `README.md` must escalate just like a nested path. The nested form requires a slash, so these were
27
+ // previously missed entirely.
28
+ for (const s of ["bumped `package.json`", "reworded README.md.", "edit tsconfig.json,", "touched nano.app.json"]) {
29
+ const files = extractFiles(s);
30
+ assertEquals(files.size, 1, `expected one root file in ${JSON.stringify(s)}, got ${[...files].join(",")}`);
31
+ }
32
+ assert(extractFiles("bumped `package.json`").has("package.json"));
33
+ assert(extractFiles("reworded README.md.").has("README.md"));
34
+ // A repeated-root-file loop escalates end to end.
35
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
36
+ roundNo: i + 1,
37
+ status: "addressed",
38
+ summary: `chore: bump dependency ${i} in \`package.json\``,
39
+ }));
40
+ const res = detectChurn(rounds);
41
+ assertEquals(res.churning, true);
42
+ assertEquals(res.file, "package.json");
43
+ });
44
+
45
+ test("extractFiles: prose dots are NOT mined as root files (version strings, abbreviations)", () => {
46
+ // The bare-filename form must stay conservative: version numbers and sentence abbreviations whose
47
+ // "extension" is numeric or a single letter must not be mined as fake root files.
48
+ for (const s of ["bumped to 4.8 today", "e.g. the loop", "i.e. a fixed point", "the U.S. team", "ran it 3.0 times"]) {
49
+ assertEquals(extractFiles(s).size, 0, `prose should yield no file in ${JSON.stringify(s)}`);
50
+ }
51
+ });
52
+
53
+ test("extractFiles: dotted API symbols and domains are NOT mined as root files (#870 review)", () => {
54
+ // A permissive extension mines dotted identifiers/domains as fake root files: `z.object`'s "extension"
55
+ // is `object`, `example.com`'s is `com`. Four rounds that each mention the same `z.object` schema
56
+ // while fixing DIFFERENT real files would otherwise intersect on `z.object` and falsely escalate.
57
+ // The extension allowlist rejects every one of these (their trailing word is not a file extension).
58
+ for (const s of [
59
+ "validated z.object while changing the schema",
60
+ "fixed z.string and z.number",
61
+ "used io.nanobpm.agentResult in the worker",
62
+ "called object.keys on the result",
63
+ "see example.com or Deno.land for docs",
64
+ ]) {
65
+ assertEquals(extractFiles(s).size, 0, `dotted symbol/domain should yield no file in ${JSON.stringify(s)}`);
66
+ }
67
+ // The real file alongside a dotted symbol is still mined; only the symbol is dropped.
68
+ const mixed = extractFiles("validated z.object while changing src/a.ts");
69
+ assert(mixed.has("src/a.ts"), "the real nested file is still mined");
70
+ assertEquals(mixed.has("z.object"), false, "the dotted API symbol is not mined");
71
+ });
72
+
73
+ test("extractFiles: the allowlisted extension must be FINAL — a trailing dotted segment is not a file (#870 review)", () => {
74
+ // Class: `(?=\b|$)` also succeeds BEFORE another dot, so an API chain whose head happens to look
75
+ // like `name.<ext>.method` (`schema.ts.parse`, `config.json.parse`, `data.yaml.load`) was mined as
76
+ // the truncated `name.<ext>`. Four rounds citing the same chain while fixing different real files
77
+ // would then intersect on the fake file and falsely escalate. The extension must be the LAST dotted
78
+ // component of the token.
79
+ for (const s of [
80
+ "called schema.ts.parse on the payload",
81
+ "ran config.json.parse then merged",
82
+ "invoked data.yaml.load in the loader",
83
+ "used app.css.modules helper",
84
+ ]) {
85
+ assertEquals(extractFiles(s).size, 0, `trailing-segment chain should yield no file in ${JSON.stringify(s)}`);
86
+ }
87
+ // But a genuine multi-dot root file whose allowlisted extension IS final still mines whole.
88
+ assert(extractFiles("touched foo.test.ts").has("foo.test.ts"));
89
+ assert(extractFiles("edited nano.app.json").has("nano.app.json"));
90
+ assert(extractFiles("regenerated lib.d.ts").has("lib.d.ts"));
91
+ });
92
+
93
+ test("extractFiles: a SLASH-qualified dotted symbol is NOT mined as a nested file (#870 review)", () => {
94
+ // Class: the nested matcher required only `name.<anything>` as the final segment, so a slash-qualified
95
+ // API chain (`src/schema.ts.parse`, `lib/config.json.parse`) was mined WHOLE as a fake repo file —
96
+ // the sibling of the root-path false positive above, which the root allowlist already closes. Four
97
+ // rounds citing the same qualified symbol while fixing different real files would intersect on it and
98
+ // falsely escalate. The nested final component must now also be an allowlisted, FINAL extension.
99
+ for (const s of [
100
+ "called src/schema.ts.parse on the payload",
101
+ "ran lib/config.json.parse then merged",
102
+ "invoked data/opts.yaml.load in the loader",
103
+ "used ui/app.css.modules helper",
104
+ ]) {
105
+ assertEquals(extractFiles(s).size, 0, `nested trailing-segment chain should yield no file in ${JSON.stringify(s)}`);
106
+ }
107
+ // But genuine nested files — including multi-dot basenames whose allowlisted extension IS final —
108
+ // still mine whole.
109
+ assert(extractFiles("fixed src/schema.ts").has("src/schema.ts"));
110
+ assert(extractFiles("touched dir/foo.test.ts").has("dir/foo.test.ts"));
111
+ assert(extractFiles("edited pkg/nano.app.json").has("pkg/nano.app.json"));
112
+ assert(extractFiles("regenerated types/lib.d.ts").has("types/lib.d.ts"));
113
+ });
114
+
115
+ test("extractFiles: a repeated SLASH-qualified dotted-symbol loop does NOT escalate as churn (#870 review)", () => {
116
+ // Acceptance counter-case for the nested class: four rounds that all cite the SAME slash-qualified
117
+ // API symbol (src/schema.ts.parse) while fixing DIFFERENT real files must NOT be churn — the shared
118
+ // token is a qualified symbol, not a contested file.
119
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
120
+ roundNo: i + 1,
121
+ status: "addressed",
122
+ summary: `re-checked src/schema.ts.parse again; fixed src/f${i}.ts this round`,
123
+ }));
124
+ assertEquals(detectChurn(rounds).churning, false);
125
+ });
126
+
127
+ test("extractFiles: mines supported dotfiles and extensionless root files (#870 review)", () => {
128
+ // Class: the allowlist/comment name `.gitignore`, `.env`, `Dockerfile`, `Makefile` as churn
129
+ // surfaces, but the `name.ext` matcher can never match a leading-dot dotfile or an extensionless
130
+ // basename, so genuine churn on those surfaces was silently missed. A dedicated conservative
131
+ // basename matcher closes that gap.
132
+ const cases: Array<[string, string]> = [
133
+ ["reworked .gitignore again", ".gitignore"],
134
+ ["tweaked the .env file", ".env"],
135
+ ["adjusted .gitattributes", ".gitattributes"],
136
+ ["fixed .editorconfig rules", ".editorconfig"],
137
+ ["rebuilt the Dockerfile layer", "Dockerfile"],
138
+ ["edited the Makefile target", "Makefile"],
139
+ ];
140
+ for (const [summary, want] of cases) {
141
+ assert(extractFiles(summary).has(want), `expected ${want} mined from ${JSON.stringify(summary)}`);
142
+ }
143
+ // Prose that merely contains the word (no leading dot / wrong form) is NOT mined.
144
+ for (const s of ["the environment was fine", "docker build ran", "make the change"]) {
145
+ assertEquals(extractFiles(s).size, 0, `prose should yield no file in ${JSON.stringify(s)}`);
146
+ }
147
+ // A repeated-dotfile loop escalates end to end.
148
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
149
+ roundNo: i + 1,
150
+ status: "addressed",
151
+ summary: `round ${i}: re-ignored another build artifact in .gitignore`,
152
+ }));
153
+ const res = detectChurn(rounds);
154
+ assertEquals(res.churning, true);
155
+ assertEquals(res.file, ".gitignore");
156
+ });
157
+
158
+ test("extractFiles: a repeated dotted-symbol loop does NOT escalate as churn (#870 review)", () => {
159
+ // The acceptance counter-case: four rounds that all mention the SAME dotted API symbol (z.object)
160
+ // while fixing DIFFERENT real files must NOT be declared churn — the shared token is prose, not a
161
+ // contested file. With the symbol correctly un-mined, the rounds have no common file and the loop
162
+ // continues.
163
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
164
+ roundNo: i + 1,
165
+ status: "addressed",
166
+ summary: `validated z.object again; fixed src/f${i}.ts this round`,
167
+ }));
168
+ assertEquals(detectChurn(rounds).churning, false);
169
+ });
170
+
171
+ test("extractFiles: ignores bare words and paths without a slash or extension", () => {
172
+ const files = extractFiles("addressed the zod concern and fail-closed on undefined (no file here) README");
173
+ assertEquals(files.size, 0);
174
+ });
175
+
176
+ test("extractFiles: a blank/non-string summary yields an empty set", () => {
177
+ for (const s of [null, undefined, "", " "]) assertEquals(extractFiles(s).size, 0);
178
+ });
179
+
180
+ test("extractFiles: strips URL spans so a citation link's path is not mined as a repo file", () => {
181
+ // A scheme URL whose path mirrors a repo path must NOT be reported — otherwise a summary that cites
182
+ // the same link every round while fixing different real files false-escalates as churn (#870).
183
+ const files = extractFiles(
184
+ "Per https://github.com/o/r/blob/main/docs/guide.md I fixed src/analyze.ts for the IIFE case.",
185
+ );
186
+ assertEquals(files.has("github.com/o/r/blob/main/docs/guide.md"), false);
187
+ assertEquals(files.has("docs/guide.md"), false);
188
+ assert(files.has("src/analyze.ts"));
189
+ // The URL is the ONLY shared token across rounds that otherwise fix different files — so once it is
190
+ // stripped, those rounds have no common file and detectChurn must NOT escalate.
191
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
192
+ roundNo: i + 1,
193
+ status: "addressed",
194
+ summary: `See https://github.com/o/r/blob/main/docs/guide.md — fixed src/f${i}.ts this round.`,
195
+ }));
196
+ assertEquals(detectChurn(rounds).churning, false);
197
+ });
198
+
199
+ test("extractFiles: rejects scheme-less citation links so their path is not mined as a repo file", () => {
200
+ // A citation link WITHOUT a scheme (`github.com/o/r/blob/main/docs/guide.md`) survives the
201
+ // scheme-required URL strip, yet its host+path looks exactly like a nested repo file — so it must be
202
+ // rejected too, or four summaries citing the same bare link while fixing different files
203
+ // false-escalate as churn (#870, Copilot "Previously missed" review of #875). Sweep the whole class:
204
+ // bare-host, `www.`, and multi-label-host forms.
205
+ const schemeless = extractFiles(
206
+ "Per github.com/o/r/blob/main/docs/guide.md I fixed src/analyze.ts for the IIFE case.",
207
+ );
208
+ assertEquals(schemeless.has("github.com/o/r/blob/main/docs/guide.md"), false);
209
+ assertEquals(schemeless.has("docs/guide.md"), false);
210
+ assert(schemeless.has("src/analyze.ts"));
211
+ // `www.`-prefixed and deep-subdomain hosts are the same class.
212
+ assertEquals(extractFiles("see www.example.com/a/b/c.md").has("www.example.com/a/b/c.md"), false);
213
+ assertEquals(
214
+ extractFiles("raw.githubusercontent.com/o/r/main/pkg/x.ts here").has(
215
+ "raw.githubusercontent.com/o/r/main/pkg/x.ts",
216
+ ),
217
+ false,
218
+ );
219
+ // A genuine nested repo file (no dotted host segment) must still be mined — the reject is surgical.
220
+ assert(extractFiles("fixed docs/guide.md this round").has("docs/guide.md"));
221
+ assert(extractFiles("fixed src/foo.ts this round").has("src/foo.ts"));
222
+ // A leading-dot dotfile directory is NOT a host (no label before the dot), so it is still mined.
223
+ assert(extractFiles("edited .github/workflows/ci.yml").has(".github/workflows/ci.yml"));
224
+ // The URL is the ONLY shared token across rounds that otherwise fix different files — once it is
225
+ // rejected, detectChurn must NOT escalate.
226
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
227
+ roundNo: i + 1,
228
+ status: "addressed",
229
+ summary: `See github.com/o/r/blob/main/docs/guide.md — fixed src/g${i}.ts this round.`,
230
+ }));
231
+ assertEquals(detectChurn(rounds).churning, false);
232
+ });
233
+
234
+ test("extractFiles: the scan is structurally capped at MAX_SCAN (no wall-clock dependence)", () => {
235
+ // Structural cap check — deterministic regardless of machine speed (AGENTS.md:21-29 forbids a
236
+ // nondeterministic wall-clock assertion). A valid path placed JUST PAST the 20,000-char MAX_SCAN
237
+ // boundary must be dropped; a path within it is still mined. Removing the `slice(0, MAX_SCAN)` cap
238
+ // would make the beyond-cap path extractable and fail this test on EVERY run.
239
+ const MAX_SCAN = 20000;
240
+ const filler = "x ".repeat(MAX_SCAN); // 40,000 chars — safely past the cap
241
+ const beyond = `${filler} src/beyond-cap.ts`;
242
+ assert(beyond.length > MAX_SCAN, "fixture must exceed the cap");
243
+ assertEquals(extractFiles(beyond).has("src/beyond-cap.ts"), false, "a path past MAX_SCAN is not mined");
244
+ assert(extractFiles(`src/within-cap.ts ${filler}`).has("src/within-cap.ts"), "a path within the cap IS mined");
245
+ });
246
+
247
+ test("extractFiles: a long unterminated path run yields no match (bounded, no quadratic stall)", () => {
248
+ // `"a/".repeat(n)` with no closing `name.ext` is the nested form's quadratic worst case; the length
249
+ // bound keeps it linear and yields no match. Asserted structurally (no timing).
250
+ const pathological = `${"a/".repeat(50_000)}b`;
251
+ assertEquals(extractFiles(pathological).size, 0);
252
+ });
253
+
254
+ // ── detectChurn ──────────────────────────────────────────────────────────────
255
+
256
+ /** A trailing run of `addressed` rounds whose summaries all name `file`. */
257
+ function sameFileRounds(count: number, file: string, startRound = 1): ChurnRound[] {
258
+ return Array.from({ length: count }, (_, i) => ({
259
+ roundNo: startRound + i,
260
+ status: "addressed",
261
+ summary: `chore: fail closed on bypass form ${i} in \`${file}\``,
262
+ }));
263
+ }
264
+
265
+ test("detectChurn: THE REPRO — repeated addressed rounds hitting the same file escalates (#870)", () => {
266
+ // Process 189847's shape: every round is `addressed`, pushes a commit, and the finding lands in the
267
+ // same analyzer file. The head advances each round so the no-progress guard never fires — churn is
268
+ // what catches it.
269
+ const file = "hooks/post/720-pure-zod-schemas.ts";
270
+ const res = detectChurn(sameFileRounds(CHURN_WINDOW, file));
271
+ assertEquals(res.churning, true);
272
+ assertEquals(res.file, file);
273
+ assertEquals(res.rounds, CHURN_WINDOW);
274
+ assert(res.question, "a churn verdict carries an actionable scope question");
275
+ });
276
+
277
+ test("detectChurn: does NOT escalate below the window", () => {
278
+ const res = detectChurn(sameFileRounds(CHURN_WINDOW - 1, "hooks/post/720.ts"));
279
+ assertEquals(res.churning, false);
280
+ });
281
+
282
+ test("detectChurn: normal convergence — findings in DIFFERENT files each round — is not escalated", () => {
283
+ // Each round addresses a finding in a different area; no single file spans the window, so the loop
284
+ // continues (the counter-case the issue's acceptance calls out).
285
+ const rounds: ChurnRound[] = [
286
+ { roundNo: 1, status: "addressed", summary: "fix src/a.ts null check" },
287
+ { roundNo: 2, status: "addressed", summary: "fix src/b.ts off-by-one" },
288
+ { roundNo: 3, status: "addressed", summary: "fix src/c.ts typo" },
289
+ { roundNo: 4, status: "addressed", summary: "fix src/d.ts import" },
290
+ { roundNo: 5, status: "addressed", summary: "docs/readme.md wording" },
291
+ ];
292
+ assertEquals(detectChurn(rounds).churning, false);
293
+ });
294
+
295
+ test("detectChurn: a non-addressed round (a prior human escalation) BREAKS the consecutive run", () => {
296
+ // A `needs_input` escalation in the middle restarts the churn clock: only one addressed round
297
+ // trails it, far short of the window.
298
+ const file = "hooks/post/720.ts";
299
+ const rounds: ChurnRound[] = [
300
+ ...sameFileRounds(3, file, 1),
301
+ { roundNo: 4, status: "needs_input", summary: `human asked about \`${file}\`` },
302
+ { roundNo: 5, status: "addressed", summary: `chore: fail closed again in \`${file}\`` },
303
+ ];
304
+ assertEquals(detectChurn(rounds).churning, false);
305
+ });
306
+
307
+ test("detectChurn: a window round with no extractable file breaks the same-file signal", () => {
308
+ const file = "hooks/post/720.ts";
309
+ const rounds = sameFileRounds(CHURN_WINDOW, file);
310
+ // Blank out one window round's file reference.
311
+ const mutated = [...rounds];
312
+ mutated[mutated.length - 2] = { roundNo: mutated[mutated.length - 2]!.roundNo, status: "addressed", summary: "addressed the finding" };
313
+ assertEquals(detectChurn(mutated).churning, false);
314
+ });
315
+
316
+ test("detectChurn: only the MOST RECENT window matters — an earlier same-file streak that moved on continues", () => {
317
+ // Rounds 1-4 all hit file A, but the loop then moved to file B for the latest rounds and is
318
+ // converging there — the trailing window is mixed, so no churn.
319
+ const rounds: ChurnRound[] = [
320
+ ...sameFileRounds(3, "a.ts/x.ts", 1),
321
+ { roundNo: 4, status: "addressed", summary: "fix dir/b.ts" },
322
+ { roundNo: 5, status: "addressed", summary: "fix dir/c.ts" },
323
+ { roundNo: 6, status: "addressed", summary: "fix dir/d.ts" },
324
+ ];
325
+ assertEquals(detectChurn(rounds).churning, false);
326
+ });
327
+
328
+ test("detectChurn: unsorted input is ordered by round number before windowing", () => {
329
+ const file = "dir/file.ts";
330
+ const rounds = sameFileRounds(CHURN_WINDOW, file);
331
+ const shuffled = [rounds[2]!, rounds[0]!, rounds[3]!, rounds[1]!];
332
+ assertEquals(detectChurn(shuffled).churning, true);
333
+ });
334
+
335
+ test("detectChurn: picks a deterministic (lexicographically smallest) file when several are common", () => {
336
+ const rounds: ChurnRound[] = Array.from({ length: CHURN_WINDOW }, (_, i) => ({
337
+ roundNo: i + 1,
338
+ status: "addressed",
339
+ summary: "touched `dir/zeta.ts` and `dir/alpha.ts`",
340
+ }));
341
+ const res = detectChurn(rounds);
342
+ assertEquals(res.churning, true);
343
+ assertEquals(res.file, "dir/alpha.ts", "the stable choice is the smallest common path");
344
+ });
345
+
346
+ test("detectChurn: THE RESET REPRO — a run-scoped churn-reset watermark restarts the clock after a human answers (#870)", () => {
347
+ // A churn escalation at round N writes NO `blocked` round (persist-escalation-noprogress sets
348
+ // recordRound=false) and the human-answer resume re-enters the SAME numeric round N, so WITHOUT the
349
+ // watermark the resumed round's history is the identical same-file window — and churn would re-fire
350
+ // forever. Passing `resetAfterRound=N` drops every round at or before N, so the clock restarts.
351
+ const file = "hooks/post/720-pure-zod-schemas.ts";
352
+ const window = sameFileRounds(CHURN_WINDOW, file);
353
+ // Red without the fix: this is exactly the window that escalated at round CHURN_WINDOW.
354
+ assertEquals(detectChurn(window).churning, true, "baseline: the window escalates");
355
+ // After the human answered the escalation raised at round CHURN_WINDOW, the resumed round re-enters
356
+ // round CHURN_WINDOW and re-runs detection with the watermark set — no new rounds past it yet.
357
+ assertEquals(
358
+ detectChurn(window, CHURN_WINDOW, CHURN_WINDOW).churning,
359
+ false,
360
+ "the human's scope decision restarts the churn clock — the answered window never re-escalates",
361
+ );
362
+ });
363
+
364
+ test("detectChurn: churn can fire AGAIN only after a fresh window accumulates past the reset watermark (#870)", () => {
365
+ // Rounds 1-4 escalated and were answered (watermark=4). Four MORE same-file rounds (5-8) then
366
+ // accumulate — a genuinely still-contested surface the human's decision did not resolve — so churn
367
+ // re-escalates on the post-decision window, not on the already-answered one.
368
+ const file = "hooks/post/720.ts";
369
+ const rounds = sameFileRounds(CHURN_WINDOW * 2, file); // rounds 1..8
370
+ assertEquals(detectChurn(rounds, CHURN_WINDOW, CHURN_WINDOW).churning, true, "a fresh post-reset window re-escalates");
371
+ // One short of a fresh window (only rounds 5-7 past the watermark) does NOT re-escalate yet.
372
+ assertEquals(
373
+ detectChurn(rounds.slice(0, CHURN_WINDOW + CHURN_WINDOW - 1), CHURN_WINDOW, CHURN_WINDOW).churning,
374
+ false,
375
+ "below a fresh window past the watermark, the loop continues",
376
+ );
377
+ });
378
+
379
+ // ── churnQuestion ────────────────────────────────────────────────────────────
380
+
381
+ test("churnQuestion: frames an actionable SCOPE decision naming the contested file", () => {
382
+ const q = churnQuestion("hooks/post/720.ts", 4);
383
+ assertStringIncludes(q, "hooks/post/720.ts");
384
+ assertStringIncludes(q, "SCOPE"); // framed as a scope decision
385
+ assertStringIncludes(q, "narrow or simplify");
386
+ assertStringIncludes(q, "follow-up issue");
387
+ assertStringIncludes(q, "non-blocking");
388
+ });