tumwater 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { eventsSince } from "../events/event-window.js";
|
|
2
|
+
import { isNotifyFailure } from "../events/notify.js";
|
|
3
|
+
import { humanSeconds } from "../text/datetime.js";
|
|
4
|
+
import { plural } from "../text/phrases.js";
|
|
5
|
+
import { errorMessage, squash } from "../text/text.js";
|
|
6
|
+
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
7
|
+
/** How far back the `notify` check looks for failed pages. */
|
|
8
|
+
const NOTIFY_CHECK_WINDOW_MS = 24 * 3600_000;
|
|
9
|
+
/** The `notify` check: whether a notify command is configured, and a warning when it failed to
|
|
10
|
+
* page within the last day (a nonzero exit, a kill, or a failed start — the warnings
|
|
11
|
+
* spawnNotifyCommand logs). Warns but never fails, like the hold rows: a dead webhook must not
|
|
12
|
+
* fail a scripted pre-flight. Never throws. `now` is the seam for the window's far edge. */
|
|
13
|
+
export function checkNotify(root, config, now = Date.now()) {
|
|
14
|
+
if (!config?.notify)
|
|
15
|
+
return okOutcome("no notify command configured — pages are off");
|
|
16
|
+
try {
|
|
17
|
+
const failures = eventsSince(root, NOTIFY_CHECK_WINDOW_MS, now, isNotifyFailure);
|
|
18
|
+
if (failures.length === 0)
|
|
19
|
+
return okOutcome("configured — no failed pages in the last 24h");
|
|
20
|
+
const latest = failures[failures.length - 1];
|
|
21
|
+
const age = humanSeconds(Math.max(0, Math.round((now - latest.ts) / 1000)));
|
|
22
|
+
return warnOutcome(`${plural(failures.length, "failed page")} in the last 24h; latest ${age} ago: ${squash(String(latest.message), 200)}`);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return warnOutcome(`could not read the event log for failed pages: ${errorMessage(err)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { worktreesDir } from "../paths.js";
|
|
@@ -50,12 +51,12 @@ function argvNamesDir(command, dirs) {
|
|
|
50
51
|
return false;
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
|
-
/** True when an argv's RELATIVE worktree path (`node .tumwater/worktrees/qa/dist/src/cli.js
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
54
|
+
/** True when an argv's RELATIVE worktree path (`node .tumwater/worktrees/qa/dist/src/cli.js gui …`)
|
|
55
|
+
* is this repo's. Resolved against the process's cwd it must resolve here; one that resolves in a
|
|
56
|
+
* worktree that exists elsewhere belongs to another checkout's team and is not blamed on this one.
|
|
57
|
+
* When the resolution points nowhere — the cwd is unreadable, or is a scratch dir since deleted
|
|
58
|
+
* (the leaked qa GUI's was), or the process chdir'd after exec — the worktree's name is the
|
|
59
|
+
* evidence left: it counts only when this repo has a worktree by that name. */
|
|
59
60
|
function relativeArgvIsOurs(command, cwd, root, dirs) {
|
|
60
61
|
for (const [, rel = "", name = ""] of command.matchAll(RELATIVE_WORKTREE_ARG)) {
|
|
61
62
|
if (cwd !== undefined) {
|
|
@@ -93,7 +94,7 @@ function descendantCount(children, pid) {
|
|
|
93
94
|
* (absolute; or relative, confirmed by cwd where it can be — relativeArgvIsOurs) or when its
|
|
94
95
|
* cwd lies under it: a leaked `node dist/test/test-runner.js` names no worktree at all.
|
|
95
96
|
*
|
|
96
|
-
* PPID 1 is what keeps the live
|
|
97
|
+
* PPID 1 is what keeps the live team out: a running orchestrator's pi runs and build checks
|
|
97
98
|
* have the orchestrator as parent, and the orchestrator and its supervisor run from the repo
|
|
98
99
|
* root, never from a worktree, even when a detached `nohup` launch leaves them parentless.
|
|
99
100
|
* (macOS always reparents an orphan to launchd, PID 1; on Linux a process under a child
|
|
@@ -104,7 +105,7 @@ function descendantCount(children, pid) {
|
|
|
104
105
|
* 2/2): every process a run starts carries `TUMWATER_RUN=<harness pid>-<nonce>` in its
|
|
105
106
|
* environment (appended to any inherited mark), wherever its cwd is — exactly where the
|
|
106
107
|
* prompts send agents (scratch dirs under the system temp). A parentless process whose
|
|
107
|
-
* every mark names a harness pid that is no longer alive is this
|
|
108
|
+
* every mark names a harness pid that is no longer alive is this team's orphan: nothing
|
|
108
109
|
* will reap it, because the run sweep that would have dies with its harness. A mark naming
|
|
109
110
|
* ANY live harness is not reported — that harness's own sweep kills everything carrying the
|
|
110
111
|
* mark when its run folds. A pid that died and was recycled by an unrelated process reads
|
|
@@ -198,7 +199,7 @@ export async function checkOrphans(root, probe = systemProcessProbe) {
|
|
|
198
199
|
// A mark-caught orphan says which run marked it: the operator killing it wants to know
|
|
199
200
|
// nothing else will, and the dead harness's pid is the evidence argv and cwd lacked.
|
|
200
201
|
const mark = (marks.get(r.pid) ?? []).find((m) => harnessGone(m) === true);
|
|
201
|
-
const marked = mark !== undefined ? ` (run ${mark};
|
|
202
|
+
const marked = mark !== undefined ? ` (run ${mark}; tumwater ${mark.split("-")[0]} exited)` : "";
|
|
202
203
|
return `pid ${r.pid} (age ${r.etime}, cpu ${r.time}${tree}) ${truncate(command, ORPHAN_COMMAND_MAX)}${marked}`;
|
|
203
204
|
});
|
|
204
205
|
const more = orphans.length - listed.length;
|
|
@@ -1,13 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { plural } from "../text/phrases.js";
|
|
2
|
+
/** One line per check (level, name, detail), the name column as wide as the longest name, at
|
|
3
|
+
* least 12. */
|
|
4
|
+
function checkLines(checks) {
|
|
5
|
+
const nameWidth = checks.reduce((w, c) => Math.max(w, c.name.length), 12);
|
|
6
|
+
return checks.map((c) => `${c.level.padEnd(5)} ${c.name.padEnd(nameWidth)} ${c.detail}`);
|
|
7
|
+
}
|
|
8
|
+
/** Render the report: a header line, one line per check (level, name, detail), the routing
|
|
9
|
+
* table (each seam's label, its agent-qualified selector, and its backend), and the verdict.
|
|
10
|
+
* The name column is one width for every row — 12 columns by default, widened to the longest
|
|
11
|
+
* name in this report ("backlog headings" is 16) so no row's detail starts a few columns early
|
|
12
|
+
* — which keeps a report of short-named checks byte-identical to the fixed-width output.
|
|
13
|
+
* Warnings never affect the exit code — only fails do. */
|
|
6
14
|
export function renderDoctor(report) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
lines.push(
|
|
15
|
+
const lines = [report.header, ...checkLines(report.checks)];
|
|
16
|
+
if (report.routing.length > 0) {
|
|
17
|
+
const width = report.routing.reduce((w, s) => Math.max(w, s.label.length), "job".length);
|
|
18
|
+
lines.push("routing: job · model · backend");
|
|
19
|
+
for (const s of report.routing)
|
|
20
|
+
lines.push(` ${s.label.padEnd(width)} ${s.selector} (${s.backend})`);
|
|
21
|
+
}
|
|
22
|
+
lines.push(report.verdict);
|
|
23
|
+
return lines.join("\n");
|
|
24
|
+
}
|
|
25
|
+
/** The report with only its warn and fail checks; header, routing, and verdict stay. */
|
|
26
|
+
export function problemsOnly(report) {
|
|
27
|
+
return { ...report, checks: report.checks.filter((c) => c.level !== "ok") };
|
|
28
|
+
}
|
|
29
|
+
/** `--problems`: the header, the warn and fail rows (name column sized over those rows alone), a
|
|
30
|
+
* line counting the hidden ok rows (left out when none), and the verdict — never the routing
|
|
31
|
+
* table. Takes the full report so it can count what it hides. */
|
|
32
|
+
export function renderDoctorProblems(report) {
|
|
33
|
+
const shown = problemsOnly(report).checks;
|
|
34
|
+
const hidden = report.checks.length - shown.length;
|
|
35
|
+
const lines = [report.header, ...checkLines(shown)];
|
|
36
|
+
if (hidden > 0)
|
|
37
|
+
lines.push(`${plural(hidden, "ok check")} hidden — run tumwater doctor for the full report`);
|
|
11
38
|
lines.push(report.verdict);
|
|
12
39
|
return lines.join("\n");
|
|
13
40
|
}
|
|
@@ -1,44 +1,67 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
import { loadConfigSafe } from "../config/config.js";
|
|
2
3
|
import { envPath } from "../files/files.js";
|
|
3
|
-
import { orchestratorAlive, readOrchestratorInfo } from "../
|
|
4
|
+
import { orchestratorAlive, readOrchestratorInfo } from "../team/orchestrator-info.js";
|
|
4
5
|
import { systemProcessProbe } from "../process/process-table.js";
|
|
5
6
|
import { checkOrphans } from "./doctor-orphans.js";
|
|
6
|
-
import { checkBudget, checkMaintenanceQuota, checkPause, checkQuietHours } from "./doctor-holds.js";
|
|
7
|
+
import { checkBudget, checkClaudeWindow, checkErrorStreaks, checkMainRed, checkMaintenanceQuota, checkPause, checkProviderHolds, checkQuietHours } from "./doctor-holds.js";
|
|
8
|
+
import { checkRootInstall } from "./doctor-install.js";
|
|
9
|
+
import { checkNotify } from "./doctor-notify.js";
|
|
7
10
|
import { checkLaunchServicesPorts } from "./doctor-launch-services.js";
|
|
8
11
|
import { plural } from "../text/phrases.js";
|
|
9
12
|
import { shortSha } from "../text/format.js";
|
|
10
|
-
import { checkAgentBinary, checkBrief, checkBootstrap, checkBuild, checkBuildCheck, checkClaudeTranscripts, checkGitBinary, checkInit, checkDiskSpace, checkEventLog, checkMergeLock, checkNodeVersion, checkRepo, checkStateDir, checkWorktreePool, checkWorkInstances, } from "./doctor-checks.js";
|
|
11
|
-
import { checkClaudeAuth, checkClaudeVersion, checkFallbackModel, checkTierModels } from "./doctor-model-checks.js";
|
|
12
|
-
import {
|
|
13
|
+
import { checkAgentBinary, checkBrief, checkBootstrap, checkBuild, checkBuildCheck, checkClaudeTranscripts, checkFunes, checkGitBinary, checkConfigKeyNames, checkInit, checkDiskSpace, checkEventLog, checkFlakyChecks, checkOpencodeVersion, checkMergeLock, checkNodeVersion, checkPiExtensions, checkProviderConcurrency, checkProviderContention, checkRepo, checkStateDir, checkWorktreePool, checkLeftoverRoleBranches, checkWorkInstances, } from "./doctor-checks.js";
|
|
14
|
+
import { checkClaudeAuth, checkClaudeVersion, checkAnthroApiKey, checkFallbackChain, checkFallbackModel, checkImageInput, checkOpencode, checkTierModels } from "./doctor-model-checks.js";
|
|
15
|
+
import { agentQualifiedSelector } from "../config/config-views.js";
|
|
16
|
+
import { seamsInUse } from "../config/config-fallbacks.js";
|
|
17
|
+
import { checkBacklogHeadings, checkFixClaims, checkLiveFinished, checkPlanDependencies, checkRefusedEntries, checkRegressedFixes, checkStaleQueuedMerges, checkStaleBugs, checkStalePrompts, checkStaleQuestions, checkStalledPlans, checkStrandedPlans, checkUncommittedBacklog, checkValidationGaps, checkWaitingBugs } from "./doctor-backlog.js";
|
|
13
18
|
/** Pre-flight environment check (`tumwater doctor`). The harness's preconditions are
|
|
14
19
|
* scattered across fail-fast checks that each command re-runs on its own (requireReadyRepo in
|
|
15
20
|
* cli.ts walks git-binary → repo → tumwater.json → commits and stops at the first failure;
|
|
16
21
|
* cmdRun adds pi-on-PATH and orchestrator-alive), so a user facing a partially initialized or
|
|
17
22
|
* drifted environment gets one error at a time. doctor runs every check, reports each result
|
|
18
23
|
* individually instead of stopping at the first failure, and exits 0/1 so it can be scripted —
|
|
19
|
-
* the pre-flight sibling of `status --json`, which queries live
|
|
24
|
+
* the pre-flight sibling of `status --json`, which queries live team state while doctor
|
|
20
25
|
* verdicts on the environment. Every check is read-only against .tumwater/ (works with or
|
|
21
26
|
* without a running harness) and none runs the project's build/test: that can take minutes and
|
|
22
27
|
* belongs in the review gate / red-main check, not a pre-flight. The individual checks live in
|
|
23
28
|
* doctor-checks.ts and doctor-model-checks.ts; this module composes them into the report. */
|
|
29
|
+
/** Wrap a possibly-absent doctor check into the report's `checks` spread: null omits the row
|
|
30
|
+
* entirely (the pi-extensions row for a team with no pi seam; the funes row when funes is not
|
|
31
|
+
* on PATH); an outcome contributes `{ name, ...outcome }`. */
|
|
32
|
+
function appendNamedOutcome(name, outcome) {
|
|
33
|
+
return outcome === null ? [] : [{ name, ...outcome }];
|
|
34
|
+
}
|
|
24
35
|
/** Run every check in order and compose the report. Read-only against .tumwater/ by
|
|
25
36
|
* construction — no check removes or repairs anything (the state-dir probe writes a temp file
|
|
26
37
|
* and deletes it again; the orphan check reports processes, never signals them) — so doctor
|
|
27
38
|
* works identically with or without a running harness. `probe` feeds the orphan and port
|
|
28
39
|
* checks, so tests can pin the report without reading the host's process table. */
|
|
29
|
-
export async function runDoctor(root, pathEnv = envPath(), probe = systemProcessProbe) {
|
|
40
|
+
export async function runDoctor(root, pathEnv = envPath(), probe = systemProcessProbe, probeImages = "never") {
|
|
30
41
|
// Loaded once for the checks that read config (repo's baseBranch); a broken file stays
|
|
31
42
|
// undefined — checkInit reports it verbatim — so doctor still runs every other check.
|
|
32
43
|
const { config } = loadConfigSafe(root);
|
|
44
|
+
// The routing table: every seam the config can spawn, with the selector each one runs on and
|
|
45
|
+
// the backend it routes to — read from the same one list the checks sweep, so an operator
|
|
46
|
+
// sees (and the checks cover) a claude fallback exactly like a primary seam. A broken config
|
|
47
|
+
// keeps it empty (the init check reports that separately).
|
|
48
|
+
const routing = config
|
|
49
|
+
? seamsInUse(config).map((s) => ({
|
|
50
|
+
label: s.label,
|
|
51
|
+
selector: s.model !== undefined ? agentQualifiedSelector({ ...s, model: s.model }) : "no model declared",
|
|
52
|
+
backend: s.agent ?? "pi",
|
|
53
|
+
}))
|
|
54
|
+
: [];
|
|
33
55
|
const info = readOrchestratorInfo(root);
|
|
34
56
|
const header = orchestratorAlive(root, info) && info
|
|
35
|
-
? `tumwater doctor —
|
|
36
|
-
: "tumwater doctor —
|
|
57
|
+
? `tumwater doctor — running (pid ${info.pid}${info.build ? `, version ${shortSha(info.build.sha)}${info.build.stale ? " — update available" : ""}${info.build.restartBlocked ? " (restart blocked)" : ""}` : ""})`
|
|
58
|
+
: "tumwater doctor — not running";
|
|
37
59
|
const checks = [
|
|
38
60
|
{ name: "node", ...checkNodeVersion() },
|
|
39
61
|
{ name: "git binary", ...checkGitBinary(pathEnv) },
|
|
40
62
|
{ name: "repo", ...(await checkRepo(root, config)) },
|
|
41
63
|
{ name: "init", ...checkInit(root) },
|
|
64
|
+
{ name: "config key names", ...checkConfigKeyNames(root) },
|
|
42
65
|
{ name: "brief", ...checkBrief(root) },
|
|
43
66
|
// New-project bootstrap (plans/work-ratio.md, part 2/2): an active hold reports as ok
|
|
44
67
|
// information, never a warning — a young project is not misconfigured.
|
|
@@ -46,32 +69,67 @@ export async function runDoctor(root, pathEnv = envPath(), probe = systemProcess
|
|
|
46
69
|
{ name: "pause", ...checkPause(root) },
|
|
47
70
|
{ name: "quiet hours", ...checkQuietHours(config) },
|
|
48
71
|
{ name: "budget", ...checkBudget(root, config, (orchestratorAlive(root, info) && info?.budget) || null) },
|
|
49
|
-
{ name: "
|
|
50
|
-
{ name: "
|
|
51
|
-
{ name: "
|
|
72
|
+
{ name: "claude window", ...checkClaudeWindow(root, undefined, (orchestratorAlive(root, info) && info?.chain) || undefined, config) },
|
|
73
|
+
{ name: "provider waits", ...checkProviderHolds(root) },
|
|
74
|
+
{ name: "maintenance allowance", ...checkMaintenanceQuota(root, config, orchestratorAlive(root, info)) },
|
|
75
|
+
{ name: "provider concurrency", ...checkProviderConcurrency(config, root) },
|
|
76
|
+
{ name: "provider contention", ...checkProviderContention(root, config) },
|
|
77
|
+
{ name: "repeated failures", ...checkErrorStreaks(root, config) },
|
|
78
|
+
{ name: "main's check", ...checkMainRed(root, config) },
|
|
79
|
+
{ name: "over-cap model", ...checkFallbackModel(root, undefined, (orchestratorAlive(root, info) && info?.fallbackDemoted) || null) },
|
|
80
|
+
{ name: "fallback chain", ...(await checkFallbackChain(root)) },
|
|
81
|
+
{ name: "models for each tier", ...(await checkTierModels(root)) },
|
|
82
|
+
{ name: "image input", ...(await checkImageInput(root, undefined, undefined, undefined, probeImages, pathEnv)) },
|
|
52
83
|
{ name: "claude auth", ...(await checkClaudeAuth(root, pathEnv)) },
|
|
84
|
+
{ name: "claude api key", ...(await checkAnthroApiKey(root, undefined, pathEnv)) },
|
|
53
85
|
{ name: "claude version", ...(await checkClaudeVersion(root, pathEnv)) },
|
|
54
|
-
{ name: "
|
|
86
|
+
{ name: "opencode version", ...(await checkOpencodeVersion(root, pathEnv)) },
|
|
87
|
+
{ name: "opencode", ...(await checkOpencode(root, pathEnv)) },
|
|
88
|
+
{ name: "backend binary", ...checkAgentBinary(root, pathEnv) },
|
|
55
89
|
// The transcript total is informational and only read when a seam routes to claude.
|
|
56
90
|
{ name: "claude transcripts", ...checkClaudeTranscripts(config) },
|
|
91
|
+
// The receive-side guards name what a running team skips, so an operator tool meant for the
|
|
92
|
+
// team (a pi package, an extension file, an mcp.json server, or funes) does not vanish
|
|
93
|
+
// silently. Each check returns null to omit its row: the pi row for a team with no pi seam,
|
|
94
|
+
// the funes row when funes is not on PATH.
|
|
95
|
+
...(appendNamedOutcome("pi extensions", checkPiExtensions(config, undefined, pathEnv))),
|
|
96
|
+
...(appendNamedOutcome("funes", checkFunes(config, pathEnv))),
|
|
57
97
|
{ name: "state dir", ...checkStateDir(root) },
|
|
58
98
|
{ name: "event log", ...checkEventLog(root) },
|
|
99
|
+
{ name: "notify", ...checkNotify(root, config) },
|
|
59
100
|
{ name: "merge lock", ...checkMergeLock(root) },
|
|
60
|
-
{ name: "
|
|
61
|
-
{ name: "
|
|
101
|
+
{ name: "workspaces", ...checkWorktreePool(root) },
|
|
102
|
+
{ name: "parallel instances", ...checkWorkInstances(root, config) },
|
|
103
|
+
{ name: "leftover role branches", ...(await checkLeftoverRoleBranches(root, config)) },
|
|
62
104
|
// The disk floor (plans/disk-floor.md, part 1/4): a broken config stays undefined (checkInit
|
|
63
105
|
// reports it), so the check falls back to the 10 GB default rather than skipping itself.
|
|
64
106
|
{ name: "disk space", ...checkDiskSpace(root, config?.diskHoldGB ?? 10, config?.diskReclaimGB ?? 40) },
|
|
65
|
-
{ name: "
|
|
66
|
-
{ name: "
|
|
67
|
-
|
|
107
|
+
{ name: "check", ...checkBuildCheck(root, config) },
|
|
108
|
+
{ name: "root install", ...checkRootInstall(root) },
|
|
109
|
+
// The review gate's flake memory, surfaced read-only: naming which checks the gate kept
|
|
110
|
+
// excusing as flakes (ok/warn only, so a flaky suite never fails a scripted pre-flight).
|
|
111
|
+
{ name: "flaky checks", ...checkFlakyChecks(root) },
|
|
112
|
+
{ name: "claimed bugs", ...checkFixClaims(root) },
|
|
113
|
+
{ name: "validation gaps", ...checkValidationGaps(root) },
|
|
114
|
+
{ name: "plans stuck in Planned", ...checkStrandedPlans(root) },
|
|
68
115
|
{ name: "backlog headings", ...checkBacklogHeadings(root) },
|
|
69
|
-
|
|
116
|
+
{ name: "backlog archive", ...checkLiveFinished(root) },
|
|
117
|
+
{ name: "refusals", ...checkRefusedEntries(root) },
|
|
118
|
+
{ name: "stalled plans", ...checkStalledPlans(root) },
|
|
119
|
+
{ name: "plan dependencies", ...checkPlanDependencies(root) },
|
|
120
|
+
{ name: "stale questions", ...checkStaleQuestions(root) },
|
|
121
|
+
{ name: "stale bugs", ...checkStaleBugs(root) },
|
|
122
|
+
{ name: "waiting bugs", ...checkWaitingBugs(root) },
|
|
123
|
+
{ name: "regressed fixes", ...checkRegressedFixes(root) },
|
|
124
|
+
{ name: "stale merge-queue entries", ...checkStaleQueuedMerges(root, Date.now()) },
|
|
125
|
+
{ name: "stale prompts", ...checkStalePrompts(root, Date.now()) },
|
|
126
|
+
{ name: "uncommitted backlog", ...(await checkUncommittedBacklog(root, config)) },
|
|
127
|
+
// The running team's own view of its build (staleness and what auto-restart made of it)
|
|
70
128
|
// when there is one; without it the check still stands on its own git comparison.
|
|
71
|
-
{ name: "
|
|
129
|
+
{ name: "update", ...(await checkBuild(root, undefined, undefined, (orchestratorAlive(root, info) && info?.build) || null)) },
|
|
72
130
|
{ name: "orphans", ...(await checkOrphans(root, probe)) },
|
|
73
|
-
{ name: "
|
|
131
|
+
{ name: "macOS port leak", ...(await checkLaunchServicesPorts(probe)) },
|
|
74
132
|
];
|
|
75
133
|
const problems = checks.filter((c) => c.level === "fail").length;
|
|
76
|
-
return { header, checks, verdict: problems === 0 ? "ready to run" : plural(problems, "problem") };
|
|
134
|
+
return { header, checks, routing, verdict: problems === 0 ? "ready to run" : plural(problems, "problem") };
|
|
77
135
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/** The doctor's live image-input probe (plans/image-input.md "Doctor", part 2/5): a claimed
|
|
2
|
+
* image-input capability in pi's models.json is only a claim — doctor shows each claimed model
|
|
3
|
+
* a real image and checks the answer. This module owns three things: the in-process PNG
|
|
4
|
+
* generator (a random 4-digit code drawn with a built-in 5×7 bitmap, encoded with
|
|
5
|
+
* `zlib.deflateSync` + `zlib.crc32` is newer than the `>=20.3` node floor, so the CRC-32 here is
|
|
6
|
+
* hand-rolled), the probe runner (the resolved pi binary in a run's environment, bounded like
|
|
7
|
+
* the doctor's other binary probes), and the `.tumwater/state/image-probes.json` cache the
|
|
8
|
+
* doctor check reads back. No new dependency; runs entirely offline against the operator's pi. */
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { randomInt } from "node:crypto";
|
|
13
|
+
import zlib from "node:zlib";
|
|
14
|
+
import { PiStreamParser } from "../pi/pi-stream.js";
|
|
15
|
+
import { resolveAgentBin } from "../pi/pi-bin.js";
|
|
16
|
+
import { imageProbesPath } from "../paths.js";
|
|
17
|
+
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
18
|
+
import { boundedProbe } from "./probe.js";
|
|
19
|
+
/** How long a cached image-probe verdict stays valid while the claim is unchanged (7 days) —
|
|
20
|
+
* a model's endpoint can change underneath a stale pass, so the answer is dated. */
|
|
21
|
+
export const IMAGE_PROBE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
/** The probe's deadline (90 s): a local model may need to load, far beyond the quick credential
|
|
23
|
+
* probes, but a doctor run is still a pre-flight — a hung backend must lose, not stall the
|
|
24
|
+
* report. */
|
|
25
|
+
const PROBE_TIMEOUT_MS = 90_000;
|
|
26
|
+
/** Cap on the probe's captured stdout, the same guard as the auth probes: a doctor run must not
|
|
27
|
+
* grow an unbounded buffer off a runaway log. */
|
|
28
|
+
const PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
29
|
+
/** The cache-file key for one seam — agent/provider/model, the natural identity of a team
|
|
30
|
+
* model, joined on `/` (agent and provider never contain it and a model id is fully qualified,
|
|
31
|
+
* so the key cannot collide with another seam's). */
|
|
32
|
+
export function imageProbeKey(agent, provider, model) {
|
|
33
|
+
return [agent ?? "", provider ?? "", model ?? ""].join("/");
|
|
34
|
+
}
|
|
35
|
+
/** Whether a cached entry still stands for the current claim: it exists, it recorded a real
|
|
36
|
+
* verdict (never `could-not-verify`, which is never cached), it tested the image-input claim,
|
|
37
|
+
* and it is younger than the TTL. */
|
|
38
|
+
export function imageProbeEntryFresh(entry, now = Date.now()) {
|
|
39
|
+
return (entry !== undefined &&
|
|
40
|
+
(entry.verdict === "pass" || entry.verdict === "fail") &&
|
|
41
|
+
entry.claim &&
|
|
42
|
+
now - entry.at <= IMAGE_PROBE_TTL_MS);
|
|
43
|
+
}
|
|
44
|
+
/** Read the probe cache, or an empty cache when the file is missing or torn (the harness's
|
|
45
|
+
* no-data policy: a crashed writer never takes down a reader). */
|
|
46
|
+
export function readImageProbeCache(root) {
|
|
47
|
+
return readJsonFile(imageProbesPath(root)) ?? {};
|
|
48
|
+
}
|
|
49
|
+
/** Write the probe cache atomically (tmp+rename) so a concurrent reader never sees a torn
|
|
50
|
+
* file. */
|
|
51
|
+
export function writeImageProbeCache(root, cache) {
|
|
52
|
+
writeJsonAtomic(imageProbesPath(root), cache);
|
|
53
|
+
}
|
|
54
|
+
// ── PNG generator ─────────────────────────────────────────────────────────────────────────
|
|
55
|
+
/** Each digit as 7 rows of 5 bits, '1' = drawn (black); the standard 5×7 seven-segment-ish
|
|
56
|
+
* font, big enough for a vision model to read and small enough to stay a trivial payload. */
|
|
57
|
+
const DIGIT_BITMAP = {
|
|
58
|
+
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
|
|
59
|
+
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
|
|
60
|
+
"2": ["01110", "10001", "00001", "00110", "01000", "10000", "11111"],
|
|
61
|
+
"3": ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
|
|
62
|
+
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
|
|
63
|
+
"5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
|
|
64
|
+
"6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
|
|
65
|
+
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
|
|
66
|
+
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
|
|
67
|
+
"9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
|
|
68
|
+
};
|
|
69
|
+
/** A hand-rolled CRC-32 (the PNG chunk checksum): `zlib.crc32` is newer than the `>=20.3` node
|
|
70
|
+
* floor, so this bitwise table version is the one home of the polynomial. */
|
|
71
|
+
const CRC32_TABLE = (() => {
|
|
72
|
+
const table = new Uint32Array(256);
|
|
73
|
+
for (let n = 0; n < 256; n++) {
|
|
74
|
+
let c = n;
|
|
75
|
+
for (let k = 0; k < 8; k++)
|
|
76
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
77
|
+
table[n] = c >>> 0;
|
|
78
|
+
}
|
|
79
|
+
return table;
|
|
80
|
+
})();
|
|
81
|
+
function crc32(buf) {
|
|
82
|
+
let c = 0xffffffff;
|
|
83
|
+
for (let i = 0; i < buf.length; i++)
|
|
84
|
+
c = (CRC32_TABLE[(c ^ (buf[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8);
|
|
85
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
86
|
+
}
|
|
87
|
+
/** One PNG chunk: 4-byte big-endian length, the 4-byte ASCII type, the data, and the CRC over
|
|
88
|
+
* type+data. */
|
|
89
|
+
function pngChunk(type, data) {
|
|
90
|
+
const len = Buffer.alloc(4);
|
|
91
|
+
len.writeUInt32BE(data.length, 0);
|
|
92
|
+
const typeBuf = Buffer.from(type, "ascii");
|
|
93
|
+
const crc = Buffer.alloc(4);
|
|
94
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
95
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
96
|
+
}
|
|
97
|
+
/** A PNG showing `code` black on white, drawn with the 5×7 bitmap scaled `scale`-fold (each
|
|
98
|
+
* font cell becomes a `scale`×`scale` block) and one `scale`-wide gap between digits. Truecolor
|
|
99
|
+
* (color type 2) so any reader handles it and the decode-check in the tests is trivial. Exported
|
|
100
|
+
* for tests (and the doctor's own validity check of a generated image). */
|
|
101
|
+
export function makeProbePng(code, scale = 8) {
|
|
102
|
+
const digits = [...code].map((d) => DIGIT_BITMAP[d] ?? DIGIT_BITMAP["0"]);
|
|
103
|
+
const width = digits.length * 5 * scale + (digits.length - 1) * scale;
|
|
104
|
+
const height = 7 * scale;
|
|
105
|
+
// Truecolor RGB scanlines, each prefixed with filter byte 0 (none).
|
|
106
|
+
const raw = Buffer.alloc(height * (1 + width * 3));
|
|
107
|
+
for (let y = 0; y < height; y++) {
|
|
108
|
+
const rowStart = y * (1 + width * 3);
|
|
109
|
+
raw[rowStart] = 0;
|
|
110
|
+
for (let x = 0; x < width; x++) {
|
|
111
|
+
// Which font cell does this pixel fall in?
|
|
112
|
+
const cell = Math.floor(x / (5 * scale + scale));
|
|
113
|
+
const inCellX = x - cell * (5 * scale + scale);
|
|
114
|
+
const cellDigit = digits[cell];
|
|
115
|
+
const row = Math.floor(y / scale);
|
|
116
|
+
const col = Math.floor(inCellX / scale);
|
|
117
|
+
const bit = inCellX < 5 * scale && cellDigit !== undefined
|
|
118
|
+
? ((cellDigit[row] ?? "")[col] ?? "")
|
|
119
|
+
: "";
|
|
120
|
+
const on = bit === "1";
|
|
121
|
+
const p = rowStart + 1 + x * 3;
|
|
122
|
+
raw[p] = on ? 0 : 255;
|
|
123
|
+
raw[p + 1] = on ? 0 : 255;
|
|
124
|
+
raw[p + 2] = on ? 0 : 255;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const ihdr = Buffer.alloc(13);
|
|
128
|
+
ihdr.writeUInt32BE(width, 0);
|
|
129
|
+
ihdr.writeUInt32BE(height, 4);
|
|
130
|
+
ihdr[8] = 8; // bit depth
|
|
131
|
+
ihdr[9] = 2; // color type: truecolor RGB
|
|
132
|
+
const idat = zlib.deflateSync(raw, { level: 9 });
|
|
133
|
+
return Buffer.concat([
|
|
134
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
135
|
+
pngChunk("IHDR", ihdr),
|
|
136
|
+
pngChunk("IDAT", idat),
|
|
137
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
/** A random 4-digit code for one probe attempt. */
|
|
141
|
+
function randomCode() {
|
|
142
|
+
let out = "";
|
|
143
|
+
for (let i = 0; i < 4; i++)
|
|
144
|
+
out += String(randomInt(0, 10));
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
// ── Probe runner ──────────────────────────────────────────────────────────────────────────
|
|
148
|
+
/** Parse the resolved probe's stdout (pi's `--mode json` event stream) the same way a real run
|
|
149
|
+
* is parsed, so a live probe sees the same final text and error classification the harness
|
|
150
|
+
* would. */
|
|
151
|
+
function parseProbeOutput(stdout) {
|
|
152
|
+
const parser = new PiStreamParser();
|
|
153
|
+
parser.feed(stdout);
|
|
154
|
+
return {
|
|
155
|
+
finalText: parser.finalText,
|
|
156
|
+
configError: parser.configError,
|
|
157
|
+
transientRateLimit: parser.transientRateLimit,
|
|
158
|
+
transientBackend: parser.transientBackend,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Whether an answer looks like a (wrong) read of the 4-digit code rather than a refusal: the
|
|
162
|
+
* text contains a 4-digit run of digits (e.g. "The number is 5825."). A model that cannot see
|
|
163
|
+
* at all does not emit a number. */
|
|
164
|
+
function isFourDigitNumber(text) {
|
|
165
|
+
return /\b\d{4}\b/.test(text);
|
|
166
|
+
}
|
|
167
|
+
/** Probe one claimed pi seam with a real image (plans/image-input.md "Doctor"). Runs the
|
|
168
|
+
* resolved pi binary in a run's environment (the caller passes `env`, typically
|
|
169
|
+
* `childEnv({ PATH: pathEnv })` which inherits `PI_CODING_AGENT_DIR`, so the probe reads the
|
|
170
|
+
* same models.json a real tick would):
|
|
171
|
+
*
|
|
172
|
+
* `pi --print --mode json --no-session --no-tools --no-mcp --provider P --model M
|
|
173
|
+
* --thinking <level> @<png> "Reply with only the 4-digit number shown in the image."`
|
|
174
|
+
*
|
|
175
|
+
* Pass: the final assistant text contains the code. A wrong answer retries once with a fresh
|
|
176
|
+
* code (a misread is not blindness). Two wrong answers that are not both numbers fail (a
|
|
177
|
+
* refusal or empty answer is blindness), and so does a 4xx. Two wrong *numbers* — the model
|
|
178
|
+
* read a number, wrongly — cannot be told from a flaky misread, so it is `could-not-verify`
|
|
179
|
+
* rather than `fail` and is never cached: the next doctor run probes again instead of turning
|
|
180
|
+
* a capable seam text-only for a week. A timeout, spawn failure, 429, 5xx, or connection error
|
|
181
|
+
* is also `could-not-verify` — it says nothing about capability and is never cached. `code` is
|
|
182
|
+
* a test seam (a real probe always draws a fresh random code); `env` and `timeoutMs` are
|
|
183
|
+
* injectable so tests need no real pi. */
|
|
184
|
+
export async function runImageProbe(config, seam, env, timeoutMs = PROBE_TIMEOUT_MS, code) {
|
|
185
|
+
const envBin = env.TUMWATER_PI_BIN?.trim();
|
|
186
|
+
// Resolve from the run's own environment (env override → config), exactly as a real tick's
|
|
187
|
+
// spawn would rather than only the harness process env — so tests can point a fake pi there.
|
|
188
|
+
const bin = envBin
|
|
189
|
+
? envBin.includes("/")
|
|
190
|
+
? path.resolve(envBin)
|
|
191
|
+
: envBin
|
|
192
|
+
: resolveAgentBin(config).bin;
|
|
193
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-image-probe-"));
|
|
194
|
+
const wrong = []; // the per-attempt "answered …" detail for a wrong answer
|
|
195
|
+
const misread = []; // whether each wrong answer was a (wrong) 4-digit number
|
|
196
|
+
try {
|
|
197
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
198
|
+
const thisCode = code ?? randomCode();
|
|
199
|
+
const png = makeProbePng(thisCode);
|
|
200
|
+
const pngPath = path.join(dir, `probe-${attempt}.png`);
|
|
201
|
+
fs.writeFileSync(pngPath, png);
|
|
202
|
+
const args = ["--print", "--mode", "json", "--no-session", "--no-tools", "--no-mcp"];
|
|
203
|
+
if (seam.provider)
|
|
204
|
+
args.push("--provider", seam.provider);
|
|
205
|
+
if (seam.model)
|
|
206
|
+
args.push("--model", seam.model);
|
|
207
|
+
if (seam.thinking)
|
|
208
|
+
args.push("--thinking", seam.thinking);
|
|
209
|
+
args.push(`@${pngPath}`, "Reply with only the 4-digit number shown in the image.");
|
|
210
|
+
const result = await boundedProbe(bin, args, env, timeoutMs, PROBE_MAX_OUTPUT_BYTES);
|
|
211
|
+
if (result === null)
|
|
212
|
+
return { verdict: "could-not-verify", detail: "the probe could not run or timed out" };
|
|
213
|
+
const parsed = parseProbeOutput(result.stdout);
|
|
214
|
+
if (parsed.configError)
|
|
215
|
+
return { verdict: "fail", detail: "the provider rejected the request (4xx)" };
|
|
216
|
+
if (parsed.transientRateLimit)
|
|
217
|
+
return { verdict: "could-not-verify", detail: "the provider rate-limited the request (429)" };
|
|
218
|
+
if (parsed.transientBackend)
|
|
219
|
+
return { verdict: "could-not-verify", detail: "the provider backend failed (5xx or connection)" };
|
|
220
|
+
const text = parsed.finalText.trim();
|
|
221
|
+
if (text.includes(thisCode))
|
|
222
|
+
return { verdict: "pass", detail: `answered "${text}"` };
|
|
223
|
+
// A wrong 4-digit number says "I looked and got the digits wrong"; a refusal or empty
|
|
224
|
+
// answer says "I cannot see". The drawn code is exactly 4 digits, so a 4-digit answer is
|
|
225
|
+
// a plausible (wrong) read.
|
|
226
|
+
misread.push(isFourDigitNumber(text));
|
|
227
|
+
wrong.push(text ? `answered "${text}"` : "answered nothing");
|
|
228
|
+
}
|
|
229
|
+
// A refusal or empty answer on either attempt is blindness — the two-strikes rule: fail,
|
|
230
|
+
// cached. Two wrong *numbers* cannot be told from a flaky misread, so it is could-not-verify
|
|
231
|
+
// and never cached: the next doctor run probes again instead of turning a capable seam
|
|
232
|
+
// text-only for a week. Either way the detail names what the model said, so the doctor
|
|
233
|
+
// line, the alert, and a bug report can tell blindness from a misread.
|
|
234
|
+
return misread[0] && misread[1]
|
|
235
|
+
? {
|
|
236
|
+
verdict: "could-not-verify",
|
|
237
|
+
detail: `two misreads (${wrong.join(", ")}) — the model read a number, wrongly; not blindness, re-probe on the next doctor run`,
|
|
238
|
+
}
|
|
239
|
+
: { verdict: "fail", detail: `two wrong answers (${wrong[1]})` };
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { releaseChildHandles, signalTree } from "../process/process.js";
|
|
3
|
+
/** One bounded probe of a resolved binary: `null` when the spawn threw, the child errored,
|
|
4
|
+
* the deadline passed, or stdout overran `maxOutputBytes` — every case the caller must read as
|
|
5
|
+
* "could not check", never as a verdict. The child runs detached and at the deadline (or on
|
|
6
|
+
* overrun) its whole process group is SIGKILLed and the promise settles, because `'close'`
|
|
7
|
+
* waits for the stdio pipes and a grandchild holding them open would otherwise leave the
|
|
8
|
+
* promise pending past the deadline and hang the caller (the same trap git-run.ts's bounded
|
|
9
|
+
* spawn closes for git). stderr is ignored rather than piped, since nothing reads it and a
|
|
10
|
+
* full stderr pipe would block the child. `timeoutMs` and `maxOutputBytes` are test seams;
|
|
11
|
+
* production callers pass their own named constants. One home of the shape, shared by the
|
|
12
|
+
* model checks' credential/version probes, the tier-model probes, and the image-input probe —
|
|
13
|
+
* before the split each doctor module carried its own byte-for-byte copy. */
|
|
14
|
+
export function boundedProbe(bin, args, env, timeoutMs, maxOutputBytes) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let child;
|
|
17
|
+
try {
|
|
18
|
+
// Detached so the deadline's signalTree reaches a grandchild too; stderr is ignored
|
|
19
|
+
// rather than piped, since nothing reads it and a full stderr pipe would block the
|
|
20
|
+
// child until the deadline.
|
|
21
|
+
child = spawn(bin, args, {
|
|
22
|
+
cwd: process.cwd(),
|
|
23
|
+
detached: true,
|
|
24
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
env,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
resolve(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let out = "";
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
let settled = false;
|
|
35
|
+
let timer;
|
|
36
|
+
const settle = (result) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
if (timer !== undefined)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
// Destroy the captured stdout and unref the process handle (releaseChildHandles): a
|
|
43
|
+
// grandchild that escaped the group and holds the pipe's write end would otherwise keep
|
|
44
|
+
// the process's event loop alive though the probe already has its answer.
|
|
45
|
+
releaseChildHandles(child);
|
|
46
|
+
resolve(result);
|
|
47
|
+
};
|
|
48
|
+
// Deadline or runaway output: kill the whole group, tear down the handles, and settle —
|
|
49
|
+
// do not wait for a `'close'` the group may never deliver.
|
|
50
|
+
const abort = () => {
|
|
51
|
+
signalTree(child, "SIGKILL");
|
|
52
|
+
settle(null);
|
|
53
|
+
};
|
|
54
|
+
timer = setTimeout(abort, timeoutMs);
|
|
55
|
+
child.stdout?.on("data", (chunk) => {
|
|
56
|
+
out += chunk.toString("utf8");
|
|
57
|
+
bytes += chunk.length;
|
|
58
|
+
if (bytes > maxOutputBytes)
|
|
59
|
+
abort();
|
|
60
|
+
});
|
|
61
|
+
child.on("error", () => settle(null));
|
|
62
|
+
child.on("close", (code) => settle({ code, stdout: out }));
|
|
63
|
+
});
|
|
64
|
+
}
|