tumwater 0.4.3 → 0.4.4

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 (164) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +2 -1
  4. package/dist/src/agent/claude/claude-account.js +6 -20
  5. package/dist/src/agent/codex/codex-account.js +5 -6
  6. package/dist/src/agent/codex/codex-args.js +5 -5
  7. package/dist/src/agent/codex/codex-rollout.js +1 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +24 -22
  9. package/dist/src/agent/opencode/opencode-catalog.js +35 -9
  10. package/dist/src/agent/session-ledger.js +22 -26
  11. package/dist/src/agent/subscription-account.js +2 -83
  12. package/dist/src/backlog/backlog-archive.js +3 -4
  13. package/dist/src/backlog/backlog-eligibility.js +14 -36
  14. package/dist/src/backlog/backlog-question-answer.js +1 -1
  15. package/dist/src/backlog/backlog.js +2 -1
  16. package/dist/src/baseline/main-guardrails.js +2 -2
  17. package/dist/src/baseline/main-watch.js +84 -50
  18. package/dist/src/brief/brief.js +26 -13
  19. package/dist/src/budget/budget.js +4 -5
  20. package/dist/src/build/build-check-report.js +10 -0
  21. package/dist/src/build/build-check-scoped.js +0 -2
  22. package/dist/src/build/build-stage.js +6 -4
  23. package/dist/src/change/change-data.js +1 -4
  24. package/dist/src/cli/cli-completion-command.js +75 -0
  25. package/dist/src/cli/cli-doctor-command.js +103 -0
  26. package/dist/src/cli/cli-init.js +2 -2
  27. package/dist/src/cli/cli-query-commands.js +1 -1
  28. package/dist/src/cli/cli-role-command.js +2 -41
  29. package/dist/src/cli/cli-start-summary.js +4 -3
  30. package/dist/src/cli/cli-start-supervisor.js +190 -0
  31. package/dist/src/cli/cli-start.js +4 -187
  32. package/dist/src/cli/completion.js +3 -3
  33. package/dist/src/cli/follow-view.js +20 -2
  34. package/dist/src/cli/help.js +2 -7
  35. package/dist/src/cli.js +19 -171
  36. package/dist/src/collections.js +11 -14
  37. package/dist/src/concurrency/lock.js +1 -1
  38. package/dist/src/config/config-keys.js +61 -20
  39. package/dist/src/config/config-request.js +11 -1
  40. package/dist/src/config/config-schema.js +1 -1
  41. package/dist/src/config/config-validation-roles.js +6 -22
  42. package/dist/src/config/config-validation.js +0 -1
  43. package/dist/src/config/config-views.js +17 -16
  44. package/dist/src/config/config-write.js +122 -79
  45. package/dist/src/config/config.js +1 -9
  46. package/dist/src/doctor/doctor-backlog-plans.js +29 -96
  47. package/dist/src/doctor/doctor-backlog.js +10 -10
  48. package/dist/src/doctor/doctor-checks.js +4 -4
  49. package/dist/src/doctor/doctor-claude-checks.js +27 -9
  50. package/dist/src/doctor/doctor-contention-checks.js +13 -5
  51. package/dist/src/doctor/doctor-fallback-checks.js +46 -24
  52. package/dist/src/doctor/doctor-holds.js +2 -2
  53. package/dist/src/doctor/doctor-orphans.js +3 -2
  54. package/dist/src/doctor/doctor-project-checks.js +21 -8
  55. package/dist/src/events/event-format.js +15 -11
  56. package/dist/src/events/event-legacy.js +2 -2
  57. package/dist/src/events/event-notify.js +4 -4
  58. package/dist/src/events/event-window.js +1 -1
  59. package/dist/src/events/events.js +8 -0
  60. package/dist/src/failure/failure-cluster.js +4 -13
  61. package/dist/src/failure/failure-data.js +4 -4
  62. package/dist/src/failure/failure-render.js +2 -2
  63. package/dist/src/failure/failure-state-change.js +50 -86
  64. package/dist/src/gates/gate-polls.js +5 -6
  65. package/dist/src/gates/startup-gates.js +2 -1
  66. package/dist/src/git/git-diff.js +6 -2
  67. package/dist/src/gui/gui-args.js +12 -0
  68. package/dist/src/gui/gui-config-commands.js +2 -64
  69. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  70. package/dist/src/gui/gui-endpoint-common.js +19 -1
  71. package/dist/src/gui/gui-operator-commands.js +21 -25
  72. package/dist/src/gui/gui-prompt-commands.js +2 -2
  73. package/dist/src/gui/gui-server.js +1 -2
  74. package/dist/src/gui/gui-team-commands.js +2 -1
  75. package/dist/src/history/history.js +4 -4
  76. package/dist/src/merge/merge-check-failures.js +12 -9
  77. package/dist/src/merge/merge-core.js +6 -31
  78. package/dist/src/merge/merge-drain.js +3 -2
  79. package/dist/src/merge/merge-post-review-drain.js +1 -1
  80. package/dist/src/merge/merge-queue-render.js +3 -3
  81. package/dist/src/merge/merge-stack.js +17 -18
  82. package/dist/src/merge/merge-to-main.js +13 -58
  83. package/dist/src/merge/merge-verify.js +4 -3
  84. package/dist/src/operator/operator-commands.js +4 -3
  85. package/dist/src/orchestrator/orchestrator-poll.js +18 -38
  86. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  87. package/dist/src/orchestrator/scheduling-scope.js +17 -26
  88. package/dist/src/orchestrator/scheduling-skip.js +9 -13
  89. package/dist/src/paths.js +2 -2
  90. package/dist/src/pi/pi-event-line.js +27 -0
  91. package/dist/src/pi/pi-listed-models.js +13 -0
  92. package/dist/src/pi/pi-stream.js +9 -2
  93. package/dist/src/pi/pi-watchdogs.js +31 -10
  94. package/dist/src/process/process-group.js +2 -21
  95. package/dist/src/process/process.js +12 -4
  96. package/dist/src/process/run-marker.js +2 -2
  97. package/dist/src/prompt/gate-prompts.js +1 -44
  98. package/dist/src/redeploy/redeployer.js +21 -1
  99. package/dist/src/review/diff-rules.js +35 -21
  100. package/dist/src/review/post-merge-review.js +47 -40
  101. package/dist/src/review/review-precheck.js +3 -3
  102. package/dist/src/review/review.js +13 -22
  103. package/dist/src/roles/legacy-role-migration.js +6 -3
  104. package/dist/src/roles/role-render.js +6 -32
  105. package/dist/src/roles/role-view.js +2 -13
  106. package/dist/src/run/ci-parity.js +19 -8
  107. package/dist/src/run/run-apply.js +0 -9
  108. package/dist/src/run/run-stage.js +6 -6
  109. package/dist/src/run/run-verdict.js +13 -4
  110. package/dist/src/run/stage-check.js +17 -24
  111. package/dist/src/runner/runner-leftover.js +9 -26
  112. package/dist/src/runner/runner-outcome.js +1 -1
  113. package/dist/src/runner/runner-pi.js +6 -12
  114. package/dist/src/runner/runner-recovery.js +4 -12
  115. package/dist/src/runner/runner-revision.js +1 -2
  116. package/dist/src/runner/runner-run.js +7 -16
  117. package/dist/src/runner/runner-state-load.js +6 -19
  118. package/dist/src/runner/runner-state.js +0 -14
  119. package/dist/src/scheduling/backoff.js +8 -3
  120. package/dist/src/scheduling/claim-entries.js +2 -1
  121. package/dist/src/status/status-budget.js +4 -43
  122. package/dist/src/status/status-data.js +34 -38
  123. package/dist/src/status/status-roles.js +1 -2
  124. package/dist/src/team/team-hold.js +7 -5
  125. package/dist/src/team/team-orchestrator-info.js +53 -21
  126. package/dist/src/team/team-polls.js +36 -86
  127. package/dist/src/text/datetime.js +16 -7
  128. package/dist/src/text/phrases.js +9 -0
  129. package/dist/src/text/suggest.js +1 -1
  130. package/dist/src/text/text.js +3 -2
  131. package/dist/src/ui/alert-feeds.js +0 -41
  132. package/dist/src/ui/badges.js +14 -75
  133. package/dist/src/ui/gui/gui-client-boot.js +0 -6
  134. package/dist/src/ui/gui/gui-client-drawer-config.js +1 -16
  135. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  136. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  137. package/dist/src/ui/gui/gui-client-team.js +8 -2
  138. package/dist/src/ui/progress-data.js +54 -4
  139. package/dist/src/ui/run-progress-model.js +12 -3
  140. package/dist/src/ui/status-payload.js +10 -25
  141. package/dist/src/ui/status-render.js +10 -17
  142. package/dist/src/ui/team-alert-backlog.js +15 -205
  143. package/dist/src/ui/team-alert-shared.js +6 -29
  144. package/dist/src/ui/team-alerts.js +4 -10
  145. package/dist/src/ui/tui/tui-backlog.js +2 -2
  146. package/dist/src/ui/tui/tui-editors.js +5 -4
  147. package/dist/src/ui/tui/tui-input.js +8 -6
  148. package/dist/src/ui/tui/tui-pane-activity.js +2 -1
  149. package/dist/src/ui/tui/tui-pane-health.js +21 -23
  150. package/dist/src/ui/tui/tui-pane-history.js +7 -5
  151. package/dist/src/ui/tui/tui-pane-pending.js +19 -15
  152. package/dist/src/ui/tui/tui-pane-session.js +4 -3
  153. package/dist/src/ui/tui/tui-panes.js +9 -1
  154. package/dist/src/ui/tui/tui.js +3 -5
  155. package/dist/src/verdict/fix-claim.js +36 -7
  156. package/dist/src/verdict/reply-contract.js +3 -4
  157. package/package.json +2 -2
  158. package/dist/src/review/review-followup.js +0 -87
  159. package/dist/src/review/review-run.js +0 -395
  160. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  161. /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
  162. /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
  163. /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
  164. /package/dist/src/cli/{question-commands.js → cli-question-commands.js} +0 -0
@@ -392,6 +392,106 @@ export function setRoleModelFields(root, base, fields) {
392
392
  return { ok: true, unchanged: true };
393
393
  return result;
394
394
  }
395
+ /** Unset the top-level key `parsed.key` from the raw file, reverting it to its default or to
396
+ * absence (a whole-config write). Absent from the file (only a default carries it): nothing to
397
+ * remove — return the config unchanged so writeConfigMutation skips the write. */
398
+ function unsetTopKey(root, cfg, raw, parsed) {
399
+ // Absent from the file (only a default carries it): nothing to remove.
400
+ if (!Object.hasOwn(raw, parsed.key))
401
+ return { cfg, oldValue: undefined };
402
+ const oldValue = raw[parsed.key];
403
+ const record = cfg;
404
+ const next = without(record, parsed.key);
405
+ // An unrelated unset is a whole-config write too: keep the file's own roles section.
406
+ if (parsed.key !== "roles")
407
+ preserveFileRoles(root, next);
408
+ return { cfg: next, oldValue };
409
+ }
410
+ /** Unset ONE dotted-map entry (`maxDailyCostUsdPerRole.<role>` / `quietHoursPerRole.<role>`),
411
+ * preserving its siblings; the `parsed.map` section is dropped when it empties. An absent file
412
+ * entry is a no-op success that leaves the config unchanged. */
413
+ function unsetMapEntry(root, cfg, raw, parsed) {
414
+ const rawMap = objectOrEmpty(raw[parsed.map]);
415
+ if (!Object.hasOwn(rawMap, parsed.role))
416
+ return { cfg, oldValue: undefined }; // absent file entry: a no-op success
417
+ const oldValue = rawMap[parsed.role];
418
+ const record = cfg;
419
+ const existing = record[parsed.map] ?? {};
420
+ const next = without(existing, parsed.role);
421
+ return { cfg: withSectionOrDropped(root, cfg, parsed.map, next), oldValue };
422
+ }
423
+ /** Unset one `model.<tier>`; a string `model` resolves to { default } (modelTierMap), so unsetting
424
+ * a tier the FILE does not hold is a no-op that leaves the string untouched. The `model` section
425
+ * is dropped when it empties. */
426
+ function unsetModelTier(root, cfg, raw, parsed) {
427
+ // A string `model` resolves to { default } (modelTierMap); unsetting a tier the FILE
428
+ // does not hold is a no-op that leaves the string untouched.
429
+ const rawTiers = modelTierMap(raw.model);
430
+ if (!Object.hasOwn(rawTiers, parsed.tier))
431
+ return { cfg, oldValue: undefined };
432
+ const oldValue = rawTiers[parsed.tier];
433
+ const existing = modelTierMap(cfg.model);
434
+ const next = without(existing, parsed.tier);
435
+ return { cfg: withSectionOrDropped(root, cfg, "model", next), oldValue };
436
+ }
437
+ /** Unset ONE dotted section field (`backoff.<field>` / `bootstrap.<field>` …), preserving its
438
+ * siblings; the section is dropped when it empties. Presence is judged against the FILE: a field
439
+ * only the default carries is a no-op success. */
440
+ function unsetSectionField(root, cfg, raw, parsed) {
441
+ // Presence is judged against the FILE: a field only the default carries is a no-op
442
+ // success, and an emptied section leaves the file.
443
+ const rawSection = objectOrEmpty(raw[parsed.section]);
444
+ if (!Object.hasOwn(rawSection, parsed.field))
445
+ return { cfg, oldValue: undefined };
446
+ const oldValue = rawSection[parsed.field];
447
+ const record = cfg;
448
+ const existing = record[parsed.section] ?? {};
449
+ return { cfg: withSectionOrDropped(root, cfg, parsed.section, without(existing, parsed.field)), oldValue };
450
+ }
451
+ /** Unset ONE `backends.<id>` field (`bin`/`args`/`configDir`); the entry and the `backends`
452
+ * section are dropped when they empty, so the file never keeps an empty object. Presence is
453
+ * judged against the FILE, so a field only the default (or the legacy alias's resolved view)
454
+ * carries is a no-op success. */
455
+ function unsetBackendField(root, cfg, raw, parsed) {
456
+ // Presence is judged against the FILE, so unsetting a field only the default (or the
457
+ // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
458
+ // section are dropped when they empty, so the file never keeps an empty object.
459
+ const rawBackends = objectOrEmpty(raw.backends);
460
+ const rawEntry = objectOrEmpty(rawBackends[parsed.id]);
461
+ if (!Object.hasOwn(rawEntry, parsed.field))
462
+ return { cfg, oldValue: undefined }; // absent file field: a no-op success
463
+ const oldValue = rawEntry[parsed.field];
464
+ const record = cfg;
465
+ const backends = { ...record.backends };
466
+ const entry = { ...(backends[parsed.id] ?? {}) };
467
+ delete entry[parsed.field];
468
+ if (Object.keys(entry).length === 0)
469
+ delete backends[parsed.id];
470
+ else
471
+ backends[parsed.id] = entry;
472
+ return { cfg: withSectionOrDropped(root, cfg, "backends", backends), oldValue };
473
+ }
474
+ /** Unset ONE `roles.<id>` field, based on the FILE's own roles — not the loaded (catalog-merged)
475
+ * map — so the write must not re-expand the operator's partial section to every built-in. The
476
+ * entry is dropped when it empties, and the whole `roles` section when it was the last one. */
477
+ function unsetRoleField(cfg, raw, parsed) {
478
+ const rawRoles = objectOrEmpty(raw.roles);
479
+ const rawEntry = objectOrEmpty(rawRoles[parsed.id]);
480
+ if (!Object.hasOwn(rawEntry, parsed.field))
481
+ return { cfg, oldValue: undefined }; // absent file field: a no-op success
482
+ const oldValue = rawEntry[parsed.field];
483
+ // Base the removal on the FILE's own roles, not the loaded (catalog-merged) map: the
484
+ // write must not re-expand the operator's partial section to every built-in.
485
+ const record = cfg;
486
+ const nextEntry = without(rawEntry, parsed.field); // the file's own entry (its field was present, checked above)
487
+ if (Object.keys(nextEntry).length > 0)
488
+ return { cfg: { ...cfg, roles: { ...rawRoles, [parsed.id]: nextEntry } }, oldValue };
489
+ // The entry emptied: drop it, and the whole `roles` section when it was the last one.
490
+ const nextRoles = without(rawRoles, parsed.id);
491
+ if (Object.keys(nextRoles).length === 0)
492
+ return { cfg: without(record, "roles"), oldValue };
493
+ return { cfg: { ...cfg, roles: nextRoles }, oldValue };
494
+ }
395
495
  /** Remove ONE value of tumwater.json from its raw CLI text (`tumwater config unset <key>`):
396
496
  * the inverse of setConfigKey. A bare top-level key is deleted (reverting to its default or
397
497
  * to absence); a dotted key (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`,
@@ -427,86 +527,29 @@ export function unsetConfigKey(root, key) {
427
527
  let oldValue;
428
528
  const result = writeConfigMutation(root, (cfg) => {
429
529
  const raw = rawConfigObject(root); // the file's own keys, before defaults fill them in
430
- const record = cfg;
431
- if (parsed.kind === "top") {
432
- // Absent from the file (only a default carries it): nothing to remove.
433
- if (!Object.hasOwn(raw, parsed.key))
434
- return cfg;
435
- oldValue = raw[parsed.key];
436
- const next = without(record, parsed.key);
437
- // An unrelated unset is a whole-config write too: keep the file's own roles section.
438
- if (parsed.key !== "roles")
439
- preserveFileRoles(root, next);
440
- return next;
441
- }
442
- if (parsed.kind === "map") {
443
- const rawMapCandidate = raw[parsed.map];
444
- const rawMap = objectOrEmpty(rawMapCandidate);
445
- if (!Object.hasOwn(rawMap, parsed.role))
446
- return cfg; // absent file entry: a no-op success
447
- oldValue = rawMap[parsed.role];
448
- const existing = record[parsed.map] ?? {};
449
- const next = without(existing, parsed.role);
450
- return withSectionOrDropped(root, cfg, parsed.map, next);
530
+ let moved;
531
+ switch (parsed.kind) {
532
+ case "top":
533
+ moved = unsetTopKey(root, cfg, raw, parsed);
534
+ break;
535
+ case "map":
536
+ moved = unsetMapEntry(root, cfg, raw, parsed);
537
+ break;
538
+ case "tier":
539
+ moved = unsetModelTier(root, cfg, raw, parsed);
540
+ break;
541
+ case "section":
542
+ moved = unsetSectionField(root, cfg, raw, parsed);
543
+ break;
544
+ case "agent":
545
+ moved = unsetBackendField(root, cfg, raw, parsed);
546
+ break;
547
+ case "role":
548
+ moved = unsetRoleField(cfg, raw, parsed);
549
+ break;
451
550
  }
452
- if (parsed.kind === "tier") {
453
- // A string `model` resolves to { default } (modelTierMap); unsetting a tier the FILE
454
- // does not hold is a no-op that leaves the string untouched.
455
- const rawTiers = modelTierMap(raw.model);
456
- if (!Object.hasOwn(rawTiers, parsed.tier))
457
- return cfg;
458
- oldValue = rawTiers[parsed.tier];
459
- const existing = modelTierMap(record.model);
460
- const next = without(existing, parsed.tier);
461
- return withSectionOrDropped(root, cfg, "model", next);
462
- }
463
- if (parsed.kind === "section") {
464
- // Presence is judged against the FILE: a field only the default carries is a no-op
465
- // success, and an emptied section leaves the file.
466
- const rawSection = objectOrEmpty(raw[parsed.section]);
467
- if (!Object.hasOwn(rawSection, parsed.field))
468
- return cfg;
469
- oldValue = rawSection[parsed.field];
470
- const existing = record[parsed.section] ?? {};
471
- return withSectionOrDropped(root, cfg, parsed.section, without(existing, parsed.field));
472
- }
473
- if (parsed.kind === "agent") {
474
- // Presence is judged against the FILE, so unsetting a field only the default (or the
475
- // legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
476
- // section are dropped when they empty, so the file never keeps an empty object.
477
- const rawBackends = objectOrEmpty(raw.backends);
478
- const rawEntryCandidate = rawBackends[parsed.id];
479
- const rawEntry = objectOrEmpty(rawEntryCandidate);
480
- if (!Object.hasOwn(rawEntry, parsed.field))
481
- return cfg; // absent file field: a no-op success
482
- oldValue = rawEntry[parsed.field];
483
- const backends = { ...record.backends };
484
- const entry = { ...(backends[parsed.id] ?? {}) };
485
- delete entry[parsed.field];
486
- if (Object.keys(entry).length === 0)
487
- delete backends[parsed.id];
488
- else
489
- backends[parsed.id] = entry;
490
- return withSectionOrDropped(root, cfg, "backends", backends);
491
- }
492
- const rawRolesCandidate = raw.roles;
493
- const rawRoles = objectOrEmpty(rawRolesCandidate);
494
- const rawEntryCandidate = rawRoles[parsed.id];
495
- const rawEntry = objectOrEmpty(rawEntryCandidate);
496
- if (!Object.hasOwn(rawEntry, parsed.field))
497
- return cfg; // absent file field: a no-op success
498
- oldValue = rawEntry[parsed.field];
499
- // Base the removal on the FILE's own roles, not the loaded (catalog-merged) map: the
500
- // write must not re-expand the operator's partial section to every built-in.
501
- const existing = rawEntry; // the file's own entry (its field was present, checked above)
502
- const nextEntry = without(existing, parsed.field);
503
- if (Object.keys(nextEntry).length > 0)
504
- return { ...cfg, roles: { ...rawRoles, [parsed.id]: nextEntry } };
505
- // The entry emptied: drop it, and the whole `roles` section when it was the last one.
506
- const nextRoles = without(rawRoles, parsed.id);
507
- if (Object.keys(nextRoles).length === 0)
508
- return without(record, "roles");
509
- return { ...cfg, roles: nextRoles };
551
+ oldValue = moved.oldValue;
552
+ return moved.cfg;
510
553
  });
511
554
  if (!result.ok)
512
555
  return result;
@@ -143,15 +143,7 @@ export function defaultConfig() {
143
143
  // explicit user instruction from being discarded by a rejected review — has nothing left
144
144
  // to exempt. Removing it also closes the path where a director run could merge a mixed
145
145
  // doc-plus-config diff unreviewed.
146
- // Reviewer as a regular role (part 1/4): the high-volume, low-blast-radius roles — bugfix,
147
- // readme, organize, plus clean, dedupe, coverage — are reviewed AFTER they merge, on their
148
- // merged commit (src/review/post-merge-review.ts), instead of holding the merge queue for a
149
- // reviewer run on their (usually trivial) diff; a rejection keeps the commit on main
150
- // with the reviewer's reasons. feature, director, perf, improve and custom roles stay
151
- // pre-merge. A
152
- // config that omits `review.afterMerge` inherits this, and `"afterMerge": []` opts out (back
153
- // to reviewing before merging).
154
- review: { enabled: true, exemptPaths: ["*.md", "docs/**"], afterMerge: ["bugfix", "clean", "coverage", "dedupe", "organize", "readme"] },
146
+ review: { enabled: true, exemptPaths: ["*.md", "docs/**"] },
155
147
  customRoles: [],
156
148
  roles,
157
149
  };
@@ -7,11 +7,10 @@ import { truncate } from "../text/text.js";
7
7
  import { donePlans, fixedBugEntries, openBugEntries, plannedPlanEntries } from "../backlog/backlog.js";
8
8
  import { entryHold, entryKindLabel, isPlanStalled } from "../backlog/backlog-eligibility.js";
9
9
  import { planDependencyProblems } from "../backlog/backlog-parts.js";
10
- import { BACKLOG_FILES, FINISHED_ARCHIVES, validationGapTag } from "../backlog/backlog-md.js";
11
- import { statOrNull } from "../files/files.js";
10
+ import { BACKLOG_FILES, validationGapTag } from "../backlog/backlog-md.js";
12
11
  import { duplicateHeadings, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
13
12
  import { liveFinishedHistory, liveFinishedRowList } from "../backlog/backlog-archive.js";
14
- import { fixSymbols, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
13
+ import { fixSymbols, isCommitRef, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
15
14
  import { okOutcome, warnOutcome } from "./doctor-outcome.js";
16
15
  /** The doctor's PLANS.md-structure and fix-claim checks, split out of doctor-backlog.ts: the
17
16
  * checks that re-verify BUGS.md's newest Fixed records against the tree (checkFixClaims,
@@ -49,16 +48,18 @@ export function checkFixClaims(root) {
49
48
  return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
50
49
  `exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
51
50
  }
52
- /** The fix-claim rows shared by the doctor's check and the always-on alert: each newest Fixed
51
+ /** The fix-claim rows the doctor's check warns about: each newest Fixed
53
52
  * record classified through fixSymbols and unbackedSymbols — the gate's own parsing and
54
53
  * existence rules — against ONE cachedSourceHaystack built for the whole scan, keeping a record
55
54
  * only when EVERY symbol its Fix paragraph names is absent, and skipping a record that names no
56
- * symbols (pure-documentation fixes are legitimate). */
55
+ * symbols (pure-documentation fixes are legitimate). A commit id is a citation, not a symbol:
56
+ * this synchronous scan cannot ask git whether it exists (the gate does), so a record that cites
57
+ * only the commit it reviewed names nothing checkable. */
57
58
  function classifyFixClaims(records, root) {
58
59
  const problems = [];
59
60
  let haystack;
60
61
  for (const { title, body } of records) {
61
- const symbols = fixSymbols(body);
62
+ const symbols = fixSymbols(body).filter((symbol) => !isCommitRef(symbol));
62
63
  if (symbols.length === 0)
63
64
  continue;
64
65
  haystack ??= cachedSourceHaystack(root);
@@ -71,24 +72,20 @@ function classifyFixClaims(records, root) {
71
72
  /** The ONE shared fix-claim scan: the newest Fixed records (bounded to FIX_CLAIMS_CHECKED so
72
73
  * pre-convention history never warns forever) as fixedBugEntries reads them — the stat-cached
73
74
  * section reader BUGS.md + BUGS-FIXED.md, bodies kept whole — classified through
74
- * classifyFixClaims. Reading through this same reader (and not a one-off fixedEntriesAt walk) is
75
- * what keeps the alert and the doctor on the identical problem set; there is only one scan. */
75
+ * classifyFixClaims. */
76
76
  function fixClaimRecords(root) {
77
77
  return fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED);
78
78
  }
79
- /** The problems the ONE shared fix-claim scan found among the newest Fixed records at `root`
79
+ /** The problems the ONE fix-claim scan found among the newest Fixed records at `root`
80
80
  * (empty when BUGS.md is unreadable or every newest Fixed record names at least one live
81
- * symbol). Feeds the always-on amber alert through the stat-cached fixedBugEntries reader and
82
- * cachedSourceHaystack, so it stays in step with the doctor and adds no re-read to the 1 Hz TUI
83
- * and status-poll path. */
81
+ * symbol). */
84
82
  export function fixClaimProblems(root) {
85
83
  return classifyFixClaims(fixClaimRecords(root), root);
86
84
  }
87
- /** The validation-gap rows shared by the doctor's check and the always-on alert: each newest
85
+ /** The validation-gap rows the doctor's check warns about: each newest
88
86
  * Fixed record classified against the closed `**Validation gap:**` vocabulary through
89
87
  * validationGapTag, which is fence-aware — suspects (missing/unrecognized) first, then
90
- * `unpinned`, so the first row is the one the doctor's message names and the alert's reason
91
- * picks. */
88
+ * `unpinned`, so the first row is the one the doctor's message names. */
92
89
  function classifyValidationGaps(records) {
93
90
  const suspects = [];
94
91
  const unpinned = [];
@@ -103,22 +100,13 @@ function classifyValidationGaps(records) {
103
100
  }
104
101
  return [...suspects, ...unpinned];
105
102
  }
106
- /** The ONE scan the doctor's `validation gaps` check and the always-on alert both hand their
107
- * rows from: the newest Fixed records (bounded to FIX_CLAIMS_CHECKED so pre-convention history
108
- * never warns forever) as fixedBugEntries reads them — the stat-cached section reader BUGS.md +
109
- * BUGS-FIXED.md, bodies kept whole — classified through classifyValidationGaps. Reading both
110
- * through this same reader (and not the doctor's one-off fixedEntriesAt/fixedSection walk) is
111
- * what keeps the alert and the doctor on the identical problem set; there is only one scan. */
103
+ /** The ONE scan the doctor's `validation gaps` check hands its rows from: the newest Fixed
104
+ * records (bounded to FIX_CLAIMS_CHECKED so pre-convention history never warns forever) as
105
+ * fixedBugEntries reads them — the stat-cached section reader BUGS.md + BUGS-FIXED.md, bodies
106
+ * kept whole — classified through classifyValidationGaps. */
112
107
  function validationGapRecords(root) {
113
108
  return fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED);
114
109
  }
115
- /** The problems the ONE shared validation-gap scan found among the newest Fixed records at `root`
116
- * (empty when BUGS.md/BUGS-FIXED.md are unreadable or every newest Fixed record carries a known
117
- * tag/trace). Feeds the always-on amber alert through the stat-cached fixedBugEntries reader, so
118
- * it stays in step with the doctor and adds no re-read to the 1 Hz TUI and status-poll path. */
119
- export function validationGapProblems(root) {
120
- return classifyValidationGaps(validationGapRecords(root));
121
- }
122
110
  /** The fixed-record validation-gap twin of checkFixClaims, standing in for plans/repair-traces.md's
123
111
  * invariant 2 ("the vocabulary is closed and lives in one place"): the bugfix guidance tells every
124
112
  * run to write exactly one `**Validation gap:**` line with a known tag, and `none` rather than no
@@ -136,8 +124,7 @@ export function validationGapProblems(root) {
136
124
  export function checkValidationGaps(root) {
137
125
  // The doctor's check reads BUGS.md itself so it can return its no-file outcome (a missing
138
126
  // BUGS.md — nothing to verify); the classification then runs on the SAME fixedBugEntries
139
- // reader the always-on alert feeds from, so the two cannot drift on the problem set. There
140
- // is only one scan (validationGapRecords).
127
+ // reader, so the problem set cannot drift. There is only one scan (validationGapRecords).
141
128
  const doc = readBacklogChecked(root, "BUGS.md");
142
129
  if (typeof doc !== "string")
143
130
  return doc;
@@ -202,22 +189,17 @@ export function checkLiveFinished(root) {
202
189
  return warnOutcome(`${liveFinishedRowList(live)} — every run that reads the file pays for them; ` +
203
190
  "`tumwater archive` moves them to the archive files and commits them (a starting team does too)");
204
191
  }
205
- /** The always-on `backlog-headings` alert polls this on every observer surface and every
206
- * observer tick, so the per-file read must be stat-cached: a steady-state poll pays one stat
207
- * per file, not a full re-read of every BACKLOG_FILES member — the archives grow without bound
208
- * (PLANS-DONE.md and BUGS-FIXED.md run to MBs), and an uncached read on every poll would copy
209
- * them wholesale each time, unlike the stat-cached section readers the rest of the poll path
210
- * uses. cachedByStat serves an unchanged file from cache (dev/ino/mtime/size) and re-reads on
211
- * a change, so this shares the poll path's freshness contract. The one loop over BACKLOG_FILES
212
- * that feeds both checkBacklogHeadings and the always-on `backlog-headings` team alert: absent
192
+ /** The one loop over BACKLOG_FILES the doctor's `backlog headings` check scans for duplicated
193
+ * `## ` titles. The per-file read is stat-cached (backlogHeadingFileCache) so repeated checks
194
+ * pay one stat per unchanged file rather than re-reading the whole BACKLOG_FILES member — the
195
+ * archives grow without bound (PLANS-DONE.md and BUGS-FIXED.md run to MBs) — and cachedByStat
196
+ * serves an unchanged file from cache (dev/ino/mtime/size) and re-reads on a change. Absent
213
197
  * files (an early repo) contribute nothing, an existing file that cannot be read adds
214
198
  * `${file} (unreadable)` to `rows` (its duplicate headings, if any, are invisible to every
215
- * reader), and each duplicate `## ` title joins both `duplicates` and, as
216
- * `${file} "## ${title}"`, `rows` — which therefore keeps BACKLOG_FILES order, the order the
217
- * doctor's `backlog headings` row lists its messages in. Two public functions share this so
218
- * they cannot drift on the file set, the unreadable clause, or the headings. */
199
+ * reader), and each duplicate `## ` title appears as `${file} "## ${title}"` in
200
+ * `rows` — which therefore keeps BACKLOG_FILES order, the order the doctor's `backlog
201
+ * headings` row lists its messages in. */
219
202
  function scanBacklogHeadings(root) {
220
- const duplicates = [];
221
203
  const rows = [];
222
204
  for (const file of BACKLOG_FILES) {
223
205
  const p = path.join(root, file);
@@ -234,63 +216,14 @@ function scanBacklogHeadings(root) {
234
216
  continue;
235
217
  }
236
218
  for (const heading of scan.headings) {
237
- duplicates.push({ file, heading });
238
219
  rows.push(`${file} "## ${heading}"`);
239
220
  }
240
221
  }
241
- return { duplicates, rows };
222
+ return rows;
242
223
  }
243
- /** The module-level stat cache backing scanBacklogHeadings, shared by the doctor row and the
244
- * always-on alert so the poll path never re-reads an unchanged backlog or archive file. */
224
+ /** The module-level stat cache backing scanBacklogHeadings, so a repeated check never re-reads
225
+ * an unchanged backlog or archive file. */
245
226
  const backlogHeadingFileCache = new Map();
246
- /** The duplicated `## ` headings already on main, exported so the always-on `backlog-headings`
247
- * team alert (src/ui/team-alert-backlog.ts) renders the same set the doctor's `backlog
248
- * headings` row warns about. */
249
- export function duplicatedBacklogHeadings(root) {
250
- return scanBacklogHeadings(root).duplicates;
251
- }
252
- /** The module-level stat cache backing liveFinishedEntriesFor, keyed by root, so a steady-state
253
- * status poll pays four stat calls (two live files, two archives) instead of re-parsing the
254
- * live files and the unbounded archives (PLANS-DONE.md runs to MBs) on every poll. Keyed by a
255
- * combined stat signature rather than the shared cachedByStat because each row spans FOUR files
256
- * (a live file, its finished entries, and the archive it empties into), so no single-file
257
- * stat-keyed cache entry can serve it. */
258
- const liveFinishedCache = new Map();
259
- /** The finished-history rows already on main, exported so the always-on `backlog-archive` team
260
- * alert (src/ui/team-alert-backlog.ts) renders the same set the doctor's `backlog archive`
261
- * row warns about — stat-gated so a steady-state poll pays four stats, not a full re-parse.
262
- * The cache key is the stat identity (`dev:ino:mtimeMs:size`, `absent` when the stat is
263
- * null) of every
264
- * FINISHED_ARCHIVES live file and archive under `root`, so any write that changes finished
265
- * history or the archive refreshes the rows on the next call without a process restart. Returns
266
- * a copy on a hit; on a miss it recomputes via liveFinishedHistory, and returns `[]` (uncached)
267
- * when that throws, so an unreadable file stays checkLiveFinished's loud signal and never
268
- * breaks a status poll. checkLiveFinished keeps calling liveFinishedHistory directly and is
269
- * unchanged. */
270
- export function liveFinishedEntriesFor(root) {
271
- const key = FINISHED_ARCHIVES
272
- .flatMap((spec) => [path.join(root, spec.file), path.join(root, spec.archive)])
273
- .map((p) => {
274
- const st = statOrNull(p);
275
- return st ? `${st.dev}:${st.ino}:${st.mtimeMs}:${st.size}` : "absent";
276
- })
277
- .join("|");
278
- const cached = liveFinishedCache.get(root);
279
- if (cached?.key === key)
280
- return cached.rows.map((r) => ({ ...r }));
281
- let rows;
282
- try {
283
- rows = liveFinishedHistory(root);
284
- }
285
- catch {
286
- liveFinishedCache.delete(root); // Never serve a stale read for an unreadable tree.
287
- return []; // Unreadable — checkLiveFinished's signal; a status poll must not throw.
288
- }
289
- if (liveFinishedCache.size >= 64)
290
- liveFinishedCache.clear(); // Safety cap, like backlogHeadingFileCache.
291
- liveFinishedCache.set(root, { key, rows });
292
- return rows.map((r) => ({ ...r }));
293
- }
294
227
  /** Duplicated `## ` headings already on main (the duplicate-heading half of the backlog-structure
295
228
  * check, src/backlog/backlog-structure.ts): a `## Done` a past merge or conflict resolution added
296
229
  * twice leaves every later entry after the first section invisible to the section readers, and
@@ -301,7 +234,7 @@ export function liveFinishedEntriesFor(root) {
301
234
  * that leaves an existing duplicate alone passes and the duplicate itself needs a deliberate
302
235
  * removal edit. */
303
236
  export function checkBacklogHeadings(root) {
304
- const { rows } = scanBacklogHeadings(root);
237
+ const rows = scanBacklogHeadings(root);
305
238
  if (rows.length === 0)
306
239
  return okOutcome("no duplicated ## section headings in the backlog files");
307
240
  const [first, ...rest] = rows;
@@ -4,11 +4,11 @@ import path from "node:path";
4
4
  import { errorMessage, truncate } from "../text/text.js";
5
5
  import { shortSha } from "../text/format.js";
6
6
  import { readPostReviews, POST_REVIEW_FAILURE_LIMIT } from "../review/post-merge-review.js";
7
- import { humanSeconds, secondsSince, HOUR_MS, DAY_MS } from "../text/datetime.js";
7
+ import { humanSeconds, humanSince, HOUR_MS, DAY_MS } from "../text/datetime.js";
8
8
  import { eventsSince } from "../events/event-window.js";
9
9
  import { finiteNumber } from "../files/json-object.js";
10
- import { lowerMedian } from "../collections.js";
11
- import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
10
+ import { distinctOrdered, lowerMedian } from "../collections.js";
11
+ import { agree, plural, trailingDayMedianPhrase } from "../text/phrases.js";
12
12
  import { queuedMerges, unreadableMergeQueueFiles } from "../merge/merge-queue.js";
13
13
  import { mergeChanges, readMergeMarker } from "../merge/merge-slot.js";
14
14
  import { listWideScope, queuedListRows } from "../inbox/inbox.js";
@@ -136,7 +136,7 @@ export function checkStaleQueuedMerges(root, now) {
136
136
  }
137
137
  return phrase ? { ...out, detail: `${out.detail} · ${phrase}` } : out;
138
138
  };
139
- const stale = staleQueuedMergeRows(root, now).map((e) => ({ ...e, age: humanSeconds(secondsSince(now - e.waitedMs, now)) }));
139
+ const stale = staleQueuedMergeRows(root, now).map((e) => ({ ...e, age: humanSince(now - e.waitedMs, now) }));
140
140
  const [first, ...rest] = stale;
141
141
  if (!first)
142
142
  return withWait(okOutcome(`no merge-queue entry has waited more than ${humanSeconds(MERGE_QUEUE_STALE_MS / 1000)} to start`));
@@ -262,7 +262,7 @@ export function checkStalePrompts(root, now) {
262
262
  if (!first)
263
263
  return okOutcome("no queued prompt has waited more than 1 day for a role to dequeue it");
264
264
  const more = andMore(rest, (p) => `${p.role} #${p.position}`);
265
- return warnOutcome(`inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${humanSeconds(secondsSince(first.queuedAtMs, now))} — ` +
265
+ return warnOutcome(`inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${humanSince(first.queuedAtMs, now)} — ` +
266
266
  "the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with " +
267
267
  `\`tumwater prompt --cancel ${first.position} --role ${first.role}\`${more}`);
268
268
  }
@@ -279,7 +279,7 @@ export function mergeWaitPhrase(events) {
279
279
  return undefined;
280
280
  const median = lowerMedian(waits);
281
281
  const longest = Math.max(...waits);
282
- return `merges in the last 24h waited ${shortSpanPhrase(median)} median, ${shortSpanPhrase(longest)} longest (${plural(waits.length, "merge")})`;
282
+ return `merges in the last 24h waited ${trailingDayMedianPhrase(median, longest, "longest", waits.length, "merge")}`;
283
283
  }
284
284
  /** Discover the tracked backlog/brief files with uncommitted edits in the primary checkout (the
285
285
  * shared discovery behind checkUncommittedBacklog and commitUncommittedBacklog). A primary off
@@ -301,7 +301,7 @@ export async function dirtyBacklogFiles(root, config) {
301
301
  // An unreadable brief is a doctor finding of its own; it must not abort the uncommitted-backlog
302
302
  // check or `--commit-backlog`, so the tracked backlog set is watched without it.
303
303
  }
304
- const watched = [...new Set([...BACKLOG_FILES, ...(brief ? [brief] : [])])];
304
+ const watched = distinctOrdered([...BACKLOG_FILES, ...(brief ? [brief] : [])]);
305
305
  const out = await gitTry(root, "status", "--porcelain", "--", ...watched);
306
306
  if (out === null)
307
307
  return { base, primary, watched, dirty: null };
@@ -359,9 +359,9 @@ export async function commitUncommittedBacklog(root, config) {
359
359
  * for a BacklogCommitReport, so the three surfaces cannot drift: one `committed <file> on
360
360
  * <base>` line per committed file, one `could not commit <file> — commit it by hand` line per
361
361
  * failed file, then the skipped sentence when set. `[]` when the report committed nothing,
362
- * failed nothing, and carried no skip sentence. `tumwater doctor --commit-backlog` (src/cli.ts),
363
- * the dashboard Health card's Commit backlog (handleCommitBacklog) and the TUI Health tab's
364
- * Ctrl+A all render from this one source. */
362
+ * failed nothing, and carried no skip sentence. `tumwater doctor --commit-backlog`
363
+ * (src/cli/cli-doctor-command.ts), the dashboard Health card's Commit backlog
364
+ * (handleCommitBacklog) and the TUI Health tab's Ctrl+A all render from this one source. */
365
365
  export function backlogCommitLines(report) {
366
366
  const lines = [
367
367
  ...report.committed.map((file) => `committed ${file} on ${report.base}`),
@@ -18,7 +18,7 @@ import { formatBytes } from "../text/format.js";
18
18
  import { BRIEF_TEXT_MAX_CHARS, briefFile, promptBlockOf, readBrief } from "../brief/brief.js";
19
19
  import { plural } from "../text/phrases.js";
20
20
  import { backendsInUse, unregisteredBackendInUse } from "../gates/startup-gates.js";
21
- import { claudeSeamsInUse, minVersionOutcome, opencodeSeamsInUse, versionFromProbe, piSeamsInUse } from "./doctor-model-checks.js";
21
+ import { claudeSeamsInUse, loadConfigForCheck, minVersionOutcome, opencodeSeamsInUse, versionFromProbe, piSeamsInUse } from "./doctor-model-checks.js";
22
22
  import { claudeConfigDir } from "../agent/claude/claude-backend.js";
23
23
  import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
24
24
  import { childEnv } from "../process/process.js";
@@ -236,9 +236,9 @@ export function checkBackendBinary(root, pathEnv = envPath()) {
236
236
  * (a 1.x binary included) fails naming `brew install opencode`; at or above it reads the
237
237
  * version. Stands down when no seam routes to opencode, so a pi-only team spawns no opencode. */
238
238
  export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
239
- const { config } = loadConfigSafe(root);
240
- if (config === undefined)
241
- return noConfigOutcome();
239
+ const config = loadConfigForCheck(root);
240
+ if ("level" in config)
241
+ return config;
242
242
  if (!opencodeSeamsInUse(config))
243
243
  return okOutcome("no model jobs run on opencode");
244
244
  const resolved = opencodeBackend.resolveBin(config);
@@ -19,6 +19,21 @@ import { claudeSeamsInUse, loadConfigForCheck, versionFromProbe, seamedModels, }
19
19
  * seamPairs) come from doctor-model-checks.ts, and the AUTH_CHECK_* probe budgets
20
20
  * from doctor-probe.ts, with the shared report contract (CheckOutcome) from
21
21
  * doctor-outcome.ts, exactly as the other doctor-* modules share them. */
22
+ /** The config a claude readiness row reads, or the warn outcome a broken `tumwater.json`
23
+ * earns, or the ok stand-down when no seam routes to claude. The claude rows repeat the same
24
+ * three-line guard and the same seam stand-down (`loadConfigForCheck` + `claudeSeamsInUse`);
25
+ * folding them here mirrors codexCheckBase so a change to how doctor guards a claude check
26
+ * touches one spot instead of three. Each caller still checks the seams it needs — the auth
27
+ * rows use `claudeSeamsInUse` directly (folded here), the version row goes on to
28
+ * `seamedModels` for the floors. */
29
+ function claudeCheckBase(root) {
30
+ const config = loadConfigForCheck(root);
31
+ if ("level" in config)
32
+ return config;
33
+ if (!claudeSeamsInUse(config))
34
+ return okOutcome("no model jobs run on claude");
35
+ return config;
36
+ }
22
37
  /** One bounded probe of the resolved `claude` binary under the scrubbed run environment a real
23
38
  * run gets: resolve the binary through the claude adapter that would spawn it and run `args`
24
39
  * with a short deadline. Null when the probe could not run, the child errored, or the deadline
@@ -47,6 +62,13 @@ async function claudeAuthProbe(config, pathEnv, timeoutMs) {
47
62
  return null;
48
63
  return parseClaudeAuthStatus(result.stdout);
49
64
  }
65
+ /** The warn verdict both auth-driven claude rows return when `claude auth status` could not
66
+ * run or its reply was unreadable — the one home of the wording so checkClaudeAuth and
67
+ * checkAnthroApiKey cannot drift on the unreadable-probe message. Each caller makes its own
68
+ * solitary `claudeAuthProbe` call and branches on null through it. */
69
+ function claudeAuthUnreadable() {
70
+ return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
71
+ }
50
72
  /** claude credential readiness (plans/agent-backends.md "Every seam, checked"): a claude seam
51
73
  * whose login is gone fails every run before it can work, so doctor runs `claude auth status`
52
74
  * under the scrubbed run environment and fails when it reports `loggedIn: false`. An ok detail
@@ -55,14 +77,12 @@ async function claudeAuthProbe(config, pathEnv, timeoutMs) {
55
77
  * probe that could not run or a reply this cannot read warns rather than guesses. Stands down
56
78
  * (ok) when no seam routes to claude, so a pi-only team spawns nothing. */
57
79
  export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
58
- const config = loadConfigForCheck(root);
80
+ const config = claudeCheckBase(root);
59
81
  if ("level" in config)
60
82
  return config;
61
- if (!claudeSeamsInUse(config))
62
- return okOutcome("no model jobs run on claude");
63
83
  const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
64
84
  if (auth === null)
65
- return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
85
+ return claudeAuthUnreadable();
66
86
  if (!auth.loggedIn)
67
87
  return failOutcome("claude reports logged in: false — run `claude auth login`");
68
88
  const subscription = claudeAuthUsesSubscription(auth);
@@ -80,17 +100,15 @@ export async function checkClaudeAuth(root, pathEnv = envPath(), timeoutMs = AUT
80
100
  * an API-key login (then there is no subscription to lose). `env` is injectable so the check
81
101
  * stays hermetic; a probe that cannot run warns rather than guesses like checkClaudeAuth. */
82
102
  export async function checkAnthroApiKey(root, env = process.env, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
83
- const config = loadConfigForCheck(root);
103
+ const config = claudeCheckBase(root);
84
104
  if ("level" in config)
85
105
  return config;
86
- if (!claudeSeamsInUse(config))
87
- return okOutcome("no model jobs run on claude");
88
106
  const apiKey = env.ANTHROPIC_API_KEY;
89
107
  if (apiKey === undefined || apiKey === "")
90
108
  return okOutcome("no ANTHROPIC_API_KEY set");
91
109
  const auth = await claudeAuthProbe(config, pathEnv, timeoutMs);
92
110
  if (auth === null)
93
- return warnOutcome("could not check — `claude auth status` did not run or its output was unreadable");
111
+ return claudeAuthUnreadable();
94
112
  if (!auth.loggedIn || !claudeAuthUsesSubscription(auth))
95
113
  return okOutcome("ANTHROPIC_API_KEY is set, but claude reports no claude.ai login");
96
114
  return warnOutcome("ANTHROPIC_API_KEY is set while claude reports a claude.ai login — model jobs on claude will bill the API key, not the subscription; unset it or route every anthropic model job on pi explicitly");
@@ -143,7 +161,7 @@ function claudeVersionOutcome(found, floors, clampNote) {
143
161
  * The `off`/`minimal` thinking clamp is noted here too, since this check already has the
144
162
  * claude seams in hand. Stands down when no seam routes to claude. */
145
163
  export async function checkClaudeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
146
- const config = loadConfigForCheck(root);
164
+ const config = claudeCheckBase(root);
147
165
  if ("level" in config)
148
166
  return config;
149
167
  const seams = seamedModels(config, "claude");