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
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
import { fail, say, sayJsonLine } from "./cli-output.js";
|
|
2
|
-
import { durationLabel, failRivalShapes, flagValue, parseCountFlag, parseGrepFlag, parseRoleScope, parseSinceFlag } from "./cli-args.js";
|
|
3
|
+
import { durationLabel, failRivalShapes, flagValue, parseCountFlag, parseGrepFlag, parseRoleScope, parseSinceFlag, parseTypeFlag } from "./cli-args.js";
|
|
3
4
|
import { LOGS_SINCE_MAX_MS, readEventsSince, SPARSE_WINDOW_NOTE, } from "../events/event-window.js";
|
|
5
|
+
import { compareCodeUnits, increment, sortByValueDesc } from "../collections.js";
|
|
4
6
|
import { parseEventLine, readEventsTailWithEnd } from "../events/event-read.js";
|
|
5
7
|
import { eventMatchesGrep, formatEvent } from "../events/event-format.js";
|
|
8
|
+
import { canonicalEventType } from "../events/event-legacy.js";
|
|
9
|
+
import { EVENT_TYPE_LABELS, eventTypeLabel } from "../events/event-labels.js";
|
|
6
10
|
import { followFile } from "../files/tail.js";
|
|
11
|
+
import { typoSuffix } from "../text/suggest.js";
|
|
7
12
|
import { createTranscriptRenderer } from "../ui/transcript.js";
|
|
8
13
|
import { readTranscriptTail } from "../ui/transcript-tail.js";
|
|
9
14
|
import { eventsLogPath, piLogPath } from "../paths.js";
|
|
10
15
|
/** The read-only observing half of the CLI's non-dispatch commands: `tumwater logs` and its
|
|
11
16
|
* `--role` transcript view, split out of cli.ts so the entry point stays a dispatch table. Unlike
|
|
12
17
|
* operator/operator-commands.ts these write nothing but stdout — they only read the event log and
|
|
13
|
-
* each loop's
|
|
18
|
+
* each loop's agent transcript. It lives beside the other CLI command bodies (history.ts,
|
|
14
19
|
* cli/cli-query-commands.ts, operator/operator-commands.ts), which may import the ui/ rendering
|
|
15
20
|
* layer they drive — here the event formatter and the transcript renderer — while src/ui/
|
|
16
21
|
* itself stays off src/ module boundaries it does not own. */
|
|
@@ -25,36 +30,90 @@ function sayEventLine(e, json) {
|
|
|
25
30
|
else
|
|
26
31
|
say(formatEvent(e));
|
|
27
32
|
}
|
|
33
|
+
/** Render the `--count` tally: one `N × <type>` line per present event type, most-frequent
|
|
34
|
+
* first with ties broken alphabetically by type — the single shape shared by both window
|
|
35
|
+
* sources (-n and --since), so the print and the count cannot drift on which events count.
|
|
36
|
+
* In `--json` mode it emits one compact object mapping `type → count` with keys sorted, the
|
|
37
|
+
* machine-readable twin of the prose tally. */
|
|
38
|
+
function sayTally(events, json) {
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const e of events)
|
|
41
|
+
increment(counts, e.type);
|
|
42
|
+
if (json) {
|
|
43
|
+
const obj = {};
|
|
44
|
+
for (const [type] of [...counts.entries()].sort((a, b) => compareCodeUnits(a[0], b[0])))
|
|
45
|
+
obj[type] = counts.get(type);
|
|
46
|
+
sayJsonLine(obj);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const [type, n] of sortByValueDesc([...counts.entries()]))
|
|
50
|
+
say(`${n} × ${eventTypeLabel(type)} (${type})`);
|
|
51
|
+
}
|
|
28
52
|
/** The shared rival-shape guard for logs flags that only apply to the event-log feed:
|
|
29
|
-
* `--role` swaps in a
|
|
53
|
+
* `--role` swaps in a transcript rather than the event log (--prompt requires --role, so
|
|
30
54
|
* it is excluded with it), so each such flag fails naming both. One wording for all of them —
|
|
31
55
|
* the --json, --grep, and --since checks cannot drift apart in what they say or why. `active`
|
|
32
56
|
* is the flag's own presence — --json alone composes with nothing here but --role, while
|
|
33
57
|
* --grep and --since call from inside their own presence guards. */
|
|
34
58
|
function rejectRoleViewRival(rest, flag, active) {
|
|
35
59
|
if (active && (rest.includes("--role") || rest.includes("--prompt")))
|
|
36
|
-
failRivalShapes(`logs ${flag}`, "--role", "the --role view is
|
|
60
|
+
failRivalShapes(`logs ${flag}`, "--role", "the --role view is the role's agent transcript, not the event log; --prompt requires --role");
|
|
37
61
|
}
|
|
38
62
|
/** The missing-pattern error cmdLogs prints for a valueless `--grep`, exported so cli.ts's
|
|
39
63
|
* rejectUnknownArgs spec for --grep can fail a trailing `logs --grep` with the same wording
|
|
40
64
|
* (the gate runs before the ready-repo gate and this parser, so the wordings must not drift). */
|
|
41
65
|
export const GREP_VALUE_ERROR = "logs --grep needs a pattern";
|
|
66
|
+
/** The missing-type error cmdLogs prints for a valueless `--type`, exported so cli.ts's
|
|
67
|
+
* rejectUnknownArgs spec for --type can fail a trailing `logs --type` with the same wording
|
|
68
|
+
* (the gate runs before the ready-repo gate and this parser, so the wordings must not drift). */
|
|
69
|
+
export const TYPE_VALUE_ERROR = "logs --type needs an event type";
|
|
70
|
+
/** The exact-type empty-match prose: the same no-match shape `--grep` uses, naming the type
|
|
71
|
+
* whose value matched nothing, so the two filters' empty replies cannot drift in form. Past the
|
|
72
|
+
* names, a value that is not itself a real event type picks up a did-you-mean hint (the shell
|
|
73
|
+
* completion already offers the valid codes, so a bare no-match for a near-miss reads as a silent
|
|
74
|
+
* mystery) — while a VALID type that merely has no matching events stays hint-free, a legitimate
|
|
75
|
+
* empty result rather than a typo. A valid type or a legacy alias (canonicalEventType maps the
|
|
76
|
+
* alias onto the current code, which is a label key) never hints. */
|
|
77
|
+
function typeEmpty(type) {
|
|
78
|
+
const canonical = canonicalEventType(type);
|
|
79
|
+
const hint = Object.hasOwn(EVENT_TYPE_LABELS, canonical)
|
|
80
|
+
? ""
|
|
81
|
+
: typoSuffix(type, Object.keys(EVENT_TYPE_LABELS));
|
|
82
|
+
return `no events matching "${type}"${hint}`;
|
|
83
|
+
}
|
|
42
84
|
/** `tumwater logs [-f] [-n <count>] [--since <duration>] [--role <id>] [--prompt]`: follow or
|
|
43
85
|
* dump the harness event log, with `--since` a bounded past window of it, or with `--role` one
|
|
44
|
-
* loop's
|
|
86
|
+
* loop's agent transcript (see cmdLogsTranscript). */
|
|
45
87
|
export async function cmdLogs(root, args) {
|
|
46
88
|
// `--grep <text>` filters the event feed (the -n view and its follow): case-insensitive
|
|
47
89
|
// substring against `${e.type} ${formatEvent(e)}` — the rendered line is what the operator
|
|
48
90
|
// would otherwise read (WYSIWYG), and prefixing the raw type id lets stable ids
|
|
49
|
-
// (review_rejected,
|
|
50
|
-
// Rival shapes stay exclusive — --role swaps in a
|
|
91
|
+
// (review_rejected, merge_failed) be filtered even where the rendering paraphrases them.
|
|
92
|
+
// Rival shapes stay exclusive — --role swaps in a transcript rather than this event log
|
|
51
93
|
// (--prompt requires --role, so it is excluded with it), and --since is a filter of its own
|
|
52
94
|
// rather than a window to filter — and each failure names both flags. The flag scan itself
|
|
53
95
|
// (the value lookup, the `rest` construction that keeps a flag-shaped pattern from
|
|
54
96
|
// impersonating a rival flag, and the empty-value fail) is cli/cli-args.ts's parseGrepFlag,
|
|
55
97
|
// the one home shared with history's identical preamble.
|
|
56
|
-
const { rest, pattern: grepPattern } = parseGrepFlag(args, GREP_VALUE_ERROR);
|
|
98
|
+
const { rest: grepped, pattern: grepPattern } = parseGrepFlag(args, GREP_VALUE_ERROR);
|
|
99
|
+
// `--type <eventType>` filters the event feed by EXACT event type (e.type === value):
|
|
100
|
+
// deterministic where --grep substrings — a merge_failed whose rendered line quotes
|
|
101
|
+
// "merge_failed" matches --grep but not --type. Unlike --grep it composes with --since
|
|
102
|
+
// (the window, not a filter of its own) as well as -n, -f, --json, and --grep; like --json
|
|
103
|
+
// it is a rival only of the --role/--prompt transcript view. The value scan (keeping a
|
|
104
|
+
// flag-shaped value like --since as text, the empty-value fail) mirrors parseGrepFlag via
|
|
105
|
+
// parseTypeFlag, so the two cannot drift; `rest` here has both flags' value tokens removed.
|
|
106
|
+
const { rest, type } = parseTypeFlag(grepped, TYPE_VALUE_ERROR);
|
|
57
107
|
const grepLower = grepPattern === null ? null : grepPattern.toLowerCase();
|
|
108
|
+
// An older event name matches its current one; the empty-match prose still names what was typed.
|
|
109
|
+
const wantedType = type === null ? null : canonicalEventType(type);
|
|
110
|
+
const typeFilter = type === null ? null : (e) => e.type === wantedType;
|
|
111
|
+
// The composed feed rule every emit site threads today's grep-only predicate through:
|
|
112
|
+
// an event passes when it is absent-or-matches the --grep substring AND absent-or-equals
|
|
113
|
+
// the --type id, held in one place so the --since window, the -n tally, and the follow
|
|
114
|
+
// cannot drift on what an event must satisfy.
|
|
115
|
+
const feedFilter = (e) => (grepLower === null || eventMatchesGrep(e, grepLower)) &&
|
|
116
|
+
(typeFilter === null || typeFilter(e));
|
|
58
117
|
const follow = rest.includes("-f") || rest.includes("--follow");
|
|
59
118
|
// `--json` switches the feed from formatEvent rendering to one JSON.stringify(e) per line —
|
|
60
119
|
// the raw HarnessEvent objects exactly as stored in the log — so scripts read the canonical
|
|
@@ -65,6 +124,15 @@ export async function cmdLogs(root, args) {
|
|
|
65
124
|
// excluded with it).
|
|
66
125
|
const json = rest.includes("--json");
|
|
67
126
|
rejectRoleViewRival(rest, "--json", json);
|
|
127
|
+
// `--count` tallies the window's present event types instead of printing the rows. Rival
|
|
128
|
+
// shapes stay exclusive: counting implies a bounded window (so -f/--follow is rejected)
|
|
129
|
+
// and the --role view is the role's agent transcript rather than the event log.
|
|
130
|
+
const count = rest.includes("--count");
|
|
131
|
+
rejectRoleViewRival(rest, "--count", count);
|
|
132
|
+
if (count && follow)
|
|
133
|
+
failRivalShapes("logs --count", "-f/--follow", "counting implies a bounded window; -f follows from now on");
|
|
134
|
+
if (type !== null)
|
|
135
|
+
rejectRoleViewRival(rest, "--type", true);
|
|
68
136
|
if (grepPattern !== null) {
|
|
69
137
|
rejectRoleViewRival(rest, "--grep", true);
|
|
70
138
|
if (rest.includes("--since"))
|
|
@@ -74,7 +142,7 @@ export async function cmdLogs(root, args) {
|
|
|
74
142
|
// it reads a bounded past window (event-window.ts's day-keyed backwards scan) and prints the
|
|
75
143
|
// survivors oldest-first, so an operator can ask for "the hour around that 429 storm" without
|
|
76
144
|
// guessing a count. Rival shapes stay exclusive — follow means "from now on", -n means "last
|
|
77
|
-
// N", and --role swaps in a
|
|
145
|
+
// N", and --role swaps in a transcript rather than this log — and each failure names
|
|
78
146
|
// both flags. (--prompt requires --role, so it is excluded with it.)
|
|
79
147
|
const ms = parseSinceFlag(rest, "logs --since", LOGS_SINCE_MAX_MS);
|
|
80
148
|
if (ms !== null) {
|
|
@@ -86,16 +154,29 @@ export async function cmdLogs(root, args) {
|
|
|
86
154
|
// covered is only consulted below when the window has rows (an empty window returns
|
|
87
155
|
// earlier), so the helper's vacuous empty-log branch never reaches the note here.
|
|
88
156
|
const { events, covered } = readEventsSince(root, ms);
|
|
89
|
-
|
|
90
|
-
|
|
157
|
+
// `--type` composes with the window (it is NOT the --since rival --grep is): only the
|
|
158
|
+
// window events of the exact type are printed, and an all-filtered window answers with
|
|
159
|
+
// the no-match prose (the type's own line) instead of the generic empty-window one —
|
|
160
|
+
// "which merge_failed in the last hour" filters the same as it would the -n view.
|
|
161
|
+
const filtered = type === null ? events : events.filter((e) => e.type === wantedType);
|
|
162
|
+
if (filtered.length === 0) {
|
|
163
|
+
// In JSON mode an empty result answers silently — an empty output IS the machine-readable
|
|
91
164
|
// answer, and prose would corrupt a consumer's NDJSON stream.
|
|
92
165
|
if (!json)
|
|
93
|
-
say(`no events in ${durationLabel(ms)}`);
|
|
166
|
+
say(type === null ? `no events in ${durationLabel(ms)}` : typeEmpty(type));
|
|
94
167
|
return;
|
|
95
168
|
}
|
|
96
|
-
|
|
169
|
+
// `--count` composes with the window: the tally covers exactly the filtered window rows
|
|
170
|
+
// the plain view would print (and answers the empty window with the plain view's own
|
|
171
|
+
// prose above), so the two cannot disagree on which events count. Never printed with the
|
|
172
|
+
// sparse-window note below: the note rides rows, and a count has none.
|
|
173
|
+
if (count) {
|
|
174
|
+
sayTally(filtered, json);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const e of filtered)
|
|
97
178
|
sayEventLine(e, json);
|
|
98
|
-
// A sparse window is never mistaken for a quiet
|
|
179
|
+
// A sparse window is never mistaken for a quiet team — but the note only ever rides rows:
|
|
99
180
|
// with no rows at all (a fresh install's missing log among them) there is nothing sparse to
|
|
100
181
|
// explain, and a flat "rotated out" claim would be false for a log that never had events.
|
|
101
182
|
// The hedged phrasing stays true whenever it prints: the oldest retained event being inside
|
|
@@ -117,17 +198,42 @@ export async function cmdLogs(root, args) {
|
|
|
117
198
|
return;
|
|
118
199
|
}
|
|
119
200
|
// -n bounds the scan, not the print: the matched subset of the last `limit` events is what
|
|
120
|
-
// appears, so `-n 200 --grep
|
|
201
|
+
// appears, so `-n 200 --grep merge_failed` may print 3 rows from 200 scanned. The read is
|
|
121
202
|
// the tail-with-end shape (not the per-poll cached readEvents) so the follow below can seed
|
|
122
203
|
// from the byte end this very read covered instead of a later stat of the file.
|
|
123
204
|
const tail = readEventsTailWithEnd(root, limit);
|
|
124
|
-
const shown = grepLower ? tail.events.filter(
|
|
205
|
+
const shown = grepLower !== null || type !== null ? tail.events.filter(feedFilter) : tail.events;
|
|
206
|
+
// `--count` over the -n view answers with the tally of exactly the filtered rows the plain
|
|
207
|
+
// view would print, and carries over the plain view's empty-match prose below (a `--type`
|
|
208
|
+
// or `--grep` that matches nothing names its value; an empty unfiltered -n view prints
|
|
209
|
+
// nothing, as the plain view does). In JSON mode the empty output is the answer.
|
|
210
|
+
if (count) {
|
|
211
|
+
if (shown.length !== 0)
|
|
212
|
+
sayTally(shown, json);
|
|
213
|
+
else if (!json && (grepPattern !== null || type !== null)) {
|
|
214
|
+
if (grepPattern !== null && type !== null)
|
|
215
|
+
say(`no events matching "${grepPattern}" or ${typeEmpty(type)}`);
|
|
216
|
+
// Type-empty gets the hint (only the type branch); a grep-only empty stays the bare
|
|
217
|
+
// no-match line, because a free-text pattern is not an event type to suggest against.
|
|
218
|
+
else
|
|
219
|
+
say(type !== null ? typeEmpty(type) : `no events matching "${grepPattern ?? ""}"`);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
125
223
|
for (const e of shown)
|
|
126
224
|
sayEventLine(e, json);
|
|
127
225
|
if (!follow) {
|
|
128
|
-
// The empty-match line is prose: in JSON mode the empty output is the answer.
|
|
129
|
-
|
|
130
|
-
|
|
226
|
+
// The empty-match line is prose: in JSON mode the empty output is the answer. It fires
|
|
227
|
+
// for either filter, naming the flag's value(s) whose match came up empty so the operator
|
|
228
|
+
// knows what to loosen.
|
|
229
|
+
if (shown.length === 0 && !json && (grepPattern !== null || type !== null)) {
|
|
230
|
+
if (grepPattern !== null && type !== null)
|
|
231
|
+
say(`no events matching "${grepPattern}" or ${typeEmpty(type)}`);
|
|
232
|
+
// Type-empty gets the hint (only the type branch); a grep-only empty stays the bare
|
|
233
|
+
// no-match line, because a free-text pattern is not an event type to suggest against.
|
|
234
|
+
else
|
|
235
|
+
say(type !== null ? typeEmpty(type) : `no events matching "${grepPattern ?? ""}"`);
|
|
236
|
+
}
|
|
131
237
|
return;
|
|
132
238
|
}
|
|
133
239
|
const file = eventsLogPath(root);
|
|
@@ -144,13 +250,13 @@ export async function cmdLogs(root, args) {
|
|
|
144
250
|
const e = parseEventLine(line);
|
|
145
251
|
// The filter holds across rotation: every event the follow callback sees goes through
|
|
146
252
|
// the same match rule as the seeded window.
|
|
147
|
-
if (e && (
|
|
253
|
+
if (e && feedFilter(e))
|
|
148
254
|
sayEventLine(e, json);
|
|
149
255
|
}
|
|
150
256
|
});
|
|
151
257
|
await new Promise(() => { }); // Follow until Ctrl+C.
|
|
152
258
|
}
|
|
153
|
-
/** `tumwater logs --role <id>`: print (and optionally follow) one loop's
|
|
259
|
+
/** `tumwater logs --role <id>`: print (and optionally follow) one loop's agent transcript —
|
|
154
260
|
* run separators, abbreviated thinking, assistant text, and tool calls; with `--prompt` also
|
|
155
261
|
* each run's exact prompt text. Read-only; the raw log is pi's streaming event stream, so only
|
|
156
262
|
* complete renderable events are shown. */
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { fail, say } from "./cli-output.js";
|
|
3
3
|
import { loadConfig } from "../config/config.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { TUMWATER_SOURCE } from "../events/events.js";
|
|
5
|
+
import { notifyCommandEnv, notifyExitGloss } from "../events/notify.js";
|
|
6
|
+
/** The one representative allowlisted event `tumwater notify --test` fires: a failed merge
|
|
7
|
+
* with source `tumwater`. Fixed and opinionated — the goal is to prove the hook runs, not to
|
|
7
8
|
* replay a specific page, so there are no per-type or per-message flags. */
|
|
8
|
-
const NOTIFY_TEST_TYPE = "
|
|
9
|
-
const
|
|
9
|
+
const NOTIFY_TEST_TYPE = "merge_failed";
|
|
10
|
+
const NOTIFY_TEST_SOURCE = TUMWATER_SOURCE;
|
|
10
11
|
const NOTIFY_TEST_MESSAGE = "tumwater notify --test";
|
|
11
12
|
/** How long `tumwater notify --test` waits for the hook before giving up. A hook still running
|
|
12
13
|
* after this long cannot usefully page anyone — the notify channel is meant to be a quick
|
|
@@ -15,15 +16,16 @@ const NOTIFY_TEST_MESSAGE = "tumwater notify --test";
|
|
|
15
16
|
* Generous enough not to misclassify a hook that is merely slow. */
|
|
16
17
|
export const NOTIFY_TEST_TIMEOUT_MS = 10_000;
|
|
17
18
|
/** Run one notify hook to completion, returning its exit code. Unlike the notifier's detached
|
|
18
|
-
* fire-and-forget spawn (src/events/notify.ts), this waits with inherited stdio
|
|
19
|
+
* fire-and-forget spawn (src/events/notify.ts), this waits with inherited stdio by
|
|
20
|
+
* default (the dashboard passes "ignore"), so the
|
|
19
21
|
* operator sees the hook's own output and its exit. A shell that cannot start resolves 127 —
|
|
20
22
|
* the conventional "command not found" code. A hook that does not finish within `timeoutMs`
|
|
21
23
|
* is SIGKILLed and reported as `{ timedOut: true }` rather than awaited forever; the timer is
|
|
22
24
|
* cleared on every outcome so a fast hook never keeps the CLI alive. `timeoutMs` is a test
|
|
23
25
|
* seam; production callers leave it at NOTIFY_TEST_TIMEOUT_MS. */
|
|
24
|
-
export function runNotifyHook(command, env, timeoutMs = NOTIFY_TEST_TIMEOUT_MS) {
|
|
26
|
+
export function runNotifyHook(command, env, timeoutMs = NOTIFY_TEST_TIMEOUT_MS, stdio = "inherit") {
|
|
25
27
|
return new Promise((resolve) => {
|
|
26
|
-
const child = spawn(command, [], { shell: true, stdio
|
|
28
|
+
const child = spawn(command, [], { shell: true, stdio, env });
|
|
27
29
|
let settled = false;
|
|
28
30
|
let timer;
|
|
29
31
|
const finish = (result) => {
|
|
@@ -43,6 +45,23 @@ export function runNotifyHook(command, env, timeoutMs = NOTIFY_TEST_TIMEOUT_MS)
|
|
|
43
45
|
child.on("error", () => finish({ code: 127 }));
|
|
44
46
|
});
|
|
45
47
|
}
|
|
48
|
+
/** Run the saved notify hook once with the synthetic test event, under the NOTIFY_TEST_TIMEOUT_MS
|
|
49
|
+
* bound. Shared by `tumwater notify --test` (inherited stdio) and the dashboard's "Send test"
|
|
50
|
+
* (`"ignore"`, so the hook's output never lands in the server's terminal). */
|
|
51
|
+
export async function testNotifyHook(root, stdio = "inherit") {
|
|
52
|
+
const command = loadConfig(root).notify;
|
|
53
|
+
if (typeof command !== "string" || command === "")
|
|
54
|
+
return { status: "not_configured" };
|
|
55
|
+
const result = await runNotifyHook(command, notifyCommandEnv(NOTIFY_TEST_TYPE, NOTIFY_TEST_SOURCE, NOTIFY_TEST_MESSAGE), NOTIFY_TEST_TIMEOUT_MS, stdio);
|
|
56
|
+
if ("timedOut" in result)
|
|
57
|
+
return { status: "ran", passed: false, message: `notify hook did not finish within ${NOTIFY_TEST_TIMEOUT_MS / 1000}s and was killed` };
|
|
58
|
+
if (result.code === 0)
|
|
59
|
+
return { status: "ran", passed: true, message: "notify hook exited 0" };
|
|
60
|
+
// The sh launch-failure gloss (same wording the real notifier warns with) names the fix
|
|
61
|
+
// for a hook that could not start: a typo'd `notify` command reads as a bare `code 127`
|
|
62
|
+
// without it.
|
|
63
|
+
return { status: "ran", passed: false, message: `notify hook exited with code ${result.code}${notifyExitGloss(result.code)}` };
|
|
64
|
+
}
|
|
46
65
|
/** `tumwater notify --test`: run the configured notify hook once with a synthetic event so an
|
|
47
66
|
* operator can check the push channel before a real page. Read-only toward harness state — no
|
|
48
67
|
* event is logged — and the exit code reflects the hook's own (0 passes, anything else fails). */
|
|
@@ -53,16 +72,12 @@ export async function cmdNotify(root, args) {
|
|
|
53
72
|
if (typeof command !== "string" || command === "")
|
|
54
73
|
fail("notify is not configured (set `notify` in tumwater.json)");
|
|
55
74
|
say(`running the notify hook (TUMWATER_EVENT_TYPE=${NOTIFY_TEST_TYPE}) …`);
|
|
56
|
-
const
|
|
57
|
-
if ("
|
|
58
|
-
|
|
59
|
-
process.exitCode = 1;
|
|
60
|
-
}
|
|
61
|
-
else if (result.code === 0) {
|
|
62
|
-
say("notify hook exited 0");
|
|
63
|
-
}
|
|
75
|
+
const outcome = await testNotifyHook(root);
|
|
76
|
+
if (outcome.status === "not_configured")
|
|
77
|
+
fail("notify is not configured (set `notify` in tumwater.json)");
|
|
64
78
|
else {
|
|
65
|
-
say(
|
|
66
|
-
|
|
79
|
+
say(outcome.message);
|
|
80
|
+
if (!outcome.passed)
|
|
81
|
+
process.exitCode = 1;
|
|
67
82
|
}
|
|
68
83
|
}
|
|
@@ -1,110 +1,14 @@
|
|
|
1
|
-
/** The CLI layer of `tumwater questions` — the
|
|
2
|
-
* questions in QUESTIONS.md numbered, or `answer <n> <decision>` one, which moves its entry
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
1
|
+
/** The CLI layer of `tumwater questions` — the `tumwater questions` command body: list the open
|
|
2
|
+
* questions in QUESTIONS.md numbered, or `answer <n> <decision>` one, which moves its entry from
|
|
3
|
+
* `## Open` to `## Answered` with a dated operator answer. The markdown surgery itself — the
|
|
4
|
+
* fence-aware `### ` block cut and the `## Answered` skeleton — lives in src/backlog/
|
|
5
|
+
* question-answer.ts (the shared QUESTIONS.md core the TUI and dashboard also call); this module
|
|
6
|
+
* is only the command's say/fail/editor jacket over that core, imported by src/cli/
|
|
7
|
+
* cli-query-commands.ts.
|
|
8
|
+
*/
|
|
9
9
|
import { fail, say, sayJson } from "./cli-output.js";
|
|
10
|
-
import {
|
|
11
|
-
import { openQuestionEntries } from "../backlog/backlog.js";
|
|
10
|
+
import { openQuestionList, questionListPayload, UNCLOSED_FENCE_MESSAGE, tryAnswerQuestion } from "../backlog/question-answer.js";
|
|
12
11
|
import { bodyStructureProblem, normalizeEntryBody } from "../backlog/backlog-write.js";
|
|
13
|
-
import { collapseWhitespace, trimLeadingBlankLines, trimTrailingBlankLines, truncate } from "../text/text.js";
|
|
14
|
-
import { readTextOrNull, writeTextAtomic } from "../files/files.js";
|
|
15
|
-
import { formatDate } from "../text/datetime.js";
|
|
16
|
-
import { numberedList } from "../text/markdown.js";
|
|
17
|
-
import { withStateLock } from "../concurrency/lock.js";
|
|
18
|
-
import { primaryWorktreeLockPath } from "../paths.js";
|
|
19
|
-
/** Cap on a question's one-line body preview, so one very long first line cannot dominate
|
|
20
|
-
* the `questions` list. */
|
|
21
|
-
const BODY_PREVIEW_MAX = 100;
|
|
22
|
-
/** The ` -- <ellipsized body>` suffix one prose list line carries for its question's first
|
|
23
|
-
* body line: the `prompt --list` numbered shape, so an operator can tell two open questions
|
|
24
|
-
* apart without opening the file. Truncated through text.ts's truncate — the one ellipsis
|
|
25
|
-
* rule — so the preview is never longer than BODY_PREVIEW_MAX and never splits a surrogate
|
|
26
|
-
* pair. Omitted for a heading-only entry. */
|
|
27
|
-
function firstBodySuffix(body) {
|
|
28
|
-
const line = body
|
|
29
|
-
.split("\n")
|
|
30
|
-
.map((l) => l.trim())
|
|
31
|
-
.find((l) => l !== "");
|
|
32
|
-
if (line === undefined)
|
|
33
|
-
return "";
|
|
34
|
-
return ` — ${truncate(line, BODY_PREVIEW_MAX)}`;
|
|
35
|
-
}
|
|
36
|
-
/** `tumwater questions` with no arguments: the open questions numbered from 1 in file order,
|
|
37
|
-
* one line each (`prompt --list`'s shape), or one line when none are open. Reads through
|
|
38
|
-
* openQuestionEntries, so a missing or unreadable QUESTIONS.md degrades to "no open
|
|
39
|
-
* questions" like every backlog reader. */
|
|
40
|
-
export function openQuestionList(root) {
|
|
41
|
-
const questions = openQuestionEntries(root);
|
|
42
|
-
if (questions.length === 0)
|
|
43
|
-
return "no open questions";
|
|
44
|
-
return numberedList(questions.map((q) => `${q.title}${firstBodySuffix(q.body)}`));
|
|
45
|
-
}
|
|
46
|
-
/** The same list as data for `--json` (the backlog --json pattern: a JSON document in every
|
|
47
|
-
* exit-0 case, never prose): each entry's 1-based position (the number `answer` consumes),
|
|
48
|
-
* its verbatim heading text, and its full body. */
|
|
49
|
-
export function questionListPayload(root) {
|
|
50
|
-
return {
|
|
51
|
-
questions: openQuestionEntries(root).map((q, i) => ({
|
|
52
|
-
position: i + 1,
|
|
53
|
-
title: q.title,
|
|
54
|
-
body: q.body,
|
|
55
|
-
})),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/** Locate QUESTIONS.md's `## Open` and `## Answered` section heading lines (fence-aware) and
|
|
59
|
-
* every open entry's block between them. Returns null when the file has no `## Open` section
|
|
60
|
-
* at all — then there is nothing to answer, whatever the position. */
|
|
61
|
-
function scanQuestions(lines) {
|
|
62
|
-
const fenced = fenceTracker();
|
|
63
|
-
let openIdx = -1;
|
|
64
|
-
let answeredIdx = -1;
|
|
65
|
-
for (let i = 0; i < lines.length; i++) {
|
|
66
|
-
const line = lines[i] ?? "";
|
|
67
|
-
const title = fencedHeadingTitle(line, fenced, "## ");
|
|
68
|
-
if (title !== null) {
|
|
69
|
-
if (title === "Open" && openIdx === -1)
|
|
70
|
-
openIdx = i;
|
|
71
|
-
else if (title === "Answered" && answeredIdx === -1)
|
|
72
|
-
answeredIdx = i;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (openIdx === -1)
|
|
76
|
-
return null;
|
|
77
|
-
// A fresh tracker for this walk: the first loop consumed every line and left the tracker in
|
|
78
|
-
// its end-of-file state, so reusing it here would quote all Open headings whenever a fence
|
|
79
|
-
// after the Open section runs unclosed to EOF — and `answer` would refuse a question the
|
|
80
|
-
// list just showed.
|
|
81
|
-
const fencedBlocks = fenceTracker();
|
|
82
|
-
// The section's end is the next `## ` heading of ANY title, fence-aware — the same boundary
|
|
83
|
-
// rule sectionLines applies, so the entries `--list` numbers (sectionEntries →
|
|
84
|
-
// parseEntryDetails) and the blocks `answer` moves can never disagree about where Open
|
|
85
|
-
// stops. Ending at the first `## Answered` instead would run the walk through any
|
|
86
|
-
// intermediate section and let one answer move another section's entries.
|
|
87
|
-
const openEnd = nextSectionHeading(lines, openIdx + 1, fencedBlocks);
|
|
88
|
-
const blocks = [];
|
|
89
|
-
// A fresh tracker for this walk too: the openEnd walk consumed the lines up to the section's
|
|
90
|
-
// end, so a fence in Open left unclosed at that boundary (an unclosed fence runs openEnd to
|
|
91
|
-
// EOF) would leave the shared tracker inside and quote every `### ` heading — answer would
|
|
92
|
-
// then see no blocks, or answer a later entry, on a file `--list` just numbered.
|
|
93
|
-
const fencedEntries = fenceTracker();
|
|
94
|
-
let current = null;
|
|
95
|
-
for (let i = openIdx + 1; i < openEnd; i++) {
|
|
96
|
-
const line = lines[i] ?? "";
|
|
97
|
-
const entryTitle = fencedHeadingTitle(line, fencedEntries, "### ");
|
|
98
|
-
if (entryTitle !== null) {
|
|
99
|
-
if (current !== null)
|
|
100
|
-
current.end = i;
|
|
101
|
-
current = { start: i, end: openEnd, title: entryTitle };
|
|
102
|
-
blocks.push(current);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return { openIdx, answeredIdx, openEnd, fenceOpen: fencedEntries.open(), blocks };
|
|
106
|
-
}
|
|
107
|
-
export const UNCLOSED_FENCE_MESSAGE = "QUESTIONS.md's ## Open section holds an unclosed code fence — every reader quotes the rest of the file as that entry's body, so answering would corrupt the file; close or remove the fence and re-run";
|
|
108
12
|
/** The `tumwater questions answer` entry point: `tryAnswerQuestion` without a title check, its
|
|
109
13
|
* refusals mapped to `fail` (exit 1) with the CLI's wording. Returns the answered question's
|
|
110
14
|
* title plus the collapsed decision that landed in the file. */
|
|
@@ -113,10 +17,14 @@ export function answerQuestion(root, n, rawDecision) {
|
|
|
113
17
|
}
|
|
114
18
|
/** Map a non-answered outcome to the CLI's `fail` wording; shared by both CLI entry points. */
|
|
115
19
|
function answeredOrFail(n, outcome) {
|
|
116
|
-
if (outcome.status === "missing")
|
|
117
|
-
|
|
20
|
+
if (outcome.status === "missing") {
|
|
21
|
+
const range = outcome.open > 0 ? ` — valid: 1-${outcome.open}` : "";
|
|
22
|
+
fail(`no question at position ${n} (${outcome.open} open)${range}`);
|
|
23
|
+
}
|
|
118
24
|
if (outcome.status === "fenced")
|
|
119
25
|
fail(UNCLOSED_FENCE_MESSAGE);
|
|
26
|
+
if (outcome.status === "moved")
|
|
27
|
+
fail(`question ${n} is now "${outcome.title}" — the list changed while you edited; nothing changed`);
|
|
120
28
|
if (outcome.status !== "answered")
|
|
121
29
|
fail(`no question at position ${n}`);
|
|
122
30
|
return { title: outcome.title, decision: outcome.decision };
|
|
@@ -124,131 +32,28 @@ function answeredOrFail(n, outcome) {
|
|
|
124
32
|
/** `tumwater questions answer <n> --file <path>`: the decision's lines kept (normalizeEntryBody),
|
|
125
33
|
* behind the same structural guard `plan --file` uses, so a stray `## ` line cannot grow a
|
|
126
34
|
* phantom section. Refusals exit 1 through `fail`. */
|
|
127
|
-
export function answerQuestionFromText(root, n, text) {
|
|
35
|
+
export function answerQuestionFromText(root, n, text, expectedTitle) {
|
|
128
36
|
const body = normalizeEntryBody(text);
|
|
129
37
|
if (body === "")
|
|
130
38
|
fail("questions answer needs a decision: the file is blank");
|
|
131
39
|
const problem = bodyStructureProblem(body);
|
|
132
40
|
if (problem !== null)
|
|
133
41
|
fail(`questions answer --file: ${problem}`);
|
|
134
|
-
return answeredOrFail(n, tryAnswerQuestion(root, n, body,
|
|
42
|
+
return answeredOrFail(n, tryAnswerQuestion(root, n, body, expectedTitle, true));
|
|
135
43
|
}
|
|
136
|
-
/** The
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
export function tryAnswerQuestion(root, n, rawDecision, expectedTitle, keepLines = false) {
|
|
143
|
-
return withStateLock(primaryWorktreeLockPath(root), () => answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines));
|
|
44
|
+
/** The line that ends the decision area of the `questions answer <n>` editor buffer. */
|
|
45
|
+
export const ANSWER_EDITOR_CUT = "<!-- tumwater: write your decision above this line; this line and everything below it are dropped -->";
|
|
46
|
+
/** The editor buffer for bare `questions answer <n>`: an empty decision area, the cut line, then
|
|
47
|
+
* the question's heading and body for reference. */
|
|
48
|
+
export function answerEditorBuffer(title, body) {
|
|
49
|
+
return ["", "", ANSWER_EDITOR_CUT, `### ${title}`, body].join("\n");
|
|
144
50
|
}
|
|
145
|
-
/**
|
|
146
|
-
*
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* keeps the lines, behind bodyStructureProblem — the guard `plan --file` uses — applied by its
|
|
152
|
-
* caller; a multi-line decision follows the stamp after a blank line. The Open section that is left empty regains
|
|
153
|
-
* its `_None yet._` placeholder; a file with no
|
|
154
|
-
* `## Answered` section gains one. Returns the outcome (see `AnswerOutcome`); on `answered`
|
|
155
|
-
* the title for the confirmation line, plus the collapsed decision that actually landed in the file so callers
|
|
156
|
-
* report what was written rather than the caller's raw whitespace. */
|
|
157
|
-
function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
|
|
158
|
-
const decision = keepLines ? normalizeEntryBody(rawDecision) : collapseWhitespace(rawDecision);
|
|
159
|
-
const file = path.join(root, "QUESTIONS.md");
|
|
160
|
-
const md = readTextOrNull(file);
|
|
161
|
-
const lines = md === null ? null : md.split("\n");
|
|
162
|
-
const scan = lines === null ? null : scanQuestions(lines);
|
|
163
|
-
const openCount = scan === null ? 0 : scan.blocks.length;
|
|
164
|
-
if (scan === null || n < 1 || n > scan.blocks.length) {
|
|
165
|
-
return { status: "missing", open: openCount };
|
|
166
|
-
}
|
|
167
|
-
const scanned = scan;
|
|
168
|
-
// A fence inside ## Open left unclosed runs to EOF (CommonMark), so the whole tail — the
|
|
169
|
-
// real ## Answered section included — reads as quoted body of the still-open entry. Acting
|
|
170
|
-
// on that degraded read moves Answered content inside the moved block's fence and grows a
|
|
171
|
-
// second ## Answered at the file's end, while the question still lists as open: refuse
|
|
172
|
-
// instead, and tell the operator the one local repair (close or remove the fence).
|
|
173
|
-
if (scanned.fenceOpen) {
|
|
174
|
-
return { status: "fenced" };
|
|
175
|
-
}
|
|
176
|
-
const block = scanned.blocks[n - 1];
|
|
177
|
-
if (expectedTitle !== undefined && block.title !== expectedTitle)
|
|
178
|
-
return { status: "moved", title: block.title };
|
|
179
|
-
const moved = lines.slice(block.start, block.end);
|
|
180
|
-
trimTrailingBlankLines(moved);
|
|
181
|
-
// Cut the block out of ## Open: everything before it, everything from its end on. When the
|
|
182
|
-
// section is left with no content at all, restore the `_None yet._` placeholder the skeleton
|
|
183
|
-
// carries, so an empty outbox still reads as an intentionally empty section.
|
|
184
|
-
// Emptiness is judged against the Open section only (openIdx+1 .. openEnd): with an
|
|
185
|
-
// intermediate section between Open and Answered, judging against answeredIdx would keep
|
|
186
|
-
// the placeholder from returning and, worse, the empty rebuild would drop that section.
|
|
187
|
-
const openContent = lines.slice(scanned.openIdx + 1, scanned.openEnd);
|
|
188
|
-
const remaining = openContent.filter((_, i) => i < block.start - scanned.openIdx - 1 || i >= block.end - scanned.openIdx - 1);
|
|
189
|
-
const openEmpty = remaining.every((l) => l.trim() === "");
|
|
190
|
-
const rebuilt = [];
|
|
191
|
-
if (openEmpty) {
|
|
192
|
-
rebuilt.push(...lines.slice(0, scanned.openIdx + 1), "", "_None yet._",
|
|
193
|
-
// Resume at the section that followed Open (openEnd, not answeredIdx — an intermediate
|
|
194
|
-
// section between the two must survive) with the blank separator line the dropped Open
|
|
195
|
-
// content used to carry, so `_None yet._` never reads as a paragraph glued to a heading.
|
|
196
|
-
"", ...lines.slice(scanned.openEnd));
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
rebuilt.push(...lines.slice(0, block.start), ...lines.slice(block.end));
|
|
200
|
-
}
|
|
201
|
-
// Append the block at the end of ## Answered: before the next `## ` heading when one
|
|
202
|
-
// follows, at the file's end otherwise — the section is last in the documented skeleton.
|
|
203
|
-
const stamp = `**Answered ${formatDate(new Date())} by operator:**`;
|
|
204
|
-
const stamped = decision.includes("\n") ? [stamp, "", ...decision.split("\n")] : [`${stamp} ${decision}`];
|
|
205
|
-
const answerLines = moved.concat("", ...stamped, "");
|
|
206
|
-
if (scanned.answeredIdx === -1) {
|
|
207
|
-
// No ## Answered section: grow the file's end with the skeleton section plus the block.
|
|
208
|
-
let out = rebuilt.join("\n");
|
|
209
|
-
if (out !== "" && !out.endsWith("\n"))
|
|
210
|
-
out += "\n";
|
|
211
|
-
out += `\n## Answered\n\n${answerLines.join("\n")}`;
|
|
212
|
-
writeTextAtomic(file, out);
|
|
213
|
-
return { status: "answered", title: block.title, decision };
|
|
214
|
-
}
|
|
215
|
-
const fenced2 = fenceTracker();
|
|
216
|
-
let answeredAt = -1;
|
|
217
|
-
const placeholderAt = [];
|
|
218
|
-
for (let i = 0; i < rebuilt.length; i++) {
|
|
219
|
-
const line = rebuilt[i] ?? "";
|
|
220
|
-
if (fenced2.inside(line))
|
|
221
|
-
continue;
|
|
222
|
-
if (answeredAt === -1) {
|
|
223
|
-
if (fencedHeadingTitle(line, fenced2, "## ") === "Answered")
|
|
224
|
-
answeredAt = i;
|
|
225
|
-
}
|
|
226
|
-
else if (fencedHeadingTitle(line, fenced2, "## ") !== null) {
|
|
227
|
-
break; // The Answered section's end: a placeholder beyond it is another section's.
|
|
228
|
-
}
|
|
229
|
-
else if (line.trim() === "_None yet._") {
|
|
230
|
-
placeholderAt.push(i);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
// A section holding only its `_None yet._` skeleton placeholder must lose the placeholder
|
|
234
|
-
// when a real entry lands there: the placeholder means "no entries", so leaving it above
|
|
235
|
-
// the moved block makes ## Answered claim to be empty while holding an answered question.
|
|
236
|
-
// Fence-aware: a `_None yet._` line quoted inside a fenced block is quoted content, never
|
|
237
|
-
// the placeholder.
|
|
238
|
-
if (placeholderAt.length > 0) {
|
|
239
|
-
const dropped = new Set(placeholderAt);
|
|
240
|
-
const stripped = rebuilt.filter((_, i) => !dropped.has(i));
|
|
241
|
-
rebuilt.length = 0;
|
|
242
|
-
rebuilt.push(...stripped);
|
|
243
|
-
}
|
|
244
|
-
const insertAt = nextSectionHeading(rebuilt, answeredAt + 1, fenced2);
|
|
245
|
-
const head = rebuilt.slice(0, insertAt);
|
|
246
|
-
const tail = rebuilt.slice(insertAt);
|
|
247
|
-
trimTrailingBlankLines(head);
|
|
248
|
-
trimLeadingBlankLines(tail);
|
|
249
|
-
const result = head.concat("", answerLines, tail);
|
|
250
|
-
writeTextAtomic(file, result.join("\n"));
|
|
251
|
-
return { status: "answered", title: block.title, decision };
|
|
51
|
+
/** The decision an edited `answerEditorBuffer` holds: the text before the first cut line (all of
|
|
52
|
+
* it when the operator deleted the line). */
|
|
53
|
+
export function decisionFromEditorBuffer(text) {
|
|
54
|
+
const lines = text.split("\n");
|
|
55
|
+
const cut = lines.findIndex((l) => l.trim() === ANSWER_EDITOR_CUT);
|
|
56
|
+
return (cut === -1 ? lines : lines.slice(0, cut)).join("\n");
|
|
252
57
|
}
|
|
253
58
|
/** The one confirmation line `questions answer` prints in prose mode: which position was
|
|
254
59
|
* answered and which entry moved — `--json` swaps it for the answer result payload. */
|