omp-conductor 0.19.6 → 0.20.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 (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The fleet's accumulated per-repo knowledge overlay (Phase 3).
3
+ *
4
+ * A worker's turns are mostly spent finding code, not writing it: where the
5
+ * feature is wired, which command actually proves it, which green-looking test
6
+ * asserts nothing. The fleet learns those facts constantly and then throws them
7
+ * away — a to-spec verdict's `entryPoints`, `proofCommands` and
8
+ * `likelySilentFake` die with the grooming row when the issue closes, and a
9
+ * worker's own discovery dies with its transcript. Every later run in the same
10
+ * repo pays for the same search again.
11
+ *
12
+ * So: one append-only Markdown file per repo under the conductor state root,
13
+ * rendered into the brief of everything the fleet launches against that repo.
14
+ * Small on purpose — {@link KNOWLEDGE_MAX_BYTES} is a hard ceiling, because this
15
+ * competes for the same context window as the issue, the diff and the findings.
16
+ * When it fills, the OLDEST entries are dropped: a repo fact learned twenty
17
+ * issues ago is either still true and probably already distilled, or stale and
18
+ * actively misleading. Nothing is ever truncated mid-entry — half a sentence of
19
+ * accumulated knowledge is worse than one fewer fact, because a worker cannot
20
+ * tell a clipped warning from a complete one.
21
+ *
22
+ * Writes are best-effort. This is an optimisation, and a run must never fail
23
+ * because the overlay could not be written: the callers are settlement paths.
24
+ */
25
+
26
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { stateDir } from "./config.ts";
29
+
30
+ const KNOWLEDGE_DIR = "knowledge";
31
+
32
+ /** What one entry line starts with. Markdown bullet, so the file reads as prose. */
33
+ const ENTRY_PREFIX = "- ";
34
+
35
+ /**
36
+ * The heading the overlay renders under, in every brief that carries it. One
37
+ * literal so the brief-side renderers, the distillation duty in the
38
+ * orchestrator floor, and the tests all name the same section.
39
+ */
40
+ export const KNOWLEDGE_HEADING = "## Repo knowledge (fleet-accumulated)";
41
+
42
+ /**
43
+ * Hard ceiling on one repo's overlay, in bytes.
44
+ *
45
+ * 16 KB is roughly a screen of dense facts and a few percent of a worker's
46
+ * context — enough to carry the entry points, proof commands and traps of a
47
+ * substantial repo, small enough that it can never crowd out the issue it is
48
+ * supposed to help implement. The orchestrator distils the file when it passes
49
+ * three quarters of this; the drop-oldest enforcement below is what keeps the
50
+ * ceiling true when nobody has.
51
+ */
52
+ export const KNOWLEDGE_MAX_BYTES = 16 * 1024;
53
+
54
+ /**
55
+ * Filesystem-safe, injective encoding of a repo key, in the same discipline
56
+ * `arm-challenge.ts` uses for project keys: readable when the raw name needs no
57
+ * escaping, an unambiguous escape hatch otherwise.
58
+ *
59
+ * The readable form is deliberate rather than incidental — an operator distils
60
+ * these files by hand, so `TerrifiedBug__conductor.md` is worth having over a
61
+ * hex blob. It stays injective because the readable class excludes `_` entirely:
62
+ * a two-segment slug maps to exactly one `__` join and splits back at it, a
63
+ * one-segment routing name can never contain `__`, and anything else (empty,
64
+ * dotted, over-long, or carrying a path separator we did not sanction) falls
65
+ * into the `=`-prefixed hex namespace, which the readable class cannot produce.
66
+ */
67
+ function encodeRepoKey(repo: string): string {
68
+ const segments = repo.split("/");
69
+ const safe =
70
+ segments.length <= 2 &&
71
+ segments.every((s) => /^[A-Za-z0-9.-]{1,64}$/.test(s) && s !== "." && s !== "..");
72
+ if (safe) return segments.join("__");
73
+ return `=${[...Buffer.from(repo, "utf8")].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
74
+ }
75
+
76
+ /**
77
+ * The overlay's entry lines, oldest first, with blank lines and any hand-added
78
+ * prose dropped.
79
+ *
80
+ * One parse, shared by the writer and the brief renderer on purpose: they must
81
+ * agree on what counts as an entry, or the cap one enforces is not the cap the
82
+ * other renders and a "16 KB" section arrives at 20.
83
+ */
84
+ function entriesOf(text: string): string[] {
85
+ return text
86
+ .split("\n")
87
+ .map((line) => line.trimEnd())
88
+ .filter((line) => line.startsWith(ENTRY_PREFIX) && line.slice(ENTRY_PREFIX.length).trim() !== "");
89
+ }
90
+
91
+ /**
92
+ * The newest entries that fit under {@link KNOWLEDGE_MAX_BYTES}, oldest dropped
93
+ * first and only ever whole.
94
+ *
95
+ * Byte lengths, not character counts: the cap is a context-budget promise, and
96
+ * one accented identifier or box-drawing character is several bytes.
97
+ */
98
+ function withinCap(entries: readonly string[]): string[] {
99
+ const kept = [...entries];
100
+ while (kept.length > 0 && Buffer.byteLength(`${kept.join("\n")}\n`, "utf8") > KNOWLEDGE_MAX_BYTES) {
101
+ kept.shift();
102
+ }
103
+ return kept;
104
+ }
105
+
106
+ /** Absolute path of one repo's overlay file. Pure: it never creates anything. */
107
+ export function knowledgePath(repo: string): string {
108
+ return join(stateDir(), KNOWLEDGE_DIR, `${encodeRepoKey(repo)}.md`);
109
+ }
110
+
111
+ /**
112
+ * One repo's overlay verbatim, or `undefined` when the fleet has learned
113
+ * nothing about it yet.
114
+ *
115
+ * `undefined` and `""` are deliberately the same outcome for a reader — no
116
+ * section — but the read stays honest about which it saw: absent file, versus a
117
+ * file that exists and holds nothing.
118
+ */
119
+ export function readKnowledge(repo: string): string | undefined {
120
+ try {
121
+ return readFileSync(knowledgePath(repo), "utf8");
122
+ } catch {
123
+ // Absent, unreadable, or a directory where the file should be. All three are
124
+ // "the fleet knows nothing", and none of them may cost a run.
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Append durable repo facts to the overlay, attributed to the issue that taught
131
+ * them and the day it did.
132
+ *
133
+ * The attribution is what makes an entry auditable: without it you cannot tell a
134
+ * still-true convention from one a refactor retired two months ago, and the
135
+ * distillation duty has nothing to sort by.
136
+ *
137
+ * Skips an entry already present verbatim, so a worker restating a fact the file
138
+ * already carries — and the to-spec verdict that produces the same
139
+ * `proofCommands` for every issue in a repo — cannot inflate the file until the
140
+ * cap evicts something real.
141
+ *
142
+ * Best-effort by contract: the callers are settlement paths, and a failed write
143
+ * here must never turn a finished run into a failed one.
144
+ */
145
+ export function appendKnowledge(
146
+ repo: string,
147
+ entries: readonly string[],
148
+ meta: { issue: number; at: number },
149
+ ): void {
150
+ const day = new Date(meta.at).toISOString().slice(0, 10);
151
+ const rendered = entries
152
+ // One entry is one line: collapsing whitespace stops a multi-line discovery
153
+ // from forging extra entries or splitting under the oldest-first drop.
154
+ .map((entry) => entry.replace(/\s+/g, " ").trim())
155
+ .filter((text) => text !== "")
156
+ .map((text) => `${ENTRY_PREFIX}${text} — #${String(meta.issue)}, ${day}`);
157
+ if (rendered.length === 0) return;
158
+ const path = knowledgePath(repo);
159
+ try {
160
+ const existing = entriesOf(readKnowledge(repo) ?? "");
161
+ // Dynamic membership over the file's own lines, grown as this batch is
162
+ // folded in, so one call cannot append the same fact twice either.
163
+ const seen = new Set(existing);
164
+ const added: string[] = [];
165
+ for (const line of rendered) {
166
+ if (seen.has(line)) continue;
167
+ seen.add(line);
168
+ added.push(line);
169
+ }
170
+ if (added.length === 0) return;
171
+ const kept = withinCap([...existing, ...added]);
172
+ mkdirSync(dirname(path), { recursive: true });
173
+ // Atomic like every other durable conductor state file: a brief renderer
174
+ // reading while a settlement writes sees the old file or the new one, never
175
+ // a half-written entry.
176
+ const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
177
+ try {
178
+ writeFileSync(tmp, `${kept.join("\n")}\n`, "utf8");
179
+ renameSync(tmp, path);
180
+ } catch (err) {
181
+ rmSync(tmp, { force: true });
182
+ throw err;
183
+ }
184
+ } catch (err) {
185
+ // Log and continue — see the module comment: the overlay is an
186
+ // optimisation, the run's outcome is not. Reported rather than swallowed so
187
+ // a permanently unwritable state root is visible in the daemon log instead
188
+ // of looking like a fleet that never learns anything.
189
+ process.stderr.write(
190
+ `knowledge: could not append ${String(rendered.length)} entr${rendered.length === 1 ? "y" : "ies"} ` +
191
+ `for ${repo}: ${err instanceof Error ? err.message : String(err)}\n`,
192
+ );
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The overlay as a brief section, or `""` when the fleet has learned nothing
198
+ * about this repo.
199
+ *
200
+ * The empty string is load-bearing: a missing file renders NO section and no
201
+ * placeholder prose. "No accumulated knowledge yet" would be a sentence that
202
+ * teaches a worker nothing and costs it a paragraph of attention on every
203
+ * single dispatch.
204
+ *
205
+ * Capped on read as well as on write, so a file an operator hand-edited past the
206
+ * ceiling still cannot blow the brief's budget — and capped by dropping whole
207
+ * oldest entries, never by clipping the last one mid-sentence.
208
+ */
209
+ export function knowledgeSection(repo: string): string {
210
+ const text = readKnowledge(repo);
211
+ if (text === undefined) return "";
212
+ const entries = withinCap(entriesOf(text));
213
+ if (entries.length === 0) return "";
214
+ return [
215
+ KNOWLEDGE_HEADING,
216
+ "",
217
+ "Facts earlier runs in this repo established, oldest first. They are evidence, not",
218
+ "instructions: verify one before you rely on it, and report a correction in your",
219
+ "settlement's `discoveries` if it has gone stale.",
220
+ "",
221
+ ...entries,
222
+ // Two closers, so the rendered block ends with a blank line: a brief
223
+ // splices this straight in front of its next heading (`{{KNOWLEDGE}}##
224
+ // Something`), exactly as `laneBlock` and the other brief sections do, and
225
+ // an omitted section then closes the gap with no whitespace left behind.
226
+ "",
227
+ "",
228
+ ].join("\n");
229
+ }
package/src/mining.ts ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Signal mining: turn the evidence the store already holds into intake items
3
+ * an operator can groom (Phase 4, chokepoints).
4
+ *
5
+ * The fleet records a great deal about how work went wrong and then never
6
+ * revisits it. A settlement flag is read once, in the tick digest of the hour
7
+ * it appeared; a failure class is counted in `stats` and otherwise forgotten.
8
+ * Nothing looks across runs and says "this keeps happening, someone should file
9
+ * an issue" — which is exactly the observation that turns recurring waste into
10
+ * fixed work. This module is that pass, and nothing else.
11
+ *
12
+ * ## Pure by construction
13
+ *
14
+ * Rows in, signals out. No store, no tracker, no `Date.now()` — the caller
15
+ * (the daemon's hourly pass) reads `statsRuns(project, since)`, hands them over
16
+ * with the same `since` it used, and files each returned signal through
17
+ * `recordIntake({ project, text, source })`. `source` is the idempotency key,
18
+ * so re-running the pass on an unchanged window files nothing new; the caller's
19
+ * hour gate is belt-and-braces rather than the thing that makes it safe.
20
+ *
21
+ * ## Why a signal needs two occurrences
22
+ *
23
+ * One settlement flag and one failed run are *already surfaced*, each by its
24
+ * own report, in the tick that produced them. Filing an intake item for them
25
+ * would duplicate that surface and bury the operator within a day. What no
26
+ * existing surface can see is the pattern across runs — the same test file
27
+ * weakened twice, the same class of failure recurring, the same CI job red on
28
+ * unrelated issues. That is the whole value here, so two occurrences inside the
29
+ * window is the floor for every kind.
30
+ *
31
+ * ## Three signals, and where each one's evidence actually lives
32
+ *
33
+ * 1. **test-weakening** — `runs.settlementFlags`, restricted to the weakening
34
+ * family `detectWeakening` produces (`test-file-deleted`, `test-disabled`,
35
+ * `assertions-removed`, `test-timeout-raised`). Grouped by file, because
36
+ * "this file's coverage keeps eroding" is the unit an operator can act on.
37
+ *
38
+ * 2. **failure-class-repeat** — `runs.failureClass`, grouped by class. No
39
+ * exclusion list: an exclusion list is where the next class quietly hides,
40
+ * and a repeating "infrastructure" class is a real issue to file (our
41
+ * runners keep dying) rather than noise. The text carries the recorded
42
+ * recovery actions so a groomer can dismiss a self-healing class in one
43
+ * read.
44
+ *
45
+ * 3. **ci-job-red** — the failing *job names*, parsed out of `runs.lastError`.
46
+ *
47
+ * That third one is worth explaining, because the obvious source for it does
48
+ * not work. A `Classification`'s `evidence` line (failure-class.ts) names the
49
+ * failing checks, but it is never persisted as a column: it rides into an
50
+ * escalation and is gone. Mining it would need a schema change.
51
+ *
52
+ * What *is* persisted, with no schema change at all, is the tracker's own
53
+ * verdict sentence: `settlePushedGreen` writes `verification.reason` verbatim
54
+ * into `runs.lastError` on a CI-red terminal row (settlement.ts), and the
55
+ * GitHub adapter builds that reason as `Checks failed: <job> (<conclusion>),
56
+ * …` on both its GraphQL and REST planes. The review-revision path persists the
57
+ * classifier's own `checks failed: <job> (<link>), …` into the same column. So
58
+ * the job name is already on the row, in one of two exact shapes this module
59
+ * parses — and only those two, anchored, so an unrelated `lastError` yields
60
+ * nothing rather than a fabricated job name.
61
+ *
62
+ * The honest limits of that, stated rather than papered over:
63
+ *
64
+ * - A row whose `lastError` was later overwritten (a merge-conflict recovery
65
+ * rewrites it) no longer carries its CI red, and this pass cannot see it.
66
+ * - Only the checks named in that one sentence are visible; a red that GitHub
67
+ * reported after the row settled is not on the row.
68
+ *
69
+ * Both make this signal an undercount, never an overcount, which is the right
70
+ * direction for something that files work for a human: a missed pattern costs
71
+ * a week, an invented one costs trust.
72
+ */
73
+
74
+ import type { RunRecord, SettlementFlagKind } from "./types.ts";
75
+
76
+ /** What a mined signal is about. Part of the `source` key, so these names are
77
+ * persisted in `intake_items` and must not be renamed casually. */
78
+ export type MinedSignalKind = "test-weakening" | "failure-class-repeat" | "ci-job-red";
79
+
80
+ /** One filed-once observation about a repeating pattern. */
81
+ export interface MinedSignal {
82
+ kind: MinedSignalKind;
83
+ /** What the pattern is about: a file path, a failure class, a CI job name. */
84
+ subject: string;
85
+ /** `mined:<kind>:<subject>` — stable across passes, distinct across subjects,
86
+ * and the idempotency key `recordIntake` derives its row id from. */
87
+ source: string;
88
+ /** What the operator reads. Self-contained: a groomer must be able to act
89
+ * (or dismiss) without re-deriving anything from the store. */
90
+ text: string;
91
+ /** Matching occurrences inside the window. Always ≥ {@link MIN_OCCURRENCES}. */
92
+ occurrences: number;
93
+ /** Distinct issues the pattern spans, ascending. */
94
+ issues: number[];
95
+ }
96
+
97
+ export interface MiningInput {
98
+ /** Named in the text so an item read months later says which fleet it came from. */
99
+ project: string;
100
+ now: number;
101
+ /**
102
+ * The project's rows over the window — `store.statsRuns(project, since)`.
103
+ * Rows outside the window are ignored here too, so passing a wider read is
104
+ * safe: the window, not the query, defines what counts.
105
+ */
106
+ runs: readonly RunRecord[];
107
+ /** Defaults to seven days, the window the plan specifies. */
108
+ windowMs?: number;
109
+ }
110
+
111
+ /** Seven days: long enough for a weekly pattern, short enough that a fixed
112
+ * problem stops being re-filed. */
113
+ export const DEFAULT_MINING_WINDOW_MS = 7 * 86_400_000;
114
+
115
+ /** Below this, the per-run report already said it; see the module header. */
116
+ export const MIN_OCCURRENCES = 2;
117
+
118
+ /**
119
+ * The settlement-flag kinds that mean coverage got weaker. The other kinds are
120
+ * deliberately absent: `lane-escape` and the `claimed-proof-*` family are about
121
+ * one run's honesty (the settlement report is the right surface, and a repeat
122
+ * is not a filable unit of work), `pr-adopted` is a recovery succeeding, and
123
+ * `changed-line-missing` / `base-branch-red` are faults with their own loud
124
+ * paths already.
125
+ */
126
+ const WEAKENING_KINDS: Partial<Record<SettlementFlagKind, true>> = {
127
+ "test-file-deleted": true,
128
+ "test-disabled": true,
129
+ "assertions-removed": true,
130
+ "test-timeout-raised": true,
131
+ };
132
+
133
+ /**
134
+ * The two persisted shapes that name failing CI jobs, both anchored at the
135
+ * start of the value: the tracker's verdict (`Checks failed: `, written by
136
+ * `settlePushedGreen`) and the classifier's line (`checks failed: `, written by
137
+ * the review-revision path). Anchored on purpose — an arbitrary `lastError`
138
+ * that merely mentions the phrase must not be mined for job names.
139
+ */
140
+ const CHECKS_FAILED_FORM = /^checks failed:\s*(.+)$/i;
141
+
142
+ /**
143
+ * One `<job> (<conclusion or link>)` entry, consuming its trailing separator.
144
+ *
145
+ * Matched rather than split on commas, because a job name may itself contain
146
+ * one (`build, test (macos)`) and a comma split would invent a job called
147
+ * "build". The trailing parenthetical is required and cannot nest, which is
148
+ * what both producers always emit; that requirement is also what stops a prose
149
+ * sentence from yielding imaginary job names.
150
+ */
151
+ const CHECK_ENTRY_FORM = /\s*(.+?)\s*\([^()]*\)\s*(?:,|$)/g;
152
+
153
+ /** The store's own window convention, as `stats.ts` uses it. */
154
+ const WINDOW_KEY = (run: RunRecord): number => run.endedAt ?? run.startedAt;
155
+
156
+ /** One subject's accumulating evidence. */
157
+ interface Bucket {
158
+ occurrences: number;
159
+ issues: Set<number>;
160
+ repos: Set<string>;
161
+ /** Free-form detail lines, bounded, newest last — what makes the text actionable. */
162
+ details: string[];
163
+ /** Kinds/actions seen for this subject, in first-seen order. */
164
+ tags: string[];
165
+ }
166
+
167
+ /** How many evidence lines one signal's text carries. Enough to act on, few
168
+ * enough that an intake list stays readable. */
169
+ const MAX_DETAILS = 3;
170
+
171
+ function bucket(map: Map<string, Bucket>, key: string): Bucket {
172
+ let found = map.get(key);
173
+ if (found === undefined) {
174
+ found = { occurrences: 0, issues: new Set(), repos: new Set(), details: [], tags: [] };
175
+ map.set(key, found);
176
+ }
177
+ return found;
178
+ }
179
+
180
+ function note(b: Bucket, run: RunRecord, detail: string, tag?: string): void {
181
+ b.occurrences += 1;
182
+ b.issues.add(run.issue);
183
+ b.repos.add(run.repo);
184
+ if (b.details.length < MAX_DETAILS) b.details.push(detail);
185
+ if (tag !== undefined && !b.tags.includes(tag)) b.tags.push(tag);
186
+ }
187
+
188
+ /** `2026-08-24`, so a text line dates its own evidence without a clock. */
189
+ function day(at: number): string {
190
+ return new Date(at).toISOString().slice(0, 10);
191
+ }
192
+
193
+ /**
194
+ * Every failing job named by a persisted CI-red `lastError`, or an empty list
195
+ * when the value is not one of the two known shapes.
196
+ */
197
+ export function failingJobsFromLastError(lastError: string | undefined): string[] {
198
+ if (lastError === undefined) return [];
199
+ const matched = CHECKS_FAILED_FORM.exec(lastError.trim());
200
+ if (matched === null) return [];
201
+ const jobs: string[] = [];
202
+ for (const entry of matched[1]!.matchAll(CHECK_ENTRY_FORM)) {
203
+ // Whatever the parenthetical says is not read: the producers have changed
204
+ // conclusion spelling across versions, and the list is failures by
205
+ // construction. Only the name is taken, and only when it is non-empty.
206
+ const name = entry[1]!.trim();
207
+ if (name.length > 0 && !jobs.includes(name)) jobs.push(name);
208
+ }
209
+ return jobs;
210
+ }
211
+
212
+ /**
213
+ * Mine one project's window. Deterministic: same rows and `now` produce the
214
+ * same signals in the same order (loudest first, then alphabetical), so a
215
+ * caller can diff two passes.
216
+ */
217
+ export function mineSignals(input: MiningInput): MinedSignal[] {
218
+ const since = input.now - (input.windowMs ?? DEFAULT_MINING_WINDOW_MS);
219
+ const weakening = new Map<string, Bucket>();
220
+ const classes = new Map<string, Bucket>();
221
+ const jobs = new Map<string, Bucket>();
222
+
223
+ for (const run of input.runs) {
224
+ const at = WINDOW_KEY(run);
225
+ if (at < since || at > input.now) continue;
226
+
227
+ for (const flag of run.settlementFlags ?? []) {
228
+ if (WEAKENING_KINDS[flag.kind] !== true) continue;
229
+ note(
230
+ bucket(weakening, flag.file),
231
+ run,
232
+ `${day(at)} #${run.issue} ${flag.kind}${flag.line === undefined ? "" : ` line ${flag.line}`}: ${flag.detail}` +
233
+ (flag.unattributed === true ? " [the dispatching issue never named this file]" : ""),
234
+ flag.kind,
235
+ );
236
+ }
237
+
238
+ if (run.failureClass !== undefined) {
239
+ note(
240
+ bucket(classes, run.failureClass),
241
+ run,
242
+ `${day(at)} #${run.issue} (${run.repo}) attempt ${run.attempt}${run.lastError === undefined ? "" : `: ${oneLine(run.lastError)}`}`,
243
+ run.recoveryAction,
244
+ );
245
+ }
246
+
247
+ for (const job of failingJobsFromLastError(run.lastError)) {
248
+ note(bucket(jobs, job), run, `${day(at)} #${run.issue} (${run.repo}) attempt ${run.attempt}`);
249
+ }
250
+ }
251
+
252
+ const windowDays = Math.round((input.now - since) / 86_400_000);
253
+ const signals: MinedSignal[] = [
254
+ ...emit(weakening, "test-weakening", (subject, b) =>
255
+ `Test coverage for \`${subject}\` was weakened in ${plural(b.occurrences, "settled run")} ` +
256
+ `over the last ${windowDays} days (${b.tags.join(", ")}). ` +
257
+ `A single weakening is reviewed in its own settlement report; this is the repeat that report cannot see, ` +
258
+ `so the question is whether the coverage was replaced or quietly lost.`,
259
+ ),
260
+ ...emit(classes, "failure-class-repeat", (subject, b) =>
261
+ `${plural(b.occurrences, "run")} failed as \`${subject}\` over the last ${windowDays} days ` +
262
+ `(recovery recorded: ${b.tags.length === 0 ? "none" : b.tags.join(", ")}). ` +
263
+ `Recurring at this rate it is a fleet problem rather than one run's bad luck: either the class needs a ` +
264
+ `mechanical recovery it does not have, or the thing it keeps tripping over needs fixing.`,
265
+ ),
266
+ ...emit(jobs, "ci-job-red", (subject, b) =>
267
+ `CI job \`${subject}\` was red on ${plural(b.occurrences, "settled run")} over the last ${windowDays} days. ` +
268
+ `Job names come from the verdict sentence persisted on each run, so this is an undercount, never an ` +
269
+ `overcount. Repeating across unrelated issues usually means the job itself is broken, flaky or ` +
270
+ `mis-scoped rather than that every diff was wrong.`,
271
+ ),
272
+ ];
273
+ return signals.sort(
274
+ (a, b) => b.occurrences - a.occurrences || a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject),
275
+ );
276
+ }
277
+
278
+ /** Buckets over the floor, rendered. */
279
+ function emit(
280
+ buckets: Map<string, Bucket>,
281
+ kind: MinedSignalKind,
282
+ headline: (subject: string, b: Bucket) => string,
283
+ ): MinedSignal[] {
284
+ const out: MinedSignal[] = [];
285
+ for (const [subject, b] of buckets) {
286
+ if (b.occurrences < MIN_OCCURRENCES) continue;
287
+ const issues = [...b.issues].sort((x, y) => x - y);
288
+ out.push({
289
+ kind,
290
+ subject,
291
+ source: `mined:${kind}:${subject}`,
292
+ text: [
293
+ headline(subject, b),
294
+ "",
295
+ `Issues: ${issues.map((i) => `#${i}`).join(", ")} · repos: ${[...b.repos].sort().join(", ")}`,
296
+ ...b.details.map((d) => `- ${d}`),
297
+ ...(b.occurrences > b.details.length
298
+ ? [`- …and ${b.occurrences - b.details.length} more in the window`]
299
+ : []),
300
+ ].join("\n"),
301
+ occurrences: b.occurrences,
302
+ issues,
303
+ });
304
+ }
305
+ return out;
306
+ }
307
+
308
+ function plural(n: number, noun: string): string {
309
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
310
+ }
311
+
312
+ /** One bounded line: an intake item is a summary, not a transcript. */
313
+ function oneLine(text: string): string {
314
+ const flat = text.replace(/\s+/g, " ").trim();
315
+ return flat.length <= 160 ? flat : `${flat.slice(0, 157)}…`;
316
+ }