omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
package/src/diff-flags.ts CHANGED
@@ -2,19 +2,21 @@
2
2
  * The settlement audit: checking a worker's own account of its work against the
3
3
  * pull request it actually pushed.
4
4
  *
5
- * Two lines of the worker report template were taken entirely on faith
6
- * (`briefs/worker.md:161-164`):
5
+ * The report's `changed:` line — the one fact a reviewer needs to use a
6
+ * settlement report as a summary — is no longer hand-written at all. It is
7
+ * derived at settlement from the pull request's own file list: {@link
8
+ * deriveChangedLine} composes it and {@link withDerivedChangedLine} rewrites it
9
+ * into the report, so the disclosure is the diff's, never a model's memory of
10
+ * work that happened dozens of turns earlier (#488). Only the *narrative* — what
11
+ * changed and why — remains the worker's to write.
7
12
  *
8
- * ```
9
- * gates: <exact commands run and their results>
10
- * changed: <files touched, one line>
11
- * ```
12
- *
13
- * #85 stopped believing the line above them — `state: pushed-green` is now
14
- * checked against GitHub by `verifyPushedGreenClaim` rather than believed. This
15
- * module does the same job for `changed:`, and for the epic's ranked item 7,
16
- * "don't weaken tests you didn't write, checked by diff review", where until now
17
- * the reviewer was the worker reviewing itself (#128).
13
+ * What this module still checks by reading a report is content, not disclosure.
14
+ * `state: pushed-green` stopped being believed in #85 — a claim is now checked
15
+ * against GitHub by `verifyPushedGreenClaim` rather than taken at face value —
16
+ * and the epic's ranked item 7, "don't weaken tests you didn't write, checked
17
+ * by diff review", is the other half: a diff-level check of the test files a PR
18
+ * touched, where until now the reviewer was the worker reviewing itself
19
+ * (#128).
18
20
  *
19
21
  * Everything here is **advisory**. A flag never changes a run's state, never
20
22
  * blocks a merge and never fails a run. The judgement a flag invites — "was
@@ -28,11 +30,11 @@
28
30
  * being checked. So **every heuristic below resolves ambiguity towards
29
31
  * silence**, and each one names the blind spot it accepts to stay quiet.
30
32
  *
31
- * Pure by construction: the analyser takes a parsed diff, the report text and
32
- * the dispatching issue's text, and returns flags. Fetching the diff is the
33
- * tracker's job (`Tracker.prDiff`) — the same split `prStateFrom`/`prState`
34
- * already use — so every rule here is pinned by a recorded diff rather than by
35
- * a live repository.
33
+ * Pure by construction: the analyser takes a parsed diff and the dispatching
34
+ * issue's text, and returns flags. Fetching the diff is the tracker's job
35
+ * (`Tracker.prDiff`) — the same split `prStateFrom`/`prState` already use — so
36
+ * every rule here is pinned by a recorded diff rather than by a live
37
+ * repository.
36
38
  */
37
39
 
38
40
  import type { PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
@@ -231,51 +233,51 @@ function isTestPath(path: string): boolean {
231
233
  return TEST_FILE_NAME.test(path) || TEST_DIR.test(path);
232
234
  }
233
235
 
236
+ // --------------------------------------------------------- derived disclosure
237
+
234
238
  /**
235
- * Lockfiles are derived from a manifest the worker did disclose, and no edit
236
- * hides in one. Reconciling them produces an omission flag on most dependency
237
- * changes — the single largest source of noise this check could have, bought
238
- * for no signal at all.
239
+ * The `changed:` line is composed from the PR's own diff — every file it
240
+ * touched, lockfiles included. Nothing is exempted, because nothing is being
241
+ * held against the worker any more: the list is the diff's, so completeness
242
+ * costs nothing.
239
243
  */
240
- const DERIVED_FILE: Record<string, true> = {
241
- "bun.lock": true,
242
- "bun.lockb": true,
243
- "package-lock.json": true,
244
- "pnpm-lock.yaml": true,
245
- "yarn.lock": true,
246
- "Cargo.lock": true,
247
- "poetry.lock": true,
248
- "uv.lock": true,
249
- "go.sum": true,
250
- "Gemfile.lock": true,
251
- "composer.lock": true,
252
- };
253
-
254
- // -------------------------------------------------------- `changed:` reconcile
255
244
 
245
+ /** The `changed:` line of the settlement report, as the rewrite targets it. */
256
246
  const CHANGED_LINE = /^[ \t>]*changed:[ \t]*(.*)$/im;
257
247
 
258
248
  /**
259
- * A token from `changed:` that is worth holding the PR to. The line is prose
260
- * written by a model — `omp/src/foo.ts, omp/src/foo.test.ts (new)` is a normal
261
- * shape — so anything without a path separator is commentary unless it ends in
262
- * an extension that starts with a letter and is at least two characters. That
263
- * last clause is not fussiness: without it `e.g`, `i.e` and `0.3.18` all parse
264
- * as filenames, and each one becomes a "claimed a file you never touched" flag
265
- * on a report that did nothing wrong.
249
+ * Any other report field heading: `word:` opening a line, possibly indented or
250
+ * blockquote-prefixed (`> next:`). A `changed:` disclosure never continues
251
+ * past one — the rewrite below ends its old-list scan at a following field,
252
+ * so `next:`/`gates:` values stay attached to the report (#435).
253
+ */
254
+ const FIELD_LINE = /^[ \t>]*[A-Za-z_][A-Za-z0-9_-]*\s*:/;
255
+
256
+ /**
257
+ * A token that reads as a path — the shape a hand-written file list is made
258
+ * of. The rewrite scans a wrapped old disclosure line by line and stops where
259
+ * the lines stop looking like paths: `omp/src/foo.ts` is a path, `(new)` and
260
+ * `e.g` are prose. The last clause is not fussiness: without it `e.g`, `i.e`
261
+ * and `0.3.18` would all count as paths and a wrapped list's boundary would
262
+ * land one line too late.
266
263
  */
267
264
  const CLAIMED_PATH = /^(?:[\w.@~+-]+\/)+[\w.@~*+-]*$|^[\w.@~+-]*\.[A-Za-z][A-Za-z0-9]{1,7}$/;
268
265
 
269
- /** A Python dotted module: `services.rule_purge`, `omp.routing`. Not a path —
270
- * segments joined by `.` — but a legitimate way for a report to name the file
271
- * it touched without disclosing the directory it lives in (#224).
272
- *
273
- * Every segment is at least two characters so prose abbreviations (`e.g`,
274
- * `i.e.`, `a.k.a`) stay commentary: they are exactly the prose the path
275
- * heuristic exists to reject, and each one that slipped through would become
276
- * an "you claimed a file you never touched" flag on an honest report. */
266
+ /** A Python module name: `services.rule_purge`, `omp.routing` — a legitimate
267
+ * way a report names a file by import path rather than location (#224), and
268
+ * still a path-shaped token for the old-list scan. Every segment is at least
269
+ * two characters so prose abbreviations (`e.g`, `i.e.`, `a.k.a`) stay
270
+ * commentary. */
277
271
  const DOTTED_MODULE = /^[A-Za-z_]\w+(?:\.[A-Za-z_]\w+)+$/;
278
272
 
273
+ /** A numeric diff statistic — `+1/-1`, `+12/-3`, `1/-1`, a bare `+1`. It is
274
+ * slash-shaped enough to look like a path — every segment is a signed
275
+ * integer, which CLAIMED_PATH's character classes accept — but it is a count,
276
+ * not a path: the wrapped-list scan must cut at it, exactly where it appears
277
+ * in real reports (`omp/package.json (…, +1/-1; …)`, the #388 report,
278
+ * verbatim). */
279
+ const DIFF_STAT = /^[+-]?\d+(?:\/[+-]?\d+)?$/;
280
+
279
281
  /** One-level brace expansion: `a/{b,c}.py` → [`a/b.py`, `a/c.py`]. Braces are
280
282
  * how a report compresses adjacent files into one token; each comma-separated
281
283
  * alternative is substituted into the skeleton. Nesting is deliberately
@@ -300,72 +302,71 @@ function expandExtensionAlternation(token: string): string[] {
300
302
  return [first, ...rest.split("/.").filter(Boolean)].map((extension) => `${base}.${extension}`);
301
303
  }
302
304
 
303
- /** Every path-shaped token on the report's `changed:` line. An absent line and
304
- * a line naming nothing are the same answer: nothing was disclosed. */
305
- function parseClaimedPaths(report: string, extensionAlternatives?: Set<string>): string[] {
306
- const line = CHANGED_LINE.exec(report)?.[1] ?? "";
307
- const seen = new Set<string>();
308
- // Split on whitespace and semicolons, and on commas *outside* a brace group:
309
- // the comma in `a/{b,c}.py` is an alternative separator, not a token
310
- // separator, so a brace-compressed token must survive whole (#224).
311
- for (const raw of line.split(/[\s;]+|,(?![^{}]*\})/)) {
312
- const token = raw.replace(/^[`'"([*-]+/, "").replace(/[`'")\].,:;]+$/, "");
313
- if (token === "" || token === "none") continue;
314
- const normalised = token.replace(/^\.?\//, "");
315
- // Expansion happens here so every downstream consumer — both `covers`
316
- // directions — sees plain paths: a brace token dies earlier at the
317
- // CLAIMED_PATH filter if it is never opened up (#224).
318
- for (const expanded of expandBraces(normalised)) {
319
- const alternatives = expandExtensionAlternation(expanded);
320
- for (const path of alternatives) {
321
- if (!CLAIMED_PATH.test(path) && !DOTTED_MODULE.test(path)) continue;
322
- seen.add(path);
323
- if (alternatives.length > 1) extensionAlternatives?.add(path);
324
- }
325
- }
326
- }
327
- return [...seen];
305
+ /** One cleaned token of a `changed:` line, as claim parsing sees it: leading
306
+ * quotes and brackets stripped, trailing punctuation stripped, empties, the
307
+ * word `none`, and numeric diff statistics dropped. */
308
+ function claimTokens(line: string): string[] {
309
+ return line
310
+ .split(/[\s;]+|,(?![^{}]*\})/)
311
+ .map((raw) => raw.replace(/^[`'"([*-]+/, "").replace(/[`'")\].,:;]+$/, ""))
312
+ .filter((token) => token !== "" && token !== "none" && !DIFF_STAT.test(token));
328
313
  }
329
314
 
330
- export function claimedPaths(report: string): string[] {
331
- return parseClaimedPaths(report);
315
+ /** Whether a cleaned token names at least one path once braces and extension
316
+ * alternatives are expanded — the same filter the disclosure rewrite applies
317
+ * to every continuation line it considers part of the old file list. */
318
+ function tokenNamesAPath(token: string): boolean {
319
+ const normalised = token.replace(/^\.?\//, "");
320
+ return expandBraces(normalised).some((expanded) =>
321
+ expandExtensionAlternation(expanded).some((path) => CLAIMED_PATH.test(path) || DOTTED_MODULE.test(path)),
322
+ );
323
+ }
324
+
325
+ /**
326
+ * The file list the PR's own diff reports, in diff order, comma-separated —
327
+ * the settlement report's `changed:` line. Derived rather than asked for, so a
328
+ * worker that never writes a file list still settles with a correct one, and a
329
+ * report that contradicts the diff is rewritten to say what the diff says
330
+ * (never the other way round).
331
+ */
332
+ export function deriveChangedLine(diff: PrDiff): string {
333
+ return diff.files.map((file) => file.path).join(", ");
332
334
  }
333
335
 
334
336
  /**
335
- * Whether one claim covers one path. Every rule here is deliberately generous,
336
- * because each one that fails produces an omission flag on an honest report:
337
+ * Rewrite the worker's hand-written `changed:` line with the derived one,
338
+ * keeping the rest of the report — the narrative — verbatim.
337
339
  *
338
- * - suffix match, so `foo.test.ts` covers `omp/src/foo.test.ts` and a worker
339
- * that wrote the full path is covered by a claim of either shape;
340
- * - directory match, so `omp/src` covers everything under it — a token whose
341
- * last segment has an extension is a filename and never a directory;
342
- * - `*` as a within-segment wildcard, because `omp/src/tracker/*` is how the
343
- * issues in this repo themselves name a file set.
340
+ * The old line and any wrapped continuation that still reads as a path list
341
+ * (#435) are removed wholesale and replaced by a single derived line. A report
342
+ * with no `changed:` line at all has one inserted before `next:`, so a settled
343
+ * report always carries the disclosure.
344
344
  */
345
- function covers(claim: string, path: string): boolean {
346
- // Callers pass `previousPath ?? ""` for the rename side. Without this guard a
347
- // directory-shaped claim like `omp/src/` "covers" the empty string, and every
348
- // unmatched claim silently disappears.
349
- if (path === "") return false;
350
- if (claim === path) return true;
351
- if (claim.includes("*")) {
352
- const pattern = new RegExp(
353
- `^${claim.replaceAll(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === "*" ? "[^/]*" : `\\${ch}`))}$`,
354
- );
355
- return pattern.test(path) || pattern.test(basename(path));
345
+ export function withDerivedChangedLine(report: string, changedLine: string): string {
346
+ const lines = report.split("\n");
347
+ const start = lines.findIndex((line) => CHANGED_LINE.test(line));
348
+ const disclosure = `changed: ${changedLine}`;
349
+ if (start === -1) {
350
+ const next = lines.findIndex((line) => /^[ \t>]*next:/.test(line));
351
+ lines.splice(next === -1 ? lines.length : next, 0, disclosure);
352
+ return lines.join("\n");
356
353
  }
357
- if (path.endsWith(`/${claim}`) || claim.endsWith(`/${path}`)) return true;
358
- // A Python dotted module names a file by import path, not by location:
359
- // `services.rule_purge` covers `backend/app/services/rule_purge.py`. Only
360
- // consulted after the path rules, because `foo.bar` is also a valid filename
361
- // and a literal match must win. Matching several changed files is fine — the
362
- // check wants evidence the worker knew, not a unique index (#224).
363
- if (DOTTED_MODULE.test(claim)) {
364
- const fragment = `${claim.replaceAll(".", "/")}.py`;
365
- if (path === fragment || path.endsWith(`/${fragment}`)) return true;
354
+ // The old disclosure is the `changed:` line plus whatever following lines
355
+ // still read as a wrapped path list: indented, non-blank, not a field heading,
356
+ // not prose. Everything past it belongs to the report and survives.
357
+ let end = start + 1;
358
+ while (end < lines.length) {
359
+ const line = lines[end] ?? "";
360
+ if (line.trim() === "") break;
361
+ if (!/^[ \t>]/.test(line)) break;
362
+ if (FIELD_LINE.test(line)) break;
363
+ const tokens = claimTokens(line);
364
+ if (tokens.length === 0 || !tokenNamesAPath(tokens[0] ?? "")) break;
365
+ end++;
366
366
  }
367
- const last = claim.slice(claim.lastIndexOf("/") + 1);
368
- return !last.includes(".") && path.startsWith(`${claim}/`);
367
+ const prefix = /^[ \t>]*/.exec(lines[start] ?? "")?.[0] ?? "";
368
+ lines.splice(start, end - start, `${prefix}${disclosure}`);
369
+ return lines.join("\n");
369
370
  }
370
371
 
371
372
  // ------------------------------------------------------ test-weakening lexicon
@@ -492,143 +493,41 @@ function evidence(text: string): string {
492
493
  // -------------------------------------------------------------------- analyser
493
494
 
494
495
  export interface SettlementAudit {
495
- /** The worker's final report, verbatim. */
496
- report: string;
497
- /** Earlier attempts' reports for the same issue, oldest first. Their
498
- * `changed:` disclosures are pooled as coverage so a file disclosed in a
499
- * prior attempt is never flagged as undisclosed in the final one, while the
500
- * reverse (unmatched-claim) direction reads only the current report — a file
501
- * claimed in attempt 1 and reverted by attempt 3 must not come back as a new
502
- * finding (#199). Absent means this is the only attempt, or older rows never
503
- * stored a report. */
504
- priorReports?: readonly string[];
505
496
  /** The dispatching issue's title and body — the attribution source. */
506
497
  issueText: string;
507
498
  diff: PrDiff;
508
499
  }
509
500
 
510
501
  /**
511
- * Every advisory finding about one settled pull request, in a stable order:
512
- * the `changed:` reconciliation first, then test weakening in diff order.
502
+ * Every advisory finding about one settled pull request, in a stable order
503
+ * (test weakening in diff order).
504
+ *
505
+ * The file-list disclosure is not audited here at all: it is derived from
506
+ * `diff` itself ({@link deriveChangedLine}), so the `undisclosed-file` /
507
+ * `unmatched-claim` reconciliation flags are structurally gone. What remains
508
+ * is the diff-content check a human is worst placed to do by hand.
513
509
  *
514
510
  * An empty array is the expected result for an honest run, and is load-bearing:
515
511
  * the settlement report says nothing at all when this returns nothing.
516
512
  */
517
513
  export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
518
514
  const flags: SettlementFlag[] = [];
519
- reconcileChanged(audit, flags);
520
515
  detectWeakening(audit, flags);
521
516
  return flags;
522
517
  }
523
518
 
524
- function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
525
- const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
526
- const extensionAlternatives = new Set<string>();
527
- const claims = parseClaimedPaths(audit.report, extensionAlternatives);
528
- const priorClaims = claimedPaths((audit.priorReports ?? []).join("\n"));
529
- // Coverage pools the current report with every prior attempt's disclosures:
530
- // a file the final report no longer names was disclosed while the work was
531
- // still in flight, so it must not be flagged undisclosed (#199).
532
- const coverage = [...new Set([...claims, ...priorClaims])];
533
-
534
- if (claims.length === 0 && priorClaims.length === 0) {
535
- // The degenerate case of the same check: with no usable `changed:` line
536
- // every file is undisclosed, and saying so once beats saying it per file.
537
- // Reporting it at all is what stops the check being defeated by writing
538
- // `changed: see the PR` — or by dropping the line entirely.
539
- if (touched.length > 0) {
540
- flags.push({
541
- kind: "changed-line-missing",
542
- file: "(report)",
543
- detail:
544
- "the report's `changed:` line is missing or names no paths, so none of the " +
545
- `${touched.length} file(s) the PR touched was disclosed`,
546
- });
547
- }
548
- return;
549
- }
550
-
551
- for (const file of touched) {
552
- if (coverage.some((claim) => covers(claim, file.path))) continue;
553
- if (file.previousPath !== undefined && coverage.some((claim) => covers(claim, file.previousPath ?? ""))) {
554
- continue;
555
- }
556
- flags.push({
557
- kind: "undisclosed-file",
558
- file: file.path,
559
- detail: `${file.status} by the PR but absent from the report's \`changed:\` line`,
560
- });
561
- }
562
-
563
- // The weaker direction, and it stays weak on purpose: a worker often names a
564
- // file it edited and then reverted, or one it renamed away from. Worth
565
- // surfacing because a `changed:` line that describes a different PR is the
566
- // signature of a report written from memory rather than from `git diff`.
567
- //
568
- // It iterates only the CURRENT report's claims (#199). A file claimed in
569
- // attempt 1 and reverted by attempt 3 was true then and false now; pooling
570
- // priors into this loop would turn every honest revert into a fresh finding.
571
- for (const claim of claims) {
572
- if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
573
- continue;
574
- }
575
- if (extensionAlternatives.has(claim)) {
576
- const stem = claim.slice(0, claim.lastIndexOf("."));
577
- const matchedAlternative = claims.some(
578
- (other) =>
579
- other !== claim &&
580
- extensionAlternatives.has(other) &&
581
- other.slice(0, other.lastIndexOf(".")) === stem &&
582
- audit.diff.files.some((f) => covers(other, f.path) || covers(other, f.previousPath ?? "")),
583
- );
584
- if (matchedAlternative) continue;
585
- }
586
- flags.push({
587
- kind: "unmatched-claim",
588
- file: claim,
589
- detail: "named by the report's `changed:` line but not touched by the PR",
590
- });
591
- }
592
-
593
- // A parser failure can otherwise accuse the report in both directions for
594
- // the same file. Collapse only that self-refuting pair; unrelated findings
595
- // remain intact.
596
- const unmatched = flags.filter((flag) => flag.kind === "unmatched-claim");
597
- const removed = new Set<SettlementFlag>();
598
- let exampleClaim = "";
599
- let examplePath = "";
600
- for (const undisclosed of flags.filter((flag) => flag.kind === "undisclosed-file")) {
601
- const name = basename(undisclosed.file);
602
- const extension = name.lastIndexOf(".");
603
- const stem = extension > 0 ? name.slice(0, extension) : name;
604
- if (stem.length < 3) continue;
605
- for (const claim of unmatched) {
606
- if (!claim.file.includes(stem)) continue;
607
- removed.add(undisclosed);
608
- removed.add(claim);
609
- if (exampleClaim === "") {
610
- exampleClaim = claim.file;
611
- examplePath = undisclosed.file;
612
- }
613
- }
614
- }
615
- if (removed.size === 0) return;
616
-
617
- const kept = flags.filter((flag) => !removed.has(flag));
618
- const removedClaims = unmatched.filter((flag) => removed.has(flag)).length;
619
- flags.splice(
620
- 0,
621
- flags.length,
622
- ...kept,
623
- {
624
- kind: "report-format-unparsed",
625
- file: "(report)",
626
- detail:
627
- `${removedClaims} claim(s) on the \`changed:\` line could not be parsed as paths yet name the same ` +
628
- `file(s) the PR touched (e.g. ${exampleClaim} vs ${examplePath}) — read the diff directly`,
629
- },
630
- );
631
- }
519
+ /**
520
+ * The one finding a settlement can carry without a diff at all. `changed-line-missing`
521
+ * used to mean "the worker's report disclosed nothing"; with the list derived
522
+ * from the PR's own diff it can only mean the tree could not be read — a real
523
+ * fault worth surfacing, never an empty list that reads as a clean bill.
524
+ */
525
+ export const UNREADABLE_TREE_FLAG: SettlementFlag = {
526
+ kind: "changed-line-missing",
527
+ file: "(report)",
528
+ detail:
529
+ "the settlement could not read the PR's diff, so no `changed:` file list could be derived",
530
+ };
632
531
 
633
532
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
634
533
  // A test file that left one path and arrived at another is a move, not a
@@ -788,30 +687,21 @@ const HEADING = "settlement audit";
788
687
  * that could not be read in full — then the absence of flags is not evidence of
789
688
  * anything, and saying so is the honest report.
790
689
  */
791
- /** Appended to the heading when a multi-attempt audit pooled prior disclosures,
792
- * so a reader is told the coverage is wider than this one report (#199). */
793
- function pooledHeading(attempts: number | undefined): string {
794
- return attempts !== undefined && attempts > 1
795
- ? ` — disclosures pooled across ${attempts} attempt(s)`
796
- : "";
797
- }
798
-
799
690
  export function formatSettlementFlags(
800
691
  flags: readonly SettlementFlag[],
801
- diff: { truncated: boolean; attempts?: number } = { truncated: false },
692
+ diff: { truncated: boolean } = { truncated: false },
802
693
  ): string[] {
803
694
  if (flags.length === 0) {
804
695
  return diff.truncated
805
696
  ? [
806
697
  `${HEADING}: no flags, but the PR diff was too large to read in full` +
807
- `${pooledHeading(diff.attempts)} — this is not a clean bill`,
698
+ " — this is not a clean bill",
808
699
  ]
809
700
  : [];
810
701
  }
811
702
  const lines = [
812
703
  `${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
813
- (diff.truncated ? ", and the PR diff was too large to read in full" : "") +
814
- pooledHeading(diff.attempts),
704
+ (diff.truncated ? ", and the PR diff was too large to read in full" : ""),
815
705
  ];
816
706
  for (const flag of flags.slice(0, RENDERED_FLAGS)) {
817
707
  if (flag.kind === "pr-adopted") {