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.
- package/README.md +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-run.js +2 -1
- package/dist/src/agent/claude/claude-account.js +6 -20
- package/dist/src/agent/codex/codex-account.js +5 -6
- package/dist/src/agent/codex/codex-args.js +5 -5
- package/dist/src/agent/codex/codex-rollout.js +1 -1
- package/dist/src/agent/opencode/opencode-backend.js +24 -22
- package/dist/src/agent/opencode/opencode-catalog.js +35 -9
- package/dist/src/agent/session-ledger.js +22 -26
- package/dist/src/agent/subscription-account.js +2 -83
- package/dist/src/backlog/backlog-archive.js +3 -4
- package/dist/src/backlog/backlog-eligibility.js +14 -36
- package/dist/src/backlog/backlog-question-answer.js +1 -1
- package/dist/src/backlog/backlog.js +2 -1
- package/dist/src/baseline/main-guardrails.js +2 -2
- package/dist/src/baseline/main-watch.js +84 -50
- package/dist/src/brief/brief.js +26 -13
- package/dist/src/budget/budget.js +4 -5
- package/dist/src/build/build-check-report.js +10 -0
- package/dist/src/build/build-check-scoped.js +0 -2
- package/dist/src/build/build-stage.js +6 -4
- package/dist/src/change/change-data.js +1 -4
- package/dist/src/cli/cli-completion-command.js +75 -0
- package/dist/src/cli/cli-doctor-command.js +103 -0
- package/dist/src/cli/cli-init.js +2 -2
- package/dist/src/cli/cli-query-commands.js +1 -1
- package/dist/src/cli/cli-role-command.js +2 -41
- package/dist/src/cli/cli-start-summary.js +4 -3
- package/dist/src/cli/cli-start-supervisor.js +190 -0
- package/dist/src/cli/cli-start.js +4 -187
- package/dist/src/cli/completion.js +3 -3
- package/dist/src/cli/follow-view.js +20 -2
- package/dist/src/cli/help.js +2 -7
- package/dist/src/cli.js +19 -171
- package/dist/src/collections.js +11 -14
- package/dist/src/concurrency/lock.js +1 -1
- package/dist/src/config/config-keys.js +61 -20
- package/dist/src/config/config-request.js +11 -1
- package/dist/src/config/config-schema.js +1 -1
- package/dist/src/config/config-validation-roles.js +6 -22
- package/dist/src/config/config-validation.js +0 -1
- package/dist/src/config/config-views.js +17 -16
- package/dist/src/config/config-write.js +122 -79
- package/dist/src/config/config.js +1 -9
- package/dist/src/doctor/doctor-backlog-plans.js +29 -96
- package/dist/src/doctor/doctor-backlog.js +10 -10
- package/dist/src/doctor/doctor-checks.js +4 -4
- package/dist/src/doctor/doctor-claude-checks.js +27 -9
- package/dist/src/doctor/doctor-contention-checks.js +13 -5
- package/dist/src/doctor/doctor-fallback-checks.js +46 -24
- package/dist/src/doctor/doctor-holds.js +2 -2
- package/dist/src/doctor/doctor-orphans.js +3 -2
- package/dist/src/doctor/doctor-project-checks.js +21 -8
- package/dist/src/events/event-format.js +15 -11
- package/dist/src/events/event-legacy.js +2 -2
- package/dist/src/events/event-notify.js +4 -4
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +8 -0
- package/dist/src/failure/failure-cluster.js +4 -13
- package/dist/src/failure/failure-data.js +4 -4
- package/dist/src/failure/failure-render.js +2 -2
- package/dist/src/failure/failure-state-change.js +50 -86
- package/dist/src/gates/gate-polls.js +5 -6
- package/dist/src/gates/startup-gates.js +2 -1
- package/dist/src/git/git-diff.js +6 -2
- package/dist/src/gui/gui-args.js +12 -0
- package/dist/src/gui/gui-config-commands.js +2 -64
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-endpoint-common.js +19 -1
- package/dist/src/gui/gui-operator-commands.js +21 -25
- package/dist/src/gui/gui-prompt-commands.js +2 -2
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/gui/gui-team-commands.js +2 -1
- package/dist/src/history/history.js +4 -4
- package/dist/src/merge/merge-check-failures.js +12 -9
- package/dist/src/merge/merge-core.js +6 -31
- package/dist/src/merge/merge-drain.js +3 -2
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/merge/merge-queue-render.js +3 -3
- package/dist/src/merge/merge-stack.js +17 -18
- package/dist/src/merge/merge-to-main.js +13 -58
- package/dist/src/merge/merge-verify.js +4 -3
- package/dist/src/operator/operator-commands.js +4 -3
- package/dist/src/orchestrator/orchestrator-poll.js +18 -38
- package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
- package/dist/src/orchestrator/scheduling-scope.js +17 -26
- package/dist/src/orchestrator/scheduling-skip.js +9 -13
- package/dist/src/paths.js +2 -2
- package/dist/src/pi/pi-event-line.js +27 -0
- package/dist/src/pi/pi-listed-models.js +13 -0
- package/dist/src/pi/pi-stream.js +9 -2
- package/dist/src/pi/pi-watchdogs.js +31 -10
- package/dist/src/process/process-group.js +2 -21
- package/dist/src/process/process.js +12 -4
- package/dist/src/process/run-marker.js +2 -2
- package/dist/src/prompt/gate-prompts.js +1 -44
- package/dist/src/redeploy/redeployer.js +21 -1
- package/dist/src/review/diff-rules.js +35 -21
- package/dist/src/review/post-merge-review.js +47 -40
- package/dist/src/review/review-precheck.js +3 -3
- package/dist/src/review/review.js +13 -22
- package/dist/src/roles/legacy-role-migration.js +6 -3
- package/dist/src/roles/role-render.js +6 -32
- package/dist/src/roles/role-view.js +2 -13
- package/dist/src/run/ci-parity.js +19 -8
- package/dist/src/run/run-apply.js +0 -9
- package/dist/src/run/run-stage.js +6 -6
- package/dist/src/run/run-verdict.js +13 -4
- package/dist/src/run/stage-check.js +17 -24
- package/dist/src/runner/runner-leftover.js +9 -26
- package/dist/src/runner/runner-outcome.js +1 -1
- package/dist/src/runner/runner-pi.js +6 -12
- package/dist/src/runner/runner-recovery.js +4 -12
- package/dist/src/runner/runner-revision.js +1 -2
- package/dist/src/runner/runner-run.js +7 -16
- package/dist/src/runner/runner-state-load.js +6 -19
- package/dist/src/runner/runner-state.js +0 -14
- package/dist/src/scheduling/backoff.js +8 -3
- package/dist/src/scheduling/claim-entries.js +2 -1
- package/dist/src/status/status-budget.js +4 -43
- package/dist/src/status/status-data.js +34 -38
- package/dist/src/status/status-roles.js +1 -2
- package/dist/src/team/team-hold.js +7 -5
- package/dist/src/team/team-orchestrator-info.js +53 -21
- package/dist/src/team/team-polls.js +36 -86
- package/dist/src/text/datetime.js +16 -7
- package/dist/src/text/phrases.js +9 -0
- package/dist/src/text/suggest.js +1 -1
- package/dist/src/text/text.js +3 -2
- package/dist/src/ui/alert-feeds.js +0 -41
- package/dist/src/ui/badges.js +14 -75
- package/dist/src/ui/gui/gui-client-boot.js +0 -6
- package/dist/src/ui/gui/gui-client-drawer-config.js +1 -16
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-operator.js +7 -5
- package/dist/src/ui/gui/gui-client-team.js +8 -2
- package/dist/src/ui/progress-data.js +54 -4
- package/dist/src/ui/run-progress-model.js +12 -3
- package/dist/src/ui/status-payload.js +10 -25
- package/dist/src/ui/status-render.js +10 -17
- package/dist/src/ui/team-alert-backlog.js +15 -205
- package/dist/src/ui/team-alert-shared.js +6 -29
- package/dist/src/ui/team-alerts.js +4 -10
- package/dist/src/ui/tui/tui-backlog.js +2 -2
- package/dist/src/ui/tui/tui-editors.js +5 -4
- package/dist/src/ui/tui/tui-input.js +8 -6
- package/dist/src/ui/tui/tui-pane-activity.js +2 -1
- package/dist/src/ui/tui/tui-pane-health.js +21 -23
- package/dist/src/ui/tui/tui-pane-history.js +7 -5
- package/dist/src/ui/tui/tui-pane-pending.js +19 -15
- package/dist/src/ui/tui/tui-pane-session.js +4 -3
- package/dist/src/ui/tui/tui-panes.js +9 -1
- package/dist/src/ui/tui/tui.js +3 -5
- package/dist/src/verdict/fix-claim.js +36 -7
- package/dist/src/verdict/reply-contract.js +3 -4
- package/package.json +2 -2
- package/dist/src/review/review-followup.js +0 -87
- package/dist/src/review/review-run.js +0 -395
- /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
- /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
- /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
- /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
- /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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
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
|
|
206
|
-
*
|
|
207
|
-
* per file
|
|
208
|
-
* (PLANS-DONE.md and BUGS-FIXED.md run to MBs)
|
|
209
|
-
*
|
|
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
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
222
|
+
return rows;
|
|
242
223
|
}
|
|
243
|
-
/** The module-level stat cache backing scanBacklogHeadings,
|
|
244
|
-
*
|
|
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
|
|
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,
|
|
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,
|
|
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:
|
|
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 ${
|
|
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 ${
|
|
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 =
|
|
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`
|
|
363
|
-
* the dashboard Health card's Commit backlog
|
|
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
|
|
240
|
-
if (
|
|
241
|
-
return
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
164
|
+
const config = claudeCheckBase(root);
|
|
147
165
|
if ("level" in config)
|
|
148
166
|
return config;
|
|
149
167
|
const seams = seamedModels(config, "claude");
|