tumwater 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +13 -0
- package/dist/src/agent/claude/claude-backend.js +2 -2
- package/dist/src/agent/claude/claude-stream.js +3 -2
- package/dist/src/agent/codex/codex-backend.js +3 -3
- package/dist/src/agent/codex/codex-stream.js +3 -1
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/backlog/backlog-archive.js +60 -25
- package/dist/src/backlog/backlog-cli-view.js +3 -28
- package/dist/src/backlog/backlog-cli.js +9 -64
- package/dist/src/backlog/backlog-commit.js +17 -9
- package/dist/src/backlog/backlog-eligibility.js +44 -26
- package/dist/src/backlog/backlog-parts.js +23 -13
- package/dist/src/backlog/backlog-release.js +33 -16
- package/dist/src/baseline/main-guardrails.js +0 -73
- package/dist/src/baseline/main-red.js +31 -21
- package/dist/src/baseline/main-watch.js +82 -34
- package/dist/src/brief/brief-edit.js +3 -1
- package/dist/src/budget/budget-fallback-view.js +288 -0
- package/dist/src/budget/budget-wiring.js +158 -0
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/dep-install.js +64 -1
- package/dist/src/cli/cli-args.js +63 -0
- package/dist/src/cli/cli-flag-specs.js +33 -0
- package/dist/src/cli/cli-query-commands.js +16 -41
- package/dist/src/cli/cli-review-commands.js +113 -0
- package/dist/src/cli/cli-role-command.js +118 -13
- package/dist/src/cli/cli-start-summary.js +10 -3
- package/dist/src/cli/cli-start.js +33 -5
- package/dist/src/cli/completion.js +21 -1
- package/dist/src/cli/config-commands.js +17 -8
- package/dist/src/cli/help.js +74 -14
- package/dist/src/cli/log-commands.js +26 -13
- package/dist/src/cli/question-commands.js +7 -1
- package/dist/src/cli.js +71 -6
- package/dist/src/collections.js +31 -4
- package/dist/src/config/config-editable-keys.js +3 -0
- package/dist/src/config/config-fallbacks.js +3 -2
- package/dist/src/config/config-validation.js +47 -42
- package/dist/src/config/config-views.js +10 -2
- package/dist/src/config/config-write.js +33 -1
- package/dist/src/config/config.js +7 -2
- package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
- package/dist/src/doctor/doctor-backlog-plans.js +214 -79
- package/dist/src/doctor/doctor-backlog.js +155 -22
- package/dist/src/doctor/doctor-check-names.js +5 -1
- package/dist/src/doctor/doctor-checks.js +10 -15
- package/dist/src/doctor/doctor-claude-checks.js +6 -9
- package/dist/src/doctor/doctor-codex-checks.js +6 -9
- package/dist/src/doctor/doctor-contention-checks.js +244 -118
- package/dist/src/doctor/doctor-fallback-checks.js +130 -0
- package/dist/src/doctor/doctor-holds.js +4 -6
- package/dist/src/doctor/doctor-model-checks.js +45 -200
- package/dist/src/doctor/doctor-notify.js +7 -2
- package/dist/src/doctor/doctor-opencode-checks.js +7 -5
- package/dist/src/doctor/doctor-pi-checks.js +62 -0
- package/dist/src/doctor/doctor-project-checks.js +106 -12
- package/dist/src/doctor/doctor.js +10 -5
- package/dist/src/doctor/probe.js +14 -1
- package/dist/src/events/event-format.js +7 -6
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-read.js +13 -0
- package/dist/src/events/event-window.js +20 -2
- package/dist/src/failure/failure-data.js +9 -4
- package/dist/src/failure/failure-render.js +8 -8
- package/dist/src/failure/time-spend.js +0 -64
- package/dist/src/files/file-queue.js +17 -0
- package/dist/src/gates/budget-gates.js +27 -257
- package/dist/src/gates/gate-polls.js +49 -141
- package/dist/src/gates/maintenance-quota.js +27 -17
- package/dist/src/git/commit-message.js +12 -0
- package/dist/src/git/git.js +18 -0
- package/dist/src/git/slot-salvage.js +3 -3
- package/dist/src/gui/gui-backlog-commands.js +3 -3
- package/dist/src/gui/gui-backlog-endpoints.js +5 -4
- package/dist/src/gui/gui-command.js +19 -1
- package/dist/src/gui/gui-config-commands.js +75 -9
- package/dist/src/gui/gui-endpoint-commands.js +2 -2
- package/dist/src/gui/gui-operator-commands.js +134 -7
- package/dist/src/gui/gui-prompt-commands.js +15 -6
- package/dist/src/gui/gui-report-endpoints.js +10 -3
- package/dist/src/gui/gui-server.js +16 -2
- package/dist/src/history/history-queued.js +8 -0
- package/dist/src/history/history.js +2 -5
- package/dist/src/inbox/inbox-top.js +4 -9
- package/dist/src/merge/merge-core.js +2 -25
- package/dist/src/merge/merge-drain.js +22 -135
- package/dist/src/merge/merge-pipeline.js +1 -1
- package/dist/src/merge/merge-post-review.js +154 -30
- package/dist/src/merge/merge-queue-render.js +11 -12
- package/dist/src/merge/merge-queue.js +22 -1
- package/dist/src/merge/merge-requeue.js +1 -0
- package/dist/src/merge/merge-stack.js +1 -7
- package/dist/src/merge/merge-to-main.js +1 -9
- package/dist/src/merge/merge-top.js +33 -19
- package/dist/src/merge/merge-verify.js +64 -9
- package/dist/src/notes-cli.js +8 -3
- package/dist/src/orchestrator/orchestrator-poll.js +47 -15
- package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
- package/dist/src/orchestrator/orchestrator.js +5 -4
- package/dist/src/orchestrator/scheduling-scope.js +51 -7
- package/dist/src/orchestrator/scheduling-skip.js +15 -5
- package/dist/src/paths.js +16 -6
- package/dist/src/pi/pi-backend.js +2 -2
- package/dist/src/process/run-marker.js +8 -0
- package/dist/src/prompt/gate-notes.js +1 -1
- package/dist/src/prompt/gate-prompts.js +30 -7
- package/dist/src/prompt/notes-backlog.js +13 -10
- package/dist/src/prompt/prompt.js +3 -1
- package/dist/src/report/report-data.js +49 -69
- package/dist/src/report/report-fold.js +11 -12
- package/dist/src/report/report-render.js +6 -4
- package/dist/src/report/report.js +2 -5
- package/dist/src/review/diff-rules.js +53 -0
- package/dist/src/review/known-flakes.js +12 -0
- package/dist/src/review/post-merge-review-accept.js +51 -0
- package/dist/src/review/post-merge-review-render.js +112 -0
- package/dist/src/review/post-merge-review.js +157 -24
- package/dist/src/review/protected-edits.js +22 -7
- package/dist/src/review/quarantine-render.js +65 -0
- package/dist/src/review/review.js +33 -49
- package/dist/src/roles/role-catalog.js +60 -2
- package/dist/src/roles/role-guidance.js +10 -0
- package/dist/src/roles/role-labels.js +6 -3
- package/dist/src/roles/role-render.js +43 -8
- package/dist/src/roles/role-view.js +36 -9
- package/dist/src/roles/roles.js +3 -3
- package/dist/src/run/ci-parity.js +121 -0
- package/dist/src/run/qa-coverage.js +50 -15
- package/dist/src/run/run-detail-data.js +9 -13
- package/dist/src/run/run-detail-render.js +1 -8
- package/dist/src/run/run-finalize.js +1 -0
- package/dist/src/run/run-prompt.js +14 -0
- package/dist/src/run/run-stage.js +66 -37
- package/dist/src/run/run-verdict.js +14 -1
- package/dist/src/run/stage-check.js +17 -11
- package/dist/src/run/stage-checks-shared.js +65 -0
- package/dist/src/run/stage-name-checks.js +204 -0
- package/dist/src/run/stage-path-checks.js +16 -515
- package/dist/src/run/stage-prose-checks.js +261 -0
- package/dist/src/runner/leftover.js +50 -19
- package/dist/src/runner/revision.js +12 -23
- package/dist/src/runner/runner-outcome.js +4 -2
- package/dist/src/runner/runner-recovery.js +18 -3
- package/dist/src/runner/runner-run.js +21 -8
- package/dist/src/runner/runner-state.js +4 -3
- package/dist/src/scheduling/claim-entries.js +49 -0
- package/dist/src/status/status-data.js +6 -1
- package/dist/src/status/status-polls.js +15 -0
- package/dist/src/status/status-roles.js +11 -0
- package/dist/src/team/reclaim.js +2 -2
- package/dist/src/text/datetime.js +3 -2
- package/dist/src/text/phrases.js +6 -3
- package/dist/src/text/text.js +11 -0
- package/dist/src/ui/badges.js +34 -1
- package/dist/src/ui/gui/gui-client-backlog.js +25 -3
- package/dist/src/ui/gui/gui-client-boot.js +63 -41
- package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
- package/dist/src/ui/gui/gui-client-drawer.js +29 -4
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-history.js +74 -72
- package/dist/src/ui/gui/gui-client-labels.js +5 -1
- package/dist/src/ui/gui/gui-client-operator.js +6 -2
- package/dist/src/ui/gui/gui-client-pending.js +188 -11
- package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
- package/dist/src/ui/gui/gui-client-report.js +20 -5
- package/dist/src/ui/gui/gui-client-settings.js +109 -3
- package/dist/src/ui/gui/gui-client-team.js +11 -0
- package/dist/src/ui/gui/gui-client.js +11 -1
- package/dist/src/ui/gui/gui-page.js +1 -0
- package/dist/src/ui/gui/gui-styles.js +4 -0
- package/dist/src/ui/run-progress-model.js +2 -1
- package/dist/src/ui/status-payload.js +237 -9
- package/dist/src/ui/status-render.js +12 -4
- package/dist/src/ui/team-alerts.js +512 -37
- package/dist/src/ui/tone.js +7 -0
- package/dist/src/ui/tui/tui-backlog.js +7 -5
- package/dist/src/ui/tui/tui-editor-attach.js +1 -7
- package/dist/src/ui/tui/tui-editor-backends.js +1 -6
- package/dist/src/ui/tui/tui-editor-brief.js +4 -24
- package/dist/src/ui/tui/tui-editor-budget.js +1 -8
- package/dist/src/ui/tui/tui-editor-check.js +29 -0
- package/dist/src/ui/tui/tui-editor-checks.js +28 -0
- package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
- package/dist/src/ui/tui/tui-editor-config.js +19 -6
- package/dist/src/ui/tui/tui-editor-entry.js +3 -17
- package/dist/src/ui/tui/tui-editor-instances.js +9 -0
- package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
- package/dist/src/ui/tui/tui-editor-interval.js +18 -7
- package/dist/src/ui/tui/tui-editor-notes.js +6 -24
- package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
- package/dist/src/ui/tui/tui-editor-retire.js +1 -7
- package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
- package/dist/src/ui/tui/tui-editor-session.js +41 -0
- package/dist/src/ui/tui/tui-editor-slots.js +1 -0
- package/dist/src/ui/tui/tui-editors.js +10 -1
- package/dist/src/ui/tui/tui-frame.js +18 -6
- package/dist/src/ui/tui/tui-keys.js +9 -0
- package/dist/src/ui/tui/tui-pane-activity.js +21 -8
- package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +113 -3
- package/dist/src/ui/tui/tui-pane-history.js +44 -0
- package/dist/src/ui/tui/tui-pane-pending.js +285 -56
- package/dist/src/ui/tui/tui-pane-queued.js +7 -2
- package/dist/src/ui/tui/tui-pane-report.js +9 -3
- package/dist/src/ui/tui/tui-pane-session.js +9 -0
- package/dist/src/ui/tui/tui-pane.js +16 -10
- package/dist/src/ui/tui/tui-panes.js +56 -7
- package/dist/src/ui/tui/tui.js +8 -5
- package/dist/src/verdict/fix-claim.js +47 -1
- package/dist/src/verdict/reply-contract.js +35 -0
- package/package.json +1 -1
- package/dist/src/baseline/main-culprit.js +0 -94
- package/dist/src/baseline/main-revert.js +0 -133
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import { andMore, readDoc,
|
|
1
|
+
import { andMore, readDoc, readBacklogChecked, FIX_CLAIMS_CHECKED, FIX_CLAIM_HEADING_MAX, REFUSAL_NOTE_MAX } from "./doctor-backlog.js";
|
|
2
|
+
import { cachedByStat } from "../files/stat-cache.js";
|
|
2
3
|
import { VALIDATION_GAP_TAGS } from "../roles/role-guidance.js";
|
|
3
|
-
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { truncate } from "../text/text.js";
|
|
6
|
-
import { donePlans, plannedPlanEntries } from "../backlog/backlog.js";
|
|
7
|
-
import { entryHold, heldCount } from "../backlog/backlog-eligibility.js";
|
|
6
|
+
import { donePlans, fixedBugEntries, openBugEntries, plannedPlanEntries } from "../backlog/backlog.js";
|
|
7
|
+
import { entryHold, entryKindLabel, heldCount } from "../backlog/backlog-eligibility.js";
|
|
8
8
|
import { planDependencyProblems } from "../backlog/backlog-parts.js";
|
|
9
|
-
import { BACKLOG_FILES, validationGapTag } from "../backlog/backlog-md.js";
|
|
9
|
+
import { BACKLOG_FILES, FINISHED_ARCHIVES, validationGapTag } from "../backlog/backlog-md.js";
|
|
10
|
+
import { statOrNull } from "../files/files.js";
|
|
10
11
|
import { duplicateHeadings, strandedPlanEntriesFor } from "../backlog/backlog-structure.js";
|
|
11
12
|
import { liveFinishedHistory } from "../backlog/backlog-archive.js";
|
|
12
|
-
import { fixSymbols,
|
|
13
|
+
import { fixSymbols, missingSymbolNames, cachedSourceHaystack, unbackedSymbols } from "../verdict/fix-claim.js";
|
|
13
14
|
import { plural } from "../text/phrases.js";
|
|
14
15
|
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
15
16
|
/** The doctor's PLANS.md-structure and fix-claim checks, split out of doctor-backlog.ts: the
|
|
16
17
|
* checks that re-verify BUGS.md's newest Fixed records against the tree (checkFixClaims,
|
|
17
18
|
* checkValidationGaps) and the PLANS.md / backlog-file integrity checks that read sections and
|
|
18
19
|
* clauses rather than a single document's entries (checkStrandedPlans, checkLiveFinished,
|
|
19
|
-
* checkBacklogHeadings,
|
|
20
|
-
* (readDoc and its checked wrapper
|
|
20
|
+
* checkBacklogHeadings, checkStalledEntries, checkPlanDependencies). They import the shared helpers
|
|
21
|
+
* (readDoc and its checked wrapper readBacklogChecked, andMore) from doctor-backlog.ts and the
|
|
21
22
|
* heading-trim bounds,
|
|
22
23
|
* and all of them warn instead of failing: damaged documentation is operator signal, not a
|
|
23
24
|
* broken environment. doctor.ts composes them beside the open-item and queue checks; the shared
|
|
@@ -33,28 +34,13 @@ import { okOutcome, warnOutcome } from "./outcome.js";
|
|
|
33
34
|
* legitimate. A warn, never a fail (the checkFallbackModel precedent): a suspicious record is
|
|
34
35
|
* operator signal, not a broken environment. */
|
|
35
36
|
export function checkFixClaims(root) {
|
|
36
|
-
const doc =
|
|
37
|
+
const doc = readBacklogChecked(root, "BUGS.md");
|
|
37
38
|
if (typeof doc !== "string")
|
|
38
39
|
return doc;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// walks where one suffices.
|
|
42
|
-
const records = fixedEntriesAt(root, doc).slice(0, FIX_CLAIMS_CHECKED);
|
|
43
|
-
// The haystack walks src/, test/ and scripts/: build it only once a record names something.
|
|
44
|
-
let haystack;
|
|
45
|
-
const phantoms = [];
|
|
46
|
-
for (const { title: heading, body } of records) {
|
|
47
|
-
const symbols = fixSymbols(body);
|
|
48
|
-
if (symbols.length === 0)
|
|
49
|
-
continue;
|
|
50
|
-
haystack ??= sourceHaystack(root);
|
|
51
|
-
const missing = unbackedSymbols(root, symbols, haystack);
|
|
52
|
-
if (missing.length === symbols.length)
|
|
53
|
-
phantoms.push({ heading, missing });
|
|
54
|
-
}
|
|
55
|
-
const [first, ...rest] = phantoms;
|
|
40
|
+
const problems = fixClaimProblems(root);
|
|
41
|
+
const [first, ...rest] = problems;
|
|
56
42
|
if (!first)
|
|
57
|
-
return okOutcome(`newest ${
|
|
43
|
+
return okOutcome(`newest ${fixClaimRecords(root).length} Fixed record(s) name code that exists on this tree`);
|
|
58
44
|
// falseFixReason's message shape: the heading, then up to 3 missing names.
|
|
59
45
|
const { heading, missing } = first;
|
|
60
46
|
const names = missingSymbolNames(missing);
|
|
@@ -63,6 +49,76 @@ export function checkFixClaims(root) {
|
|
|
63
49
|
return warnOutcome(`BUGS.md records "${heading}" as Fixed, but none of the symbols its Fix paragraph names ` +
|
|
64
50
|
`exist on this tree: ${names}${more} — land the fix or keep the bug Open / refresh a stale record`);
|
|
65
51
|
}
|
|
52
|
+
/** The fix-claim rows shared by the doctor's check and the always-on alert: each newest Fixed
|
|
53
|
+
* record classified through fixSymbols and unbackedSymbols — the gate's own parsing and
|
|
54
|
+
* existence rules — against ONE cachedSourceHaystack built for the whole scan, keeping a record
|
|
55
|
+
* only when EVERY symbol its Fix paragraph names is absent, and skipping a record that names no
|
|
56
|
+
* symbols (pure-documentation fixes are legitimate). */
|
|
57
|
+
function classifyFixClaims(records, root) {
|
|
58
|
+
const problems = [];
|
|
59
|
+
let haystack;
|
|
60
|
+
for (const { title, body } of records) {
|
|
61
|
+
const symbols = fixSymbols(body);
|
|
62
|
+
if (symbols.length === 0)
|
|
63
|
+
continue;
|
|
64
|
+
haystack ??= cachedSourceHaystack(root);
|
|
65
|
+
const missing = unbackedSymbols(root, symbols, haystack);
|
|
66
|
+
if (missing.length === symbols.length)
|
|
67
|
+
problems.push({ heading: title, missing });
|
|
68
|
+
}
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
/** The ONE shared fix-claim scan: the newest Fixed records (bounded to FIX_CLAIMS_CHECKED so
|
|
72
|
+
* pre-convention history never warns forever) as fixedBugEntries reads them — the stat-cached
|
|
73
|
+
* section reader BUGS.md + BUGS-FIXED.md, bodies kept whole — classified through
|
|
74
|
+
* classifyFixClaims. Reading through this same reader (and not a one-off fixedEntriesAt walk) is
|
|
75
|
+
* what keeps the alert and the doctor on the identical problem set; there is only one scan. */
|
|
76
|
+
function fixClaimRecords(root) {
|
|
77
|
+
return fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED);
|
|
78
|
+
}
|
|
79
|
+
/** The problems the ONE shared fix-claim scan found among the newest Fixed records at `root`
|
|
80
|
+
* (empty when BUGS.md is unreadable or every newest Fixed record names at least one live
|
|
81
|
+
* symbol). Feeds the always-on amber alert through the stat-cached fixedBugEntries reader and
|
|
82
|
+
* cachedSourceHaystack, so it stays in step with the doctor and adds no re-read to the 1 Hz TUI
|
|
83
|
+
* and status-poll path. */
|
|
84
|
+
export function fixClaimProblems(root) {
|
|
85
|
+
return classifyFixClaims(fixClaimRecords(root), root);
|
|
86
|
+
}
|
|
87
|
+
/** The validation-gap rows shared by the doctor's check and the always-on alert: each newest
|
|
88
|
+
* Fixed record classified against the closed `**Validation gap:**` vocabulary through
|
|
89
|
+
* validationGapTag, which is fence-aware — suspects (missing/unrecognized) first, then
|
|
90
|
+
* `unpinned`, so the first row is the one the doctor's message names and the alert's reason
|
|
91
|
+
* picks. */
|
|
92
|
+
function classifyValidationGaps(records) {
|
|
93
|
+
const suspects = [];
|
|
94
|
+
const unpinned = [];
|
|
95
|
+
for (const { title, body } of records) {
|
|
96
|
+
const tag = validationGapTag(body);
|
|
97
|
+
if (tag === null)
|
|
98
|
+
suspects.push({ title, kind: "missing" });
|
|
99
|
+
else if (!VALIDATION_GAP_TAGS.includes(tag))
|
|
100
|
+
suspects.push({ title, kind: "unrecognized", tag });
|
|
101
|
+
else if (tag === "unclear-invariant" && !body.toLowerCase().includes("docs/invariants.md"))
|
|
102
|
+
unpinned.push({ title, kind: "unpinned" });
|
|
103
|
+
}
|
|
104
|
+
return [...suspects, ...unpinned];
|
|
105
|
+
}
|
|
106
|
+
/** The ONE scan the doctor's `validation gaps` check and the always-on alert both hand their
|
|
107
|
+
* rows from: the newest Fixed records (bounded to FIX_CLAIMS_CHECKED so pre-convention history
|
|
108
|
+
* never warns forever) as fixedBugEntries reads them — the stat-cached section reader BUGS.md +
|
|
109
|
+
* BUGS-FIXED.md, bodies kept whole — classified through classifyValidationGaps. Reading both
|
|
110
|
+
* through this same reader (and not the doctor's one-off fixedEntriesAt/fixedSection walk) is
|
|
111
|
+
* what keeps the alert and the doctor on the identical problem set; there is only one scan. */
|
|
112
|
+
function validationGapRecords(root) {
|
|
113
|
+
return fixedBugEntries(root).slice(0, FIX_CLAIMS_CHECKED);
|
|
114
|
+
}
|
|
115
|
+
/** The problems the ONE shared validation-gap scan found among the newest Fixed records at `root`
|
|
116
|
+
* (empty when BUGS.md/BUGS-FIXED.md are unreadable or every newest Fixed record carries a known
|
|
117
|
+
* tag/trace). Feeds the always-on amber alert through the stat-cached fixedBugEntries reader, so
|
|
118
|
+
* it stays in step with the doctor and adds no re-read to the 1 Hz TUI and status-poll path. */
|
|
119
|
+
export function validationGapProblems(root) {
|
|
120
|
+
return classifyValidationGaps(validationGapRecords(root));
|
|
121
|
+
}
|
|
66
122
|
/** The fixed-record validation-gap twin of checkFixClaims, standing in for plans/repair-traces.md's
|
|
67
123
|
* invariant 2 ("the vocabulary is closed and lives in one place"): the bugfix guidance tells every
|
|
68
124
|
* run to write exactly one `**Validation gap:**` line with a known tag, and `none` rather than no
|
|
@@ -78,21 +134,16 @@ export function checkFixClaims(root) {
|
|
|
78
134
|
* may spuriously warn). A warn, never a fail, like its sibling: a suspect record is
|
|
79
135
|
* operator signal, not a broken environment. */
|
|
80
136
|
export function checkValidationGaps(root) {
|
|
81
|
-
|
|
137
|
+
// The doctor's check reads BUGS.md itself so it can return its no-file outcome (a missing
|
|
138
|
+
// BUGS.md — nothing to verify); the classification then runs on the SAME fixedBugEntries
|
|
139
|
+
// reader the always-on alert feeds from, so the two cannot drift on the problem set. There
|
|
140
|
+
// is only one scan (validationGapRecords).
|
|
141
|
+
const doc = readBacklogChecked(root, "BUGS.md");
|
|
82
142
|
if (typeof doc !== "string")
|
|
83
143
|
return doc;
|
|
84
|
-
const
|
|
85
|
-
const suspects =
|
|
86
|
-
const unpinned =
|
|
87
|
-
for (const { title, body } of records) {
|
|
88
|
-
const tag = validationGapTag(body);
|
|
89
|
-
if (tag === null)
|
|
90
|
-
suspects.push({ title, problem: "missing" });
|
|
91
|
-
else if (!VALIDATION_GAP_TAGS.includes(tag))
|
|
92
|
-
suspects.push({ title, problem: "unrecognized", tag });
|
|
93
|
-
else if (tag === "unclear-invariant" && !body.toLowerCase().includes("docs/invariants.md"))
|
|
94
|
-
unpinned.push({ title });
|
|
95
|
-
}
|
|
144
|
+
const problems = classifyValidationGaps(validationGapRecords(root));
|
|
145
|
+
const suspects = problems.filter((p) => p.kind !== "unpinned");
|
|
146
|
+
const unpinned = problems.filter((p) => p.kind === "unpinned");
|
|
96
147
|
const [first, ...rest] = suspects;
|
|
97
148
|
if (!first) {
|
|
98
149
|
if (unpinned.length > 0) {
|
|
@@ -102,10 +153,10 @@ export function checkValidationGaps(root) {
|
|
|
102
153
|
`docs/invariants.md section — record the reconstructed guarantee there (a "Pinned by" line) in the ` +
|
|
103
154
|
`same commit so the next bug in that area does not reconstruct it again${more}`);
|
|
104
155
|
}
|
|
105
|
-
return okOutcome(`newest ${
|
|
156
|
+
return okOutcome(`newest ${validationGapRecords(root).length} Fixed record(s) carry a known Validation gap tag`);
|
|
106
157
|
}
|
|
107
158
|
const more = andMore(rest, (s) => `"${truncate(s.title, FIX_CLAIM_HEADING_MAX)}"`, "record(s)");
|
|
108
|
-
const what = first.
|
|
159
|
+
const what = first.kind === "missing"
|
|
109
160
|
? `BUGS.md record "${first.title}" has no **Validation gap:** line`
|
|
110
161
|
: `BUGS.md record "${first.title}" names unrecognized Validation gap tag "${first.tag}"`;
|
|
111
162
|
const valid = VALIDATION_GAP_TAGS.join(", ");
|
|
@@ -120,7 +171,7 @@ export function checkValidationGaps(root) {
|
|
|
120
171
|
* the primary checkout IS main's tree — like checkFixClaims. A warn, never a fail: a misplaced
|
|
121
172
|
* heading is operator signal, not a broken environment. */
|
|
122
173
|
export function checkStrandedPlans(root) {
|
|
123
|
-
const doc =
|
|
174
|
+
const doc = readBacklogChecked(root, "PLANS.md");
|
|
124
175
|
if (typeof doc !== "string")
|
|
125
176
|
return doc;
|
|
126
177
|
// PLANS-DONE.md too: finished plans are archived there (plans/backlog-archive.md), and a plan
|
|
@@ -152,6 +203,94 @@ export function checkLiveFinished(root) {
|
|
|
152
203
|
return warnOutcome(`${parts.join("; ")} — every run that reads the file pays for them; ` +
|
|
153
204
|
"`tumwater archive` moves them to the archive files and commits them (a starting team does too)");
|
|
154
205
|
}
|
|
206
|
+
/** The always-on `backlog-headings` alert polls this on every observer surface and every
|
|
207
|
+
* observer tick, so the per-file read must be stat-cached: a steady-state poll pays one stat
|
|
208
|
+
* per file, not a full re-read of every BACKLOG_FILES member — the archives grow without bound
|
|
209
|
+
* (PLANS-DONE.md and BUGS-FIXED.md run to MBs), and an uncached read on every poll would copy
|
|
210
|
+
* them wholesale each time, unlike the stat-cached section readers the rest of the poll path
|
|
211
|
+
* uses. cachedByStat serves an unchanged file from cache (dev/ino/mtime/size) and re-reads on
|
|
212
|
+
* a change, so this shares the poll path's freshness contract. The one loop over BACKLOG_FILES
|
|
213
|
+
* that feeds both checkBacklogHeadings and the always-on `backlog-headings` team alert: absent
|
|
214
|
+
* files (an early repo) contribute nothing, an existing file that cannot be read adds
|
|
215
|
+
* `${file} (unreadable)` to `rows` (its duplicate headings, if any, are invisible to every
|
|
216
|
+
* reader), and each duplicate `## ` title joins both `duplicates` and, as
|
|
217
|
+
* `${file} "## ${title}"`, `rows` — which therefore keeps BACKLOG_FILES order, the order the
|
|
218
|
+
* doctor's `backlog headings` row lists its messages in. Two public functions share this so
|
|
219
|
+
* they cannot drift on the file set, the unreadable clause, or the headings. */
|
|
220
|
+
function scanBacklogHeadings(root) {
|
|
221
|
+
const duplicates = [];
|
|
222
|
+
const rows = [];
|
|
223
|
+
for (const file of BACKLOG_FILES) {
|
|
224
|
+
const p = path.join(root, file);
|
|
225
|
+
const scan = cachedByStat(backlogHeadingFileCache, p, p, () => {
|
|
226
|
+
const read = readDoc(p);
|
|
227
|
+
if ("error" in read)
|
|
228
|
+
return { status: "unreadable", headings: [] };
|
|
229
|
+
return { status: "ok", headings: duplicateHeadings(read.doc) };
|
|
230
|
+
}, (v) => ({ status: v.status, headings: [...v.headings] }));
|
|
231
|
+
if (scan === null)
|
|
232
|
+
continue; // Absent file (early repo): contributes nothing.
|
|
233
|
+
if (scan.status === "unreadable") {
|
|
234
|
+
rows.push(`${file} (unreadable)`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
for (const heading of scan.headings) {
|
|
238
|
+
duplicates.push({ file, heading });
|
|
239
|
+
rows.push(`${file} "## ${heading}"`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { duplicates, rows };
|
|
243
|
+
}
|
|
244
|
+
/** The module-level stat cache backing scanBacklogHeadings, shared by the doctor row and the
|
|
245
|
+
* always-on alert so the poll path never re-reads an unchanged backlog or archive file. */
|
|
246
|
+
const backlogHeadingFileCache = new Map();
|
|
247
|
+
/** The duplicated `## ` headings already on main, exported so the always-on `backlog-headings`
|
|
248
|
+
* team alert (src/ui/team-alerts.ts) renders the same set the doctor's `backlog headings` row
|
|
249
|
+
* warns about. */
|
|
250
|
+
export function duplicatedBacklogHeadings(root) {
|
|
251
|
+
return scanBacklogHeadings(root).duplicates;
|
|
252
|
+
}
|
|
253
|
+
/** The module-level stat cache backing liveFinishedEntriesFor, keyed by root, so a steady-state
|
|
254
|
+
* status poll pays four stat calls (two live files, two archives) instead of re-parsing the
|
|
255
|
+
* live files and the unbounded archives (PLANS-DONE.md runs to MBs) on every poll. Keyed by a
|
|
256
|
+
* combined stat signature rather than the shared cachedByStat because each row spans FOUR files
|
|
257
|
+
* (a live file, its finished entries, and the archive it empties into), so no single-file
|
|
258
|
+
* stat-keyed cache entry can serve it. */
|
|
259
|
+
const liveFinishedCache = new Map();
|
|
260
|
+
/** The finished-history rows already on main, exported so the always-on `backlog-archive` team
|
|
261
|
+
* alert (src/ui/team-alerts.ts) renders the same set the doctor's `backlog archive` row warns
|
|
262
|
+
* about — stat-gated so a steady-state poll pays four stats, not a full re-parse. The cache key
|
|
263
|
+
* is the stat identity (`dev:ino:mtimeMs:size`, `absent` when the stat is null) of every
|
|
264
|
+
* FINISHED_ARCHIVES live file and archive under `root`, so any write that changes finished
|
|
265
|
+
* history or the archive refreshes the rows on the next call without a process restart. Returns
|
|
266
|
+
* a copy on a hit; on a miss it recomputes via liveFinishedHistory, and returns `[]` (uncached)
|
|
267
|
+
* when that throws, so an unreadable file stays checkLiveFinished's loud signal and never
|
|
268
|
+
* breaks a status poll. checkLiveFinished keeps calling liveFinishedHistory directly and is
|
|
269
|
+
* unchanged. */
|
|
270
|
+
export function liveFinishedEntriesFor(root) {
|
|
271
|
+
const key = FINISHED_ARCHIVES
|
|
272
|
+
.flatMap((spec) => [path.join(root, spec.file), path.join(root, spec.archive)])
|
|
273
|
+
.map((p) => {
|
|
274
|
+
const st = statOrNull(p);
|
|
275
|
+
return st ? `${st.dev}:${st.ino}:${st.mtimeMs}:${st.size}` : "absent";
|
|
276
|
+
})
|
|
277
|
+
.join("|");
|
|
278
|
+
const cached = liveFinishedCache.get(root);
|
|
279
|
+
if (cached?.key === key)
|
|
280
|
+
return cached.rows.map((r) => ({ ...r }));
|
|
281
|
+
let rows;
|
|
282
|
+
try {
|
|
283
|
+
rows = liveFinishedHistory(root);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
liveFinishedCache.delete(root); // Never serve a stale read for an unreadable tree.
|
|
287
|
+
return []; // Unreadable — checkLiveFinished's signal; a status poll must not throw.
|
|
288
|
+
}
|
|
289
|
+
if (liveFinishedCache.size >= 64)
|
|
290
|
+
liveFinishedCache.clear(); // Safety cap, like backlogHeadingFileCache.
|
|
291
|
+
liveFinishedCache.set(root, { key, rows });
|
|
292
|
+
return rows.map((r) => ({ ...r }));
|
|
293
|
+
}
|
|
155
294
|
/** Duplicated `## ` headings already on main (the duplicate-heading half of the backlog-structure
|
|
156
295
|
* check, src/backlog/backlog-structure.ts): a `## Done` a past merge or conflict resolution added
|
|
157
296
|
* twice leaves every later entry after the first section invisible to the section readers, and
|
|
@@ -162,63 +301,59 @@ export function checkLiveFinished(root) {
|
|
|
162
301
|
* that leaves an existing duplicate alone passes and the duplicate itself needs a deliberate
|
|
163
302
|
* removal edit. */
|
|
164
303
|
export function checkBacklogHeadings(root) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
const p = path.join(root, file);
|
|
168
|
-
if (!fs.existsSync(p))
|
|
169
|
-
continue; // Absent file (early repo): contributes nothing.
|
|
170
|
-
const read = readDoc(p);
|
|
171
|
-
if ("error" in read) {
|
|
172
|
-
duplicates.push(`${file} (unreadable)`);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
const { doc } = read;
|
|
176
|
-
for (const title of duplicateHeadings(doc))
|
|
177
|
-
duplicates.push(`${file} "## ${title}"`);
|
|
178
|
-
}
|
|
179
|
-
if (duplicates.length === 0)
|
|
304
|
+
const { rows } = scanBacklogHeadings(root);
|
|
305
|
+
if (rows.length === 0)
|
|
180
306
|
return okOutcome("no duplicated ## section headings in the backlog files");
|
|
181
|
-
const [first, ...rest] =
|
|
307
|
+
const [first, ...rest] = rows;
|
|
182
308
|
const more = andMore(rest, (d) => d);
|
|
183
309
|
return warnOutcome(`${first}${more} appears more than once — readers take the first section of a name, so ` +
|
|
184
310
|
`later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
|
|
185
311
|
`that adds another copy — remove the duplicate in a deliberate edit)`);
|
|
186
312
|
}
|
|
187
|
-
/** Stalled
|
|
188
|
-
* parked until the plan role splits or replans them
|
|
189
|
-
*
|
|
190
|
-
* the
|
|
313
|
+
/** Stalled entries — plans or bugs held by a **Needs review …** or **Needs replan …** note,
|
|
314
|
+
* parked until the plan role splits or replans them, plus the held-for-operator entries
|
|
315
|
+
* parked until the operator releases them. No role takes a held entry (the claim gate refuses
|
|
316
|
+
* one) and nothing clears the note on its own, so the state is visible only as the
|
|
317
|
+
* `[needs review]`/`[needs replan]` marks in the backlog listings; this names them so an
|
|
191
318
|
* operator gets a single-line health signal from `tumwater doctor`. Reads the tree at `root`
|
|
192
319
|
* — the primary checkout IS main's tree — like checkRefusedEntries, which is also where
|
|
193
320
|
* refusal notes (a different hold) are surfaced. entryHold (src/backlog/backlog-eligibility.ts)
|
|
194
|
-
* already classifies the notes, so this check adds no parsing: only the
|
|
195
|
-
*
|
|
196
|
-
* a fail — the checkRefusedEntries precedent: a parked
|
|
197
|
-
* environment; the plan role resolves the first two itself, and
|
|
198
|
-
* the operator's to release (`tumwater plan --release`). */
|
|
199
|
-
export function
|
|
321
|
+
* already classifies the notes, so this check adds no parsing: only the entries whose hold is
|
|
322
|
+
* exactly needs-review, needs-replan, or held-for-operator are collected, from both PLANS.md
|
|
323
|
+
* and BUGS.md. A warn, never a fail — the checkRefusedEntries precedent: a parked entry is
|
|
324
|
+
* operator signal, not a broken environment; the plan role resolves the first two itself, and
|
|
325
|
+
* a held-for-operator entry is the operator's to release (`tumwater plan|bug --release`). */
|
|
326
|
+
export function checkStalledEntries(root) {
|
|
200
327
|
const planned = plannedPlanEntries(root);
|
|
201
|
-
const
|
|
328
|
+
const collect = (entry, file) => {
|
|
202
329
|
const hold = entryHold(entry, planned);
|
|
203
|
-
return hold === "needs-review" || hold === "needs-replan" || hold === "held-for-operator"
|
|
204
|
-
|
|
330
|
+
return hold === "needs-review" || hold === "needs-replan" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
|
|
331
|
+
? { file, entry }
|
|
332
|
+
: null;
|
|
333
|
+
};
|
|
334
|
+
const stalled = [
|
|
335
|
+
...planned.map((e) => collect(e, "plans")),
|
|
336
|
+
...openBugEntries(root).map((e) => collect(e, "bugs")),
|
|
337
|
+
].filter((x) => x !== null);
|
|
205
338
|
const [first, ...rest] = stalled;
|
|
206
339
|
if (!first)
|
|
207
|
-
return okOutcome("no planned plan is held by a Needs-review, Needs-replan, or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
|
|
208
|
-
const more = andMore(rest, (
|
|
209
|
-
const
|
|
210
|
-
const
|
|
340
|
+
return okOutcome("no planned plan or open bug is held by a Needs-review, Needs-replan, or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
|
|
341
|
+
const more = andMore(rest, (s) => `"${truncate(s.entry.title, FIX_CLAIM_HEADING_MAX)}"`);
|
|
342
|
+
const kind = entryKindLabel(first.file); // "Bug" | "Plan"
|
|
343
|
+
const head = `${first.file === "bugs" ? "BUGS.md" : "PLANS.md"} ${kind.toLowerCase()}`;
|
|
344
|
+
const hold = entryHold(first.entry, planned);
|
|
345
|
+
const note = (first.entry.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ") || line.trimStart().startsWith("**Needs replan ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
|
|
211
346
|
if (hold === "held-for-operator") { // vocabulary-ok: the held-for-operator hold's key
|
|
212
|
-
return warnOutcome(
|
|
347
|
+
return warnOutcome(`${head} "${truncate(first.entry.title, FIX_CLAIM_HEADING_MAX)}" is held for the operator ${heldCount(first.entry.body)} times${more} — release it with tumwater ${kind.toLowerCase()} --release`); // vocabulary-ok: a held-for-operator entry is the operator's to release
|
|
213
348
|
}
|
|
214
|
-
return warnOutcome(
|
|
349
|
+
return warnOutcome(`${head} "${truncate(first.entry.title, FIX_CLAIM_HEADING_MAX)}" is held by a ${hold === "needs-review" ? "Needs review" : "Needs replan"} note ` +
|
|
215
350
|
`(${truncate(note, REFUSAL_NOTE_MAX)})${more} — the plan role resolves it by splitting or replanning`);
|
|
216
351
|
}
|
|
217
352
|
/** Plan dependencies — mistakes in the planned plans' `requires … landed` clauses that the claim
|
|
218
353
|
* allocator (entryHoldAgainst) cannot see: a ref naming no heading (the hold is void, so the
|
|
219
354
|
* dependent is claimable now), a clause that parses to no refs (it holds nothing), and a
|
|
220
355
|
* dependency cycle (every part on it stays blocked forever). Reads the tree at `root` through
|
|
221
|
-
* planDependencyProblems. A warn, never a fail, like
|
|
356
|
+
* planDependencyProblems. A warn, never a fail, like checkStalledEntries: the plan role writes the
|
|
222
357
|
* clauses by hand and fixes them itself. The most serious kind is named — cycle, then dangling,
|
|
223
358
|
* then unparsed — with a count of the other kinds. */
|
|
224
359
|
export function planDependencyProblem(root) {
|
|
@@ -2,6 +2,8 @@ import { roleLabel } from "../roles/role-labels.js";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { errorMessage, truncate } from "../text/text.js";
|
|
5
|
+
import { shortSha } from "../text/format.js";
|
|
6
|
+
import { readPostReviews, POST_REVIEW_FAILURE_LIMIT } from "../review/post-merge-review.js";
|
|
5
7
|
import { humanSeconds, secondsSince, HOUR_MS, DAY_MS } from "../text/datetime.js";
|
|
6
8
|
import { eventsSince } from "../events/event-window.js";
|
|
7
9
|
import { finiteNumber } from "../files/json-object.js";
|
|
@@ -13,12 +15,15 @@ import { listWideScope, queuedListRows } from "../inbox/inbox.js";
|
|
|
13
15
|
import { deliverableAt } from "../inbox/prompt-not-before.js";
|
|
14
16
|
import { currentBranch, targetBranch } from "../git/git.js";
|
|
15
17
|
import { gitTry } from "../git/git-run.js";
|
|
18
|
+
import { stampedSubject } from "../git/commit-message.js";
|
|
19
|
+
import { commitOperatorBacklogEdit } from "../backlog/backlog-commit.js";
|
|
16
20
|
import { briefFile } from "../brief/brief.js";
|
|
17
21
|
import { BACKLOG_FILES } from "../backlog/backlog-md.js";
|
|
18
22
|
import { okOutcome, warnOutcome } from "./outcome.js";
|
|
19
23
|
/** The doctor's backlog modules' shared scaffolding and the check groups that do not read a
|
|
20
|
-
* single backlog document: the merge-queue and queued-prompt checks
|
|
21
|
-
* checkMergeQueueFiles, checkStalePrompts and the
|
|
24
|
+
* single backlog document: the merge-queue, post-merge-review and queued-prompt checks
|
|
25
|
+
* (checkStaleQueuedMerges, checkMergeQueueFiles, checkPostReviews, checkStalePrompts and the
|
|
26
|
+
* mergeWaitPhrase helper) and the uncommitted-
|
|
22
27
|
* edits-in-the-primary-checkout check. This module also hosts the helpers and constants the
|
|
23
28
|
* per-document checks import — readDoc and its checked wrapper readDocChecked, andMore, and the
|
|
24
29
|
* age/heading-trim bounds — so the per-document reads cannot drift. The per-document
|
|
@@ -75,9 +80,10 @@ export function readDoc(p) {
|
|
|
75
80
|
return { error: errorMessage(err) };
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
|
-
/** The single-file check's read prologue
|
|
79
|
-
*
|
|
80
|
-
*
|
|
83
|
+
/** The single-file check's read prologue: an absent file is that check's own "nothing to
|
|
84
|
+
* verify" ok outcome, an unreadable one a warn naming the file. Returns the document, or the
|
|
85
|
+
* outcome the caller should return as-is. The per-file doctor checks go through readBacklogChecked.
|
|
86
|
+
* */
|
|
81
87
|
export function readDocChecked(p, file, missingDetail) {
|
|
82
88
|
if (!fs.existsSync(p))
|
|
83
89
|
return okOutcome(missingDetail);
|
|
@@ -86,6 +92,15 @@ export function readDocChecked(p, file, missingDetail) {
|
|
|
86
92
|
return warnOutcome(`cannot read ${file} — ${read.error}`);
|
|
87
93
|
return read.doc;
|
|
88
94
|
}
|
|
95
|
+
/** The standard single-file check's read prologue for a backlog document at `root`: the
|
|
96
|
+
* readDocChecked call every per-file doctor check (checkFixClaims, checkValidationGaps,
|
|
97
|
+
* checkStrandedPlans, checkStaleQuestions, and the stale/waiting bug checks) repeats, with the
|
|
98
|
+
* `no <file> — nothing to verify` missing-detail derived from the file name so the file triple
|
|
99
|
+
* (path, display name, message) and the message template cannot drift across the seven call
|
|
100
|
+
* sites. Returns the document, or the outcome the caller should return as-is. */
|
|
101
|
+
export function readBacklogChecked(root, file) {
|
|
102
|
+
return readDocChecked(path.join(root, file), file, `no ${file} — nothing to verify`);
|
|
103
|
+
}
|
|
89
104
|
/** How long a queued merge may wait without its change starting before the stale-queued-merge check
|
|
90
105
|
* warns: 6 hours, fixed. A verify plus a merge completes in minutes (plans/merge-queue.md), so a
|
|
91
106
|
* full six-hour silence while the team is healthy — not on a hold, which doctor already reports
|
|
@@ -109,7 +124,7 @@ const MERGE_WAIT_WINDOW_MS = DAY_MS;
|
|
|
109
124
|
* holding a change being verified or merged right now — a slow review is not a stalled drain, and
|
|
110
125
|
* an absent or never-throwing unreadable marker contributes the empty set. Reads the tree at `root`
|
|
111
126
|
* with the already-exported readers, changing nothing in src/merge/. A warn, never a fail — the
|
|
112
|
-
* checkStaleQuestions/
|
|
127
|
+
* checkStaleQuestions/checkStalledEntries precedent: a queued wait is legitimate while the team is
|
|
113
128
|
* genuinely held, and those holds already warn separately. */
|
|
114
129
|
export function checkStaleQueuedMerges(root, now) {
|
|
115
130
|
const withWait = (out) => {
|
|
@@ -169,6 +184,55 @@ export function checkMergeQueueFiles(root) {
|
|
|
169
184
|
return warnOutcome(`the merge queue holds ${plural(files.length, "file")} the reader skips — ${first}${more}; ` +
|
|
170
185
|
`a torn or foreign file is never cleared, so nothing ages it or surfaces its merge — remove it (a crashed enqueue's merge ref still recovers on the next run)`);
|
|
171
186
|
}
|
|
187
|
+
/** The failures count at which a post-merge review is one failed run from being dropped: one
|
|
188
|
+
* below POST_REVIEW_FAILURE_LIMIT, so the drain's drop limit and this at-risk bar cannot drift
|
|
189
|
+
* (the STALE_BUG_DAYS precedent). A review at this bar has as many unreviewed failures as the
|
|
190
|
+
* drain allows before it drops the commit from the queue forever. */
|
|
191
|
+
export const POST_REVIEW_AT_RISK_FAILURES = POST_REVIEW_FAILURE_LIMIT - 1;
|
|
192
|
+
/** The post-merge reviews one failed run from being dropped, in file (FIFO) order: every entry
|
|
193
|
+
* whose `failures` is at least POST_REVIEW_AT_RISK_FAILURES, each with `commit`, `role`,
|
|
194
|
+
* `summary` and `failures` carried out (a missing failures reads as 0, so it is never at risk).
|
|
195
|
+
* A review that has already been rejected is left out: the drain settles a rejection as
|
|
196
|
+
* `{ ...entry, rejected }` (it preserves `failures`) and never re-runs it, so a rejected commit
|
|
197
|
+
* is not waiting on a verdict and must not read as one failed run from being dropped.
|
|
198
|
+
* A review that has already landed its fix (`fixed` set) is left out too — it is a settled
|
|
199
|
+
* record naming both shas, not a verdict-less entry the next run could drop.
|
|
200
|
+
* One traversal shared by the doctor's `post-merge reviews` row (checkPostReviews) and the
|
|
201
|
+
* always-on `post-merge-risk` team alert (src/ui/status-payload.ts postReviewRiskAlert), so the
|
|
202
|
+
* two surfaces select the same entries and cannot drift. Reads .tumwater/state/post-review.json
|
|
203
|
+
* through readPostReviews, which degrades a missing or torn file to the well-formed subset or
|
|
204
|
+
* `[]` and never throws. */
|
|
205
|
+
export function atRiskPostReviews(root) {
|
|
206
|
+
return readPostReviews(root)
|
|
207
|
+
.filter((e) => e.rejected === undefined && e.fixed === undefined && (e.failures ?? 0) >= POST_REVIEW_AT_RISK_FAILURES)
|
|
208
|
+
.map((e) => ({ commit: e.commit, role: e.role, summary: e.summary, failures: e.failures ?? 0 }));
|
|
209
|
+
}
|
|
210
|
+
/** The doctor's one sentence naming the post-merge reviews one failed run from being dropped
|
|
211
|
+
* (the checkStaleQueuedMerges phrase precedent): the first in full — role, short commit, summary
|
|
212
|
+
* and the failures count — the rest folded through andMore, and the one sentence every surface
|
|
213
|
+
* shows, so the doctor row and the alert cannot drift on the wording. null for an empty list. */
|
|
214
|
+
export function atRiskPostReviewsPhrase(rows) {
|
|
215
|
+
const [first, ...rest] = rows;
|
|
216
|
+
if (!first)
|
|
217
|
+
return null;
|
|
218
|
+
const more = andMore(rest, (r) => `${roleLabel(r.role)} ${shortSha(r.commit)}`);
|
|
219
|
+
return `the post-merge review of ${roleLabel(first.role)} ${shortSha(first.commit)} — "${truncate(first.summary, FIX_CLAIM_HEADING_MAX)}" — has ended without a verdict ${first.failures} times${more}; ` +
|
|
220
|
+
`one more failed run drops it and the commit stays on main unreviewed — fix the reviewer backend or accept the commit unreviewed deliberately`;
|
|
221
|
+
}
|
|
222
|
+
/** A post-merge review one failed run from being dropped (src/review/post-merge-review.ts, the
|
|
223
|
+
* drain drops an entry at POST_REVIEW_FAILURE_LIMIT failed runs) — the last moment an operator
|
|
224
|
+
* can fix the reviewer backend or deliberately accept the commit unreviewed before the drain
|
|
225
|
+
* drops it from the queue and it stays on main unreviewed. Reads .tumwater/state/post-review.json
|
|
226
|
+
* through the shared atRiskPostReviews traversal the always-on post-merge-risk team alert
|
|
227
|
+
* (src/ui/status-payload.ts postReviewRiskAlert) uses, so the two surfaces cannot drift. A warn,
|
|
228
|
+
* never a fail — the checkStaleQueuedMerges precedent: a failing reviewer is operator signal,
|
|
229
|
+
* not a broken environment; readPostReviews never throws (a missing or torn file degrades to the
|
|
230
|
+
* well-formed subset or `[]`), so the check needs no read-error arm. */
|
|
231
|
+
export function checkPostReviews(root) {
|
|
232
|
+
const rows = atRiskPostReviews(root);
|
|
233
|
+
const phrase = atRiskPostReviewsPhrase(rows);
|
|
234
|
+
return phrase === null ? okOutcome("no post-merge review is one failed run from being dropped") : warnOutcome(phrase);
|
|
235
|
+
}
|
|
172
236
|
/** How long a queued prompt may wait for its role to dequeue it before the stale-prompt check
|
|
173
237
|
* warns: a day, fixed. An opinionated default with no knob, like MERGE_QUEUE_STALE_MS: a day is
|
|
174
238
|
* long enough that a healthy role's next run would have dequeued the prompt, and short enough
|
|
@@ -223,6 +287,35 @@ export function mergeWaitPhrase(events) {
|
|
|
223
287
|
const longest = Math.max(...waits);
|
|
224
288
|
return `merges in the last 24h waited ${shortSpanPhrase(median)} median, ${shortSpanPhrase(longest)} longest (${plural(waits.length, "merge")})`;
|
|
225
289
|
}
|
|
290
|
+
/** Discover the tracked backlog/brief files with uncommitted edits in the primary checkout (the
|
|
291
|
+
* shared discovery behind checkUncommittedBacklog and commitUncommittedBacklog). A primary off
|
|
292
|
+
* the base branch is skipped by its callers: the ref-push merge arm leaves its working tree
|
|
293
|
+
* alone. Untracked files are not edits of a tracked file and are ignored. */
|
|
294
|
+
export async function dirtyBacklogFiles(root, config) {
|
|
295
|
+
const primary = await currentBranch(root);
|
|
296
|
+
const base = targetBranch(config?.baseBranch, primary);
|
|
297
|
+
// A primary off the base branch is skipped by every caller before it reads `watched`/`dirty`, so
|
|
298
|
+
// the brief is not touched here either: an unreadable brief must not turn that skip into a throw
|
|
299
|
+
// (the ref-push merge arm leaves an off-base working tree alone).
|
|
300
|
+
if (primary === null || primary !== base)
|
|
301
|
+
return { base, primary, watched: [], dirty: [] };
|
|
302
|
+
let brief = null;
|
|
303
|
+
try {
|
|
304
|
+
brief = briefFile(root);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// An unreadable brief is a doctor finding of its own; it must not abort the uncommitted-backlog
|
|
308
|
+
// check or `--commit-backlog`, so the tracked backlog set is watched without it.
|
|
309
|
+
}
|
|
310
|
+
const watched = [...new Set([...BACKLOG_FILES, ...(brief ? [brief] : [])])];
|
|
311
|
+
const out = await gitTry(root, "status", "--porcelain", "--", ...watched);
|
|
312
|
+
if (out === null)
|
|
313
|
+
return { base, primary, watched, dirty: null };
|
|
314
|
+
const dirtySet = new Set(out.split("\n")
|
|
315
|
+
.filter((line) => line.length > 3 && !line.startsWith("??"))
|
|
316
|
+
.map((line) => line.slice(3)));
|
|
317
|
+
return { base, primary, watched, dirty: watched.filter((f) => dirtySet.has(f)) };
|
|
318
|
+
}
|
|
226
319
|
/** Names the backlog files (and the brief file) with uncommitted edits in the primary checkout. An
|
|
227
320
|
* operator write is meant to be committed on the base branch by commitOperatorBacklogEdit; when
|
|
228
321
|
* that commit does not happen the edit is invisible to every role worktree (they branch from main)
|
|
@@ -230,26 +323,66 @@ export function mergeWaitPhrase(events) {
|
|
|
230
323
|
* the base branch is skipped: the ref-push merge arm leaves its working tree alone. Untracked files
|
|
231
324
|
* are not edits of a tracked file and are ignored. */
|
|
232
325
|
export async function checkUncommittedBacklog(root, config) {
|
|
233
|
-
const primary = await
|
|
234
|
-
const base = targetBranch(config?.baseBranch, primary);
|
|
326
|
+
const { base, primary, watched, dirty } = await dirtyBacklogFiles(root, config);
|
|
235
327
|
if (primary === null || primary !== base) {
|
|
236
328
|
return okOutcome(`your checkout is not on ${base}; backlog writes from you are not committed from it`);
|
|
237
329
|
}
|
|
238
|
-
|
|
239
|
-
const files = [...new Set([...BACKLOG_FILES, ...(brief ? [brief] : [])])];
|
|
240
|
-
const out = await gitTry(root, "status", "--porcelain", "--", ...files);
|
|
241
|
-
if (out === null)
|
|
330
|
+
if (dirty === null)
|
|
242
331
|
return okOutcome("could not read git status; skipped");
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const named = files.filter((f) => dirty.has(f));
|
|
247
|
-
if (named.length === 0) {
|
|
248
|
-
const list = files.slice(0, -1).join(", ");
|
|
249
|
-
return okOutcome(`${list} and ${files[files.length - 1]} are committed`);
|
|
332
|
+
if (dirty.length === 0) {
|
|
333
|
+
const list = watched.slice(0, -1).join(", ");
|
|
334
|
+
return okOutcome(`${list} and ${watched[watched.length - 1]} are committed`);
|
|
250
335
|
}
|
|
251
|
-
const list =
|
|
252
|
-
return warnOutcome(`${list} ${
|
|
336
|
+
const list = dirty.join(", ");
|
|
337
|
+
return warnOutcome(`${list} ${dirty.length > 1 ? "have" : "has"} uncommitted changes in your checkout — ` +
|
|
253
338
|
`roles branch from main and do not see them, and a merge that touches one cannot fast-forward the checkout ` +
|
|
254
|
-
`(merge_blocked); review them with \`git diff -- ${list}\` and commit them on ${base}`
|
|
339
|
+
`(merge_blocked); review them with \`git diff -- ${list}\` and commit them on ${base} ` +
|
|
340
|
+
`(or run \`tumwater doctor --commit-backlog\`, or the Health card's Commit backlog, or the TUI Health tab's Ctrl+A)`);
|
|
341
|
+
}
|
|
342
|
+
/** Commit every dirty tracked backlog or brief file in the primary checkout on the base branch,
|
|
343
|
+
* through the same commitOperatorBacklogEdit core every operator backlog write uses, running
|
|
344
|
+
* under the primary-worktree lock it takes. The dirtyBacklogFiles discovery and branch precedence
|
|
345
|
+
* are checkUncommittedBacklog's own, so a `tumwater doctor --commit-backlog` unblocks exactly the
|
|
346
|
+
* merges the `uncommitted backlog` row warns would be blocked. When the primary is off the base
|
|
347
|
+
* branch or the status cannot be read it commits nothing and reports a skip sentence; a file whose
|
|
348
|
+
* commit fails stays dirty and lands in `failed` while the other listed files are still attempted.
|
|
349
|
+
* Never throws. */
|
|
350
|
+
export async function commitUncommittedBacklog(root, config) {
|
|
351
|
+
const d = await dirtyBacklogFiles(root, config);
|
|
352
|
+
if (d.primary === null || d.primary !== d.base)
|
|
353
|
+
return { base: d.base, committed: [], failed: [], skipped: `your checkout is not on ${d.base}; nothing committed` };
|
|
354
|
+
if (d.dirty === null)
|
|
355
|
+
return { base: d.base, committed: [], failed: [], skipped: "could not read git status; nothing committed" };
|
|
356
|
+
const committed = [];
|
|
357
|
+
const failed = [];
|
|
358
|
+
for (const name of d.dirty) {
|
|
359
|
+
const ok = await commitOperatorBacklogEdit(root, name, stampedSubject("operator", `Commit uncommitted ${name}`)); // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
|
|
360
|
+
(ok ? committed : failed).push(name);
|
|
361
|
+
}
|
|
362
|
+
return { base: d.base, committed, failed, skipped: null };
|
|
363
|
+
}
|
|
364
|
+
/** The committed/failed/skip wording the CLI, the dashboard handler and the TUI flash all share
|
|
365
|
+
* for a BacklogCommitReport, so the three surfaces cannot drift: one `committed <file> on
|
|
366
|
+
* <base>` line per committed file, one `could not commit <file> — commit it by hand` line per
|
|
367
|
+
* failed file, then the skipped sentence when set. `[]` when the report committed nothing,
|
|
368
|
+
* failed nothing, and carried no skip sentence. `tumwater doctor --commit-backlog` (src/cli.ts),
|
|
369
|
+
* the dashboard Health card's Commit backlog (handleCommitBacklog) and the TUI Health tab's
|
|
370
|
+
* Ctrl+A all render from this one source. */
|
|
371
|
+
export function backlogCommitLines(report) {
|
|
372
|
+
const lines = [
|
|
373
|
+
...report.committed.map((file) => `committed ${file} on ${report.base}`),
|
|
374
|
+
...report.failed.map((file) => `could not commit ${file} — commit it by hand`),
|
|
375
|
+
];
|
|
376
|
+
if (report.skipped !== null)
|
|
377
|
+
lines.push(report.skipped);
|
|
378
|
+
return lines;
|
|
379
|
+
}
|
|
380
|
+
/** The single-line message a surface flashes or answers for a BacklogCommitReport: the shared
|
|
381
|
+
* backlogCommitLines joined with "; ", or the nothing-to-commit sentence when the report
|
|
382
|
+
* committed nothing, failed nothing and carried no skip sentence. Lives here beside
|
|
383
|
+
* backlogCommitLines so the dashboard Health card's Commit backlog handler and the TUI Health
|
|
384
|
+
* tab's Ctrl+A flash cannot drift from each other. */
|
|
385
|
+
export function backlogCommitMessage(report) {
|
|
386
|
+
const lines = backlogCommitLines(report);
|
|
387
|
+
return lines.length ? lines.join("; ") : "nothing to commit — every watched backlog or brief file is committed";
|
|
255
388
|
}
|