@gethmy/harness 1.2.1 → 1.4.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.
package/src/git-pr.ts CHANGED
@@ -137,15 +137,124 @@ export function upsertReviewedSha(description: string, sha: string): string {
137
137
  return `${description}${sep}${line}`;
138
138
  }
139
139
 
140
+ // The independent-review request ledger (card #1003). Deliberately the same
141
+ // shape and channel as `Reviewed-SHA` above: one line in the card description,
142
+ // keyed by the head it applies to.
143
+ //
144
+ // It exists to bound cost, not to record a verdict. Requesting a CI review is a
145
+ // remove-then-add of a label, which GitHub answers with a fresh paid Opus/Sonnet
146
+ // pass — and the merge monitor ticks every 60s. Without a per-head record of
147
+ // "already asked", a review that has not yet registered a check run would be
148
+ // re-requested on every tick, billing a full review a minute. Keyed by SHA
149
+ // rather than a bare boolean so that a branch which moves after its review
150
+ // (the `rereview` path) correctly asks again for the new head.
151
+ const CI_REVIEW_REQUESTED_SHA_RE =
152
+ /^CI-Review-Requested-SHA:\s*([0-9a-f]{7,40})\s*$/im;
153
+
154
+ export function extractCiReviewRequestedSha(
155
+ description: string | null,
156
+ ): string | null {
157
+ if (!description) return null;
158
+ const m = description.match(CI_REVIEW_REQUESTED_SHA_RE);
159
+ return m ? m[1] : null;
160
+ }
161
+
162
+ export function upsertCiReviewRequestedSha(
163
+ description: string,
164
+ sha: string,
165
+ ): string {
166
+ const line = `CI-Review-Requested-SHA: ${sha}`;
167
+ if (CI_REVIEW_REQUESTED_SHA_RE.test(description)) {
168
+ return description.replace(CI_REVIEW_REQUESTED_SHA_RE, line);
169
+ }
170
+ const sep = description ? "\n" : "";
171
+ return `${description}${sep}${line}`;
172
+ }
173
+
174
+ /**
175
+ * Whether a rollup entry is the check called `wanted` — matched against the
176
+ * check-run name (`review`) or its workflow name (`Claude PR Review`), so an
177
+ * operator can configure whichever of the two their own UI shows them.
178
+ *
179
+ * `wanted` must already be trimmed and lower-cased. A blank one matches nothing
180
+ * rather than the first entry: the callers below all read a configured name, and
181
+ * a typo that silently matched *something* is the dangerous direction.
182
+ *
183
+ * Known gap, inherited from #1003 and deliberately left as it was: a real legacy
184
+ * `StatusContext` carries its name in `context`, not `name`, so it is never
185
+ * matched here — only a CheckRun is. Both readers below still have a
186
+ * `StatusContext` branch, reachable only for an entry carrying both fields. It
187
+ * costs nothing today (GitHub Actions produces CheckRuns, and #1018's verdict is
188
+ * created through the Checks API) and widening it would change what the shipped
189
+ * #1003 reader matches, which is not this card's to change.
190
+ */
191
+ function checkMatchesName(c: Record<string, unknown>, wanted: string): boolean {
192
+ if (!wanted) return false;
193
+ const name = typeof c.name === "string" ? c.name.toLowerCase() : "";
194
+ const workflow =
195
+ typeof c.workflowName === "string" ? c.workflowName.toLowerCase() : "";
196
+ return name === wanted || workflow === wanted;
197
+ }
198
+
199
+ /**
200
+ * Whether a rollup entry is the CHECK RUN called `skip` — by its own name only,
201
+ * never by workflow name. That restriction is the whole point (#1018).
202
+ *
203
+ * One workflow now publishes two checks with opposite meanings (`review` says a
204
+ * pass ran, `review-verdict` says what it concluded), and `checkMatchesName`
205
+ * above accepts the workflow name — a spelling `.github/CLAUDE.md` documents.
206
+ * So an operator on `checkName: "Claude PR Review"` has BOTH entries matching
207
+ * the review reader, and a truncated review beside a neutral verdict read as
208
+ * `passed`: the failure was reported as a healthy review, with nothing on the
209
+ * board. Each reader therefore skips the other's check.
210
+ *
211
+ * Skipping by workflow name would be worse than not skipping: both entries
212
+ * carry the same workflow name, so it would erase the reader's own check too and
213
+ * hold every merge on `absent`.
214
+ */
215
+ function isNamedCheck(
216
+ c: Record<string, unknown>,
217
+ skip: string | undefined,
218
+ ): boolean {
219
+ if (!skip) return false;
220
+ const wanted = skip.trim().toLowerCase();
221
+ if (!wanted) return false;
222
+ return typeof c.name === "string" && c.name.toLowerCase() === wanted;
223
+ }
224
+
140
225
  /** Derive a single CI verdict from a `gh pr view --json statusCheckRollup` array.
141
226
  * Handles both CheckRun ({status, conclusion}) and legacy StatusContext ({state}).
142
- * Empty/non-array (no checks configured) → unknown (conservative: never auto-merge). */
143
- export function deriveCiStatus(rollup: unknown): PrCiStatus {
227
+ * Empty/non-array (no checks configured) → unknown (conservative: never auto-merge).
228
+ *
229
+ * `excludeChecks` names checks this verdict must NOT speak for (card #1018).
230
+ * The review checks are the only intended members: they are read separately by
231
+ * the two functions below, and folding them in here made the collapsed word lie
232
+ * about WHOSE failure it was. A red `review` check meant "the review ran out of
233
+ * turns" and a red `review-verdict` check means "the reviewer found something",
234
+ * yet both arrived at the merge gate as an indistinguishable `failure` — which
235
+ * stamped `CI checks failed` on the card and, with `ciRepair.enabled`, re-ran a
236
+ * CI that was never broken.
237
+ *
238
+ * Excluding is not the same as ignoring: every excluded state is answered by
239
+ * `deriveIndependentReviewState` / `deriveReviewVerdictState`, and both HOLD a
240
+ * merge on anything but a pass. The rollup being empty *after* exclusion still
241
+ * reads `success` rather than `unknown`, because `unknown` means "this repo
242
+ * configures no checks at all" — a repo whose only check is the review has a
243
+ * green build by that repo's own definition, and the review gates it below.
244
+ */
245
+ export function deriveCiStatus(
246
+ rollup: unknown,
247
+ excludeChecks: string[] = [],
248
+ ): PrCiStatus {
144
249
  if (!Array.isArray(rollup) || rollup.length === 0) return "unknown";
250
+ const excluded = excludeChecks
251
+ .map((n) => n.trim().toLowerCase())
252
+ .filter(Boolean);
145
253
  let anyPending = false;
146
254
  for (const check of rollup) {
147
255
  if (typeof check !== "object" || check === null) continue;
148
256
  const c = check as Record<string, unknown>;
257
+ if (excluded.some((name) => checkMatchesName(c, name))) continue;
149
258
  if (typeof c.status === "string") {
150
259
  // CheckRun
151
260
  if (c.status.toUpperCase() !== "COMPLETED") {
@@ -171,14 +280,314 @@ export function deriveCiStatus(rollup: unknown): PrCiStatus {
171
280
  return anyPending ? "pending" : "success";
172
281
  }
173
282
 
283
+ /**
284
+ * Whether an INDEPENDENT review — one produced outside the daemon that wrote the
285
+ * code — has run against the PR's current head (card #1003).
286
+ *
287
+ * `deriveCiStatus` above cannot answer this, and that is the whole bug: it folds
288
+ * `SKIPPED` in with `SUCCESS` (see its `["SUCCESS", "NEUTRAL", "SKIPPED"]`
289
+ * line). A daemon PR on an `agent/` branch has its review job skipped by the
290
+ * workflow's own branch rule, so the rollup carries a real check run reading
291
+ * `{name: "review", conclusion: "SKIPPED"}` — and the collapsed verdict for that
292
+ * PR is `"success"`. The merge gate could not tell "reviewed and clean" from
293
+ * "nobody looked".
294
+ *
295
+ * `absent` and `skipped` are kept apart on purpose. `skipped` means the workflow
296
+ * exists and declined this branch, which a `ci-review` request can fix. `absent`
297
+ * means no such check is configured on this repo at all — a request would be
298
+ * shouting into a void — or the head moved and the rollup has not caught up yet.
299
+ * Both hold the merge; only the caller can say which deserves a request.
300
+ */
301
+ export type IndependentReviewState =
302
+ | "passed"
303
+ | "failed"
304
+ | "pending"
305
+ | "skipped"
306
+ | "absent";
307
+
308
+ /**
309
+ * Read one named check out of a `statusCheckRollup` array.
310
+ *
311
+ * `checkName` matches either the check-run name (`review`) or its workflow name
312
+ * (`Claude PR Review`), case-insensitively, so an operator can configure
313
+ * whichever of the two they can see in their own UI.
314
+ *
315
+ * Precedence when several runs share the name — a re-request makes this normal —
316
+ * is `passed > pending > failed > skipped`. A pass anywhere means someone
317
+ * independent did read this head, which is the question being asked; a pending
318
+ * re-run outranks the failed run it is retrying, so the gate waits for the
319
+ * answer instead of acting on the stale one.
320
+ */
321
+ export function deriveIndependentReviewState(
322
+ rollup: unknown,
323
+ checkName: string,
324
+ /**
325
+ * The VERDICT check's name, skipped by check-run name (#1018). Required
326
+ * whenever a verdict check exists: `checkName` may legitimately be the
327
+ * workflow name, which both checks share, and without this a truncated
328
+ * review sitting beside a neutral verdict reads as `passed`.
329
+ */
330
+ skipCheckName?: string,
331
+ ): IndependentReviewState {
332
+ if (!Array.isArray(rollup)) return "absent";
333
+ const wanted = checkName.trim().toLowerCase();
334
+ if (!wanted) return "absent";
335
+
336
+ let sawPending = false;
337
+ let sawFailed = false;
338
+ let sawSkipped = false;
339
+
340
+ for (const check of rollup) {
341
+ if (typeof check !== "object" || check === null) continue;
342
+ const c = check as Record<string, unknown>;
343
+ if (isNamedCheck(c, skipCheckName)) continue;
344
+ if (!checkMatchesName(c, wanted)) continue;
345
+
346
+ // CheckRun. A run that has not COMPLETED is in flight whatever else it says.
347
+ if (typeof c.status === "string") {
348
+ if (c.status.toUpperCase() !== "COMPLETED") {
349
+ sawPending = true;
350
+ continue;
351
+ }
352
+ const conclusion =
353
+ typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
354
+ if (conclusion === "SKIPPED") {
355
+ sawSkipped = true;
356
+ continue;
357
+ }
358
+ // NEUTRAL counts as a pass for the same reason `deriveCiStatus` accepts
359
+ // it: the reviewer ran and declined to fail the PR. What matters here is
360
+ // that an independent pass happened, not what it concluded — which is
361
+ // this function's whole scope, and why `deriveReviewVerdictState` below
362
+ // exists to answer the other half (#1018).
363
+ if (conclusion === "SUCCESS" || conclusion === "NEUTRAL") return "passed";
364
+ sawFailed = true;
365
+ continue;
366
+ }
367
+
368
+ // Legacy StatusContext — no notion of "skipped".
369
+ if (typeof c.state === "string") {
370
+ const state = c.state.toUpperCase();
371
+ if (state === "SUCCESS") return "passed";
372
+ if (state === "PENDING") {
373
+ sawPending = true;
374
+ continue;
375
+ }
376
+ sawFailed = true;
377
+ }
378
+ }
379
+
380
+ if (sawPending) return "pending";
381
+ if (sawFailed) return "failed";
382
+ if (sawSkipped) return "skipped";
383
+ return "absent";
384
+ }
385
+
386
+ /**
387
+ * What the independent review CONCLUDED, as opposed to whether it ran (#1018).
388
+ *
389
+ * `deriveIndependentReviewState` above answers "did anyone outside this daemon
390
+ * read this head?", which is all #1003 claimed. It cannot answer "and did they
391
+ * object?", because a routine `claude-review.yml` pass posts its findings as PR
392
+ * comments and exits 0 — so a review that wrote `critical: …` on the PR arrived
393
+ * at the merge gate looking exactly like a clean one, and under sweep nobody
394
+ * read those comments before the merge.
395
+ *
396
+ * The verdict therefore needs its own signal, and the workflow now publishes one
397
+ * as a separate check run (`review-verdict`) whose conclusion carries the
398
+ * reviewer's own answer. Reading a *distinct* check rather than failing the
399
+ * review job is what keeps the two questions separable: the review check stays
400
+ * "it ran", so a truncated review and a review that found a bug remain
401
+ * different states on the board instead of collapsing into one red X.
402
+ *
403
+ * The five states and why each is what it is:
404
+ * - `clean` — SUCCESS. The reviewer says nothing blocks the merge.
405
+ * - `blocking` — FAILURE. It found something a person must resolve.
406
+ * - `unknown` — NEUTRAL or SKIPPED. The run produced no verdict to read (it
407
+ * truncated, or it never emitted the line). Held, not waved through: "no
408
+ * answer" is not "no findings".
409
+ * - `pending` — a run under this name is still going.
410
+ * - `absent` — no such check on this head. The repo's workflow predates the
411
+ * verdict, the head moved and the rollup has not caught up, or the API call
412
+ * that publishes it failed (a fork PR gets a read-only token). Also held.
413
+ */
414
+ export type ReviewVerdictState =
415
+ | "clean"
416
+ | "blocking"
417
+ | "unknown"
418
+ | "pending"
419
+ | "absent";
420
+
421
+ /**
422
+ * Read the verdict check out of a `statusCheckRollup` array.
423
+ *
424
+ * Precedence when several runs share the name — a re-request makes this normal —
425
+ * is the INVERSE of `deriveIndependentReviewState`'s: `blocking > pending >
426
+ * unknown > clean`. That reader takes the most permissive answer because a
427
+ * single independent read satisfies it; this one takes the most restrictive,
428
+ * because a finding does not stop being real when a later pass over the same
429
+ * head fails to repeat it. The remedy for a blocking verdict is a commit that
430
+ * fixes it, which moves the head and asks again with a clean slate.
431
+ */
432
+ export function deriveReviewVerdictState(
433
+ rollup: unknown,
434
+ checkName: string,
435
+ /**
436
+ * The REVIEW-RAN check's name, skipped by check-run name — the mirror of the
437
+ * argument on the reader above, so the two questions stay separable whichever
438
+ * of the two spellings an operator configured.
439
+ */
440
+ skipCheckName?: string,
441
+ ): ReviewVerdictState {
442
+ if (!Array.isArray(rollup)) return "absent";
443
+ const wanted = checkName.trim().toLowerCase();
444
+ if (!wanted) return "absent";
445
+
446
+ let sawPending = false;
447
+ let sawUnknown = false;
448
+ let sawClean = false;
449
+
450
+ for (const check of rollup) {
451
+ if (typeof check !== "object" || check === null) continue;
452
+ const c = check as Record<string, unknown>;
453
+ if (isNamedCheck(c, skipCheckName)) continue;
454
+ if (!checkMatchesName(c, wanted)) continue;
455
+
456
+ // CheckRun. A run that has not COMPLETED has no verdict yet.
457
+ if (typeof c.status === "string") {
458
+ if (c.status.toUpperCase() !== "COMPLETED") {
459
+ sawPending = true;
460
+ continue;
461
+ }
462
+ const conclusion =
463
+ typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
464
+ if (conclusion === "SUCCESS") {
465
+ sawClean = true;
466
+ continue;
467
+ }
468
+ // NEUTRAL is the workflow saying "I could not read a verdict", and
469
+ // SKIPPED is it declining to evaluate one. Neither is a pass here —
470
+ // unlike in the two readers above, where NEUTRAL means a reviewer ran and
471
+ // chose not to fail the PR.
472
+ if (conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
473
+ sawUnknown = true;
474
+ continue;
475
+ }
476
+ return "blocking";
477
+ }
478
+
479
+ // Legacy StatusContext — a commit status has no neutral, so the only
480
+ // readings available are pass, in flight, and everything else.
481
+ if (typeof c.state === "string") {
482
+ const state = c.state.toUpperCase();
483
+ if (state === "SUCCESS") {
484
+ sawClean = true;
485
+ continue;
486
+ }
487
+ if (state === "PENDING") {
488
+ sawPending = true;
489
+ continue;
490
+ }
491
+ return "blocking";
492
+ }
493
+ }
494
+
495
+ if (sawPending) return "pending";
496
+ if (sawUnknown) return "unknown";
497
+ if (sawClean) return "clean";
498
+ return "absent";
499
+ }
500
+
501
+ /** The three answers `readPrChecks` derives from one rollup. */
502
+ export interface PrCheckStates {
503
+ ciStatus: PrCiStatus;
504
+ independentReview: IndependentReviewState;
505
+ reviewVerdict: ReviewVerdictState;
506
+ }
507
+
508
+ /**
509
+ * Turn one `statusCheckRollup` into three orthogonal answers (#1018).
510
+ *
511
+ * Pure, and split out of `getPrStatus` deliberately: this wiring — which name
512
+ * goes to which reader, which names come out of the collapsed CI word, and which
513
+ * name each reader must skip — is where both of the review's majors lived, and a
514
+ * unit test on the readers alone could not reach it.
515
+ *
516
+ * The three do not overlap: `ciStatus` speaks for the build/test checks,
517
+ * `independentReview` for whether a review ran, and `reviewVerdict` for what it
518
+ * said. **Every NAMED check is excluded from `ciStatus`**, so the merge gate can
519
+ * never stamp `CI checks failed` for something a review gate already owns — and
520
+ * each reader skips the other's check by run name, so one workflow publishing
521
+ * both cannot make either answer for the other.
522
+ *
523
+ * Naming a check is all-or-nothing per caller: omit `independentReviewCheck` and
524
+ * the review's state is not reported AND its failures land back in `ciStatus` —
525
+ * exactly the pre-#1003 behaviour an operator with `independentReview.enabled:
526
+ * false` asked for. The caller therefore names the verdict check whenever the
527
+ * review gate is on, whether or not it GATES on the verdict, because the
528
+ * alternative is a check this card introduced becoming a new red build for
529
+ * someone who opted out of reading it.
530
+ */
531
+ export function readPrChecks(
532
+ rollup: unknown,
533
+ checks: {
534
+ independentReviewCheck?: string;
535
+ reviewVerdictCheck?: string;
536
+ } = {},
537
+ ): PrCheckStates {
538
+ const { independentReviewCheck, reviewVerdictCheck } = checks;
539
+ return {
540
+ ciStatus: deriveCiStatus(
541
+ rollup,
542
+ [independentReviewCheck, reviewVerdictCheck].filter(
543
+ (n): n is string => typeof n === "string",
544
+ ),
545
+ ),
546
+ independentReview: independentReviewCheck
547
+ ? deriveIndependentReviewState(
548
+ rollup,
549
+ independentReviewCheck,
550
+ reviewVerdictCheck,
551
+ )
552
+ : "absent",
553
+ reviewVerdict: reviewVerdictCheck
554
+ ? deriveReviewVerdictState(
555
+ rollup,
556
+ reviewVerdictCheck,
557
+ independentReviewCheck,
558
+ )
559
+ : "absent",
560
+ };
561
+ }
562
+
563
+ /**
564
+ * One `gh pr view` read, three orthogonal answers about the same head.
565
+ * See {@link readPrChecks} for what each one speaks for and what it excludes.
566
+ */
174
567
  export async function getPrStatus(
175
568
  prUrl: string,
176
569
  cwd: string,
177
570
  provider: GitProvider,
178
- ): Promise<{ ciStatus: PrCiStatus; headSha: string | null }> {
179
- if (provider !== "github" || !isValidPrUrl(prUrl)) {
180
- return { ciStatus: "unknown", headSha: null };
181
- }
571
+ /**
572
+ * Check to read the independent-review state from. Omitted → not reported,
573
+ * and its result stays inside `ciStatus`.
574
+ */
575
+ independentReviewCheck?: string,
576
+ /** Check to read the review VERDICT from (#1018). Same all-or-nothing rule. */
577
+ reviewVerdictCheck?: string,
578
+ ): Promise<{
579
+ ciStatus: PrCiStatus;
580
+ headSha: string | null;
581
+ independentReview: IndependentReviewState;
582
+ reviewVerdict: ReviewVerdictState;
583
+ }> {
584
+ const unreadable = {
585
+ ciStatus: "unknown",
586
+ headSha: null,
587
+ independentReview: "absent",
588
+ reviewVerdict: "absent",
589
+ } as const;
590
+ if (provider !== "github" || !isValidPrUrl(prUrl)) return { ...unreadable };
182
591
  try {
183
592
  const { stdout } = await execFileAsync()(
184
593
  "gh",
@@ -191,10 +600,54 @@ export async function getPrStatus(
191
600
  };
192
601
  const headSha =
193
602
  typeof parsed.headRefOid === "string" ? parsed.headRefOid : null;
194
- return { ciStatus: deriveCiStatus(parsed.statusCheckRollup), headSha };
603
+ return {
604
+ ...readPrChecks(parsed.statusCheckRollup, {
605
+ independentReviewCheck,
606
+ reviewVerdictCheck,
607
+ }),
608
+ headSha,
609
+ };
610
+ } catch {
611
+ return { ...unreadable };
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Ask CI for an independent review by (re-)applying its request label.
617
+ *
618
+ * The remove-then-add is not defensive coding, it is the API: GitHub emits
619
+ * `labeled` only on an absent→present transition, so an already-labelled PR
620
+ * needs the label taken off first or the workflow never fires. The remove is
621
+ * expected to fail when the label is not there, and that failure is discarded.
622
+ *
623
+ * Callers must rate-limit themselves — every successful call bills a full review
624
+ * pass. `extractCiReviewRequestedSha` / `upsertCiReviewRequestedSha` above are
625
+ * the intended ledger.
626
+ */
627
+ export async function requestIndependentReview(
628
+ prUrl: string,
629
+ cwd: string,
630
+ provider: GitProvider,
631
+ label: string,
632
+ ): Promise<void> {
633
+ if (provider !== "github") {
634
+ throw new Error(`independent review request unsupported for "${provider}"`);
635
+ }
636
+ const opts = { cwd, encoding: "utf-8" as const, timeout: 30_000 };
637
+ try {
638
+ await execFileAsync()(
639
+ "gh",
640
+ ["pr", "edit", prUrl, "--remove-label", label],
641
+ opts,
642
+ );
195
643
  } catch {
196
- return { ciStatus: "unknown", headSha: null };
644
+ // Not present, so nothing to clear. The add below is the operative half.
197
645
  }
646
+ await execFileAsync()(
647
+ "gh",
648
+ ["pr", "edit", prUrl, "--add-label", label],
649
+ opts,
650
+ );
198
651
  }
199
652
 
200
653
  export async function mergePullRequest(
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  export const MOTOR_NAME = "harmony-harness";
14
14
 
15
15
  export * from "./artifact-judge.js";
16
+ export * from "./ci-failure.js";
16
17
  export * from "./command-metric.js";
17
18
  export * from "./confine-to-repo.js";
18
19
  export * from "./error-classifier.js";
@@ -30,6 +31,7 @@ export * from "./oracle-collector.js";
30
31
  export * from "./pm.js";
31
32
  export * from "./process-group.js";
32
33
  export * from "./project-type.js";
34
+ export * from "./repair-sandbox.js";
33
35
  export * from "./revert-guard.js";
34
36
  export * from "./review-types.js";
35
37
  export * from "./run-sizing.js";
@@ -14,10 +14,9 @@ const claude: ModelTierConfig = {
14
14
  tiers: { simple: "haiku", advanced: "sonnet", research: "opus" },
15
15
  };
16
16
 
17
- function card(p: Partial<Card>): Card {
17
+ function card(p: Partial<Card> & Record<string, unknown>): Card {
18
18
  return {
19
19
  priority: "medium",
20
- model_tier: null,
21
20
  model_override: null,
22
21
  ...p,
23
22
  } as Card;
@@ -120,12 +119,18 @@ describe("chooseImplementModel", () => {
120
119
  ).toEqual({ model: "claude-opus-4-8", escalated: true, source: "policy" });
121
120
  });
122
121
 
123
- it("ignores card.model_tier entirely", () => {
124
- // The column still exists and still holds stale values written by the
125
- // retired create-time classifier. Nothing may read it.
122
+ it("ignores a stale classifier tier still riding on the card object", () => {
123
+ // The columns are dropped, but a cached board row TanStack Query on web,
124
+ // the persisted zustand store on mobile — can still carry the retired keys
125
+ // for as long as that cache lives. `model_override` is the ONLY card-level
126
+ // model control; anything else on the row must change nothing.
126
127
  const r = chooseImplementModel(
127
128
  claude,
128
- card({ model_tier: "research" }),
129
+ card({
130
+ model_tier: "research",
131
+ complexity_score: 9,
132
+ intent: "implement",
133
+ }),
129
134
  1,
130
135
  undefined,
131
136
  );
package/src/model-tier.ts CHANGED
@@ -74,13 +74,13 @@ export interface RunSizing {
74
74
  * 2. `sized.tier` — the pickup preflight. On a retry (attempts >=
75
75
  * escalateAfterAttempts) the tier bumps one level before resolving.
76
76
  *
77
- * This replaces the retired `card.model_tier`, which a create-time
78
- * classifier wrote by guessing engineering effort from a title composed
77
+ * This replaces the retired create-time classifier, which wrote a tier
78
+ * onto the card by guessing engineering effort from a title composed
79
79
  * before anyone had looked at the code. The preflight runs at pickup with
80
80
  * the repo readable, so the answer is measured rather than guessed — and
81
81
  * it is run-scoped, so a stale value can never outlive the run it was for.
82
- * `card.model_tier` is deliberately NOT read here; the column survives
83
- * only so old rows stay readable until a follow-up drops it.
82
+ * The card column that held that guess has been dropped, so there is no
83
+ * card-level tier left to read: `model_override` is the only one.
84
84
  * 3. Global policy fallback — escalate on high/urgent priority or on a retry.
85
85
  * This is also where EVERY preflight failure lands (spawn error, timeout,
86
86
  * malformed output, or an operator who disabled it), so a broken preflight