tumwater 0.4.1 → 0.4.2
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 +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +14 -0
- package/dist/src/agent/claude/claude-stream.js +27 -31
- package/dist/src/agent/codex/codex-rollout.js +38 -24
- package/dist/src/agent/codex/codex-stream.js +71 -38
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +4 -4
- package/dist/src/agent/run-agent.js +11 -2
- package/dist/src/agent/run-settle.js +22 -13
- package/dist/src/agent/run-spawn.js +3 -12
- package/dist/src/agent/run-stream.js +2 -10
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/backlog/backlog-archive.js +64 -165
- package/dist/src/backlog/backlog-eligibility.js +50 -58
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +2 -2
- package/dist/src/backlog/backlog-md.js +38 -27
- package/dist/src/backlog/backlog-release.js +11 -19
- package/dist/src/backlog/backlog-structure.js +14 -1
- package/dist/src/backlog/backlog-write.js +1 -1
- package/dist/src/backlog/backlog.js +5 -4
- package/dist/src/backlog/question-answer.js +30 -14
- package/dist/src/baseline/main-baseline.js +30 -20
- package/dist/src/baseline/main-guardrails.js +54 -17
- package/dist/src/baseline/main-watch.js +1 -1
- package/dist/src/budget/budget-wiring.js +0 -5
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +1 -4
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +27 -50
- package/dist/src/cli/cli-start.js +62 -12
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +6 -7
- package/dist/src/cli/log-commands.js +3 -1
- package/dist/src/cli.js +3 -7
- package/dist/src/collections.js +21 -7
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +19 -26
- package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
- package/dist/src/doctor/doctor-backlog-plans.js +8 -8
- package/dist/src/doctor/doctor-backlog.js +22 -36
- package/dist/src/doctor/doctor-check-names.js +2 -2
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +1 -1
- package/dist/src/doctor/doctor-holds.js +13 -11
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +85 -48
- package/dist/src/doctor/doctor-project-checks.js +66 -17
- package/dist/src/doctor/doctor-role-notes.js +6 -24
- package/dist/src/doctor/doctor.js +6 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +5 -15
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/failure/failure-state-change.js +183 -68
- package/dist/src/failure/time-spend.js +1 -1
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/git/slot-salvage.js +5 -4
- package/dist/src/gui/gui-admin-endpoints.js +2 -3
- package/dist/src/gui/gui-backlog-commands.js +1 -1
- package/dist/src/gui/gui-backlog-endpoints.js +1 -7
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-operator-commands.js +0 -12
- package/dist/src/gui/gui-prompt-commands.js +26 -20
- package/dist/src/gui/gui-report-endpoints.js +2 -1
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/history/history.js +102 -67
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/prompt-commands.js +24 -19
- package/dist/src/init/init.js +68 -15
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-post-review-drain.js +1 -1
- package/dist/src/operator/operator-commands.js +68 -47
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +0 -6
- package/dist/src/pi/pi-watchdogs.js +20 -1
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/process/process.js +23 -0
- package/dist/src/process/supervisor.js +17 -1
- package/dist/src/prompt/gate-notes.js +9 -9
- package/dist/src/prompt/notes-backlog.js +11 -24
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +4 -5
- package/dist/src/report/report-data.js +10 -225
- package/dist/src/report/report-fold.js +20 -295
- package/dist/src/report/report-main-red.js +45 -42
- package/dist/src/report/report-render.js +66 -39
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +3 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +9 -9
- package/dist/src/review/quarantine-render.js +2 -1
- package/dist/src/roles/role-catalog.js +18 -30
- package/dist/src/roles/role-guidance.js +18 -43
- package/dist/src/roles/role-view.js +1 -2
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-detail-data.js +23 -7
- package/dist/src/run/run-detail-render.js +6 -0
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-prompt.js +100 -56
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +4 -21
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +8 -73
- package/dist/src/run/stage-backlog-checks.js +2 -29
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-run.js +2 -79
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +3 -10
- package/dist/src/status/status-data.js +5 -9
- package/dist/src/status/status-role-notes.js +18 -14
- package/dist/src/status/status-roles.js +2 -35
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +7 -212
- package/dist/src/ui/badges.js +2 -3
- package/dist/src/ui/gui/gui-client-boot.js +19 -4
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
- package/dist/src/ui/gui/gui-client-report.js +1 -2
- package/dist/src/ui/gui/gui-client-settings.js +0 -23
- package/dist/src/ui/gui/gui-client-team.js +26 -0
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/status-model.js +2 -2
- package/dist/src/ui/status-payload.js +3 -3
- package/dist/src/ui/status-render.js +3 -3
- package/dist/src/ui/team-alert-backlog.js +23 -54
- package/dist/src/ui/team-alert-shared.js +23 -10
- package/dist/src/ui/team-alert-status.js +8 -3
- package/dist/src/ui/team-alert-tail.js +1 -14
- package/dist/src/ui/team-alerts.js +6 -17
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +105 -93
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-pause.js +209 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
- package/dist/src/ui/tui/tui-editor-release.js +50 -42
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
- package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-frame.js +1 -1
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-health.js +3 -24
- package/dist/src/ui/tui/tui-pane-history.js +20 -24
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +3 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +2 -3
- package/dist/src/ui/tui/tui.js +3 -3
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +30 -6
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- package/dist/src/ui/team-alert-backlog-feed.js +0 -181
- package/dist/src/ui/team-alert-merge.js +0 -183
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Backlog eligibility: which PLANS.md / BUGS.md entries a work role may take now (plans,
|
|
2
|
-
* parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused
|
|
3
|
-
* **Needs review
|
|
2
|
+
* parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused …**
|
|
3
|
+
* or **Needs review …** note, or when
|
|
4
4
|
* its heading's trailing parenthetical
|
|
5
5
|
* (or a parenthetical left open by a wrapped heading) names a prerequisite (`requires parts
|
|
6
6
|
* 1/5–3/5 landed`) that is itself still listed under `## Planned`. The parsers are pure over
|
|
@@ -11,24 +11,26 @@ import path from "node:path";
|
|
|
11
11
|
import { statOrNull } from "../files/files.js";
|
|
12
12
|
import { allLiveClaims } from "../runner/runner-state-load.js";
|
|
13
13
|
import { escapeRegExp, firstTrimmedLineStartingWith } from "../text/text.js";
|
|
14
|
-
import { HELD_TAG_PREFIX,
|
|
14
|
+
import { HELD_TAG_PREFIX, NEEDS_REVIEW_PREFIX, PRIOR_HELD_PREFIX } from "../roles/role-guidance.js";
|
|
15
15
|
import { baseRoleOf } from "../roles/runner-ids.js";
|
|
16
16
|
import { roleLabel } from "../roles/role-labels.js";
|
|
17
17
|
import { actionableEntryRangesFor } from "./backlog-index-data.js";
|
|
18
18
|
import { entryKey, partKey, plannedPartKeys, plansWaitingOn, requiredParts, seriesPart } from "./backlog-parts.js";
|
|
19
19
|
import { finishedFiles, openBugEntries, plannedPlanEntries } from "./backlog.js";
|
|
20
|
-
/** The number of holds an entry's count must reach before a Needs-review
|
|
20
|
+
/** The number of holds an entry's count must reach before a Needs-review note
|
|
21
21
|
* becomes a `held-for-operator` hold (the operator's decision, surfaced by parts 2/3 and 3/3). */
|
|
22
22
|
export const OPERATOR_HOLD_THRESHOLD = 3;
|
|
23
|
-
/** The held count a body records: the integer in the first Needs-review
|
|
24
|
-
* line's `(held N)` tag, else
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
/** The held count a body records: the integer in the first Needs-review note
|
|
24
|
+
* line's `(held N)` tag, else 0. A present but non-numeric `(held …)` tag is 0 (and stops the
|
|
25
|
+
* scan), so a malformed tag never throws. Pure over the body text — the data-layer read half of
|
|
26
|
+
* the convergence backstop. No writer emits a `(held N)` tag anymore and the `**Previously held
|
|
27
|
+
* N**` markers left on disk are never read, so a freshly written flat note always reads 0 and a
|
|
28
|
+
* new hold never escalates to the operator's desk. */
|
|
27
29
|
export function heldCount(body) {
|
|
28
30
|
const tagRe = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`);
|
|
29
31
|
for (const raw of body.split("\n")) {
|
|
30
32
|
const line = raw.trimStart();
|
|
31
|
-
const note = line.startsWith(NEEDS_REVIEW_PREFIX)
|
|
33
|
+
const note = line.startsWith(NEEDS_REVIEW_PREFIX);
|
|
32
34
|
if (!note)
|
|
33
35
|
continue;
|
|
34
36
|
const tag = tagRe.exec(line);
|
|
@@ -37,29 +39,22 @@ export function heldCount(body) {
|
|
|
37
39
|
if (line.includes(HELD_TAG_PREFIX))
|
|
38
40
|
return 0;
|
|
39
41
|
}
|
|
40
|
-
for (const raw of body.split("\n")) {
|
|
41
|
-
const line = raw.trimStart();
|
|
42
|
-
if (!line.startsWith(PRIOR_HELD_PREFIX))
|
|
43
|
-
continue;
|
|
44
|
-
const count = /\d+/.exec(line.slice(PRIOR_HELD_PREFIX.length));
|
|
45
|
-
return count === null ? 0 : Number(count[0]);
|
|
46
|
-
}
|
|
47
42
|
return 0;
|
|
48
43
|
}
|
|
49
|
-
/** The `(held N)` count on a single Needs-review
|
|
44
|
+
/** The `(held N)` count on a single Needs-review note line, or undefined when the
|
|
50
45
|
* line is not such a note or carries no numeric tag. The per-line form of heldCount, so a writer
|
|
51
46
|
* that drops notes (withoutHeldForOperatorNotes) can gate on the same threshold without scanning
|
|
52
47
|
* the whole body. */
|
|
53
48
|
function lineHeldCount(line) {
|
|
54
49
|
const trimmed = line.trimStart();
|
|
55
|
-
const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX)
|
|
50
|
+
const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX);
|
|
56
51
|
if (!isNote)
|
|
57
52
|
return undefined;
|
|
58
53
|
const tag = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`).exec(trimmed);
|
|
59
54
|
return tag === null ? undefined : Number(tag[1]);
|
|
60
55
|
}
|
|
61
56
|
/** The `**Refused …` note prefix a refusing run writes. Every hold note — this one and the
|
|
62
|
-
* imported Needs-review
|
|
57
|
+
* imported Needs-review prefix — is matched as a line prefix (hasNoteLine)
|
|
63
58
|
* so a mention in prose never holds an entry. */
|
|
64
59
|
const REFUSED_PREFIX = "**Refused ";
|
|
65
60
|
/** True when `body` carries a line (ignoring leading indentation) that begins with `prefix`.
|
|
@@ -79,9 +74,11 @@ function noteText(body, prefix) {
|
|
|
79
74
|
export function refusalNote(body) {
|
|
80
75
|
return noteText(body, REFUSED_PREFIX);
|
|
81
76
|
}
|
|
82
|
-
/** The text of `body`'s
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
/** The text of `body`'s Needs-review note, or null when it has none (entryHold's
|
|
78
|
+
* "needs-review" hold). A needs-review note parks the entry (an unreproducible bug, a plan
|
|
79
|
+
* judged too large, a rejected plan), so the stale-bug checks skip it like a refused one. */
|
|
80
|
+
export function needsReviewNote(body) {
|
|
81
|
+
return noteText(body, NEEDS_REVIEW_PREFIX);
|
|
85
82
|
}
|
|
86
83
|
/** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
|
|
87
84
|
* applies). When a dropped note sat between two blank lines, the blank line after it goes too,
|
|
@@ -99,17 +96,12 @@ function withoutNoteLines(lines, prefix) {
|
|
|
99
96
|
}
|
|
100
97
|
return out;
|
|
101
98
|
}
|
|
102
|
-
/** `lines` without any Refused note line. Every other line, Needs-review,
|
|
103
|
-
*
|
|
99
|
+
/** `lines` without any Refused note line. Every other line, Needs-review notes included, is
|
|
100
|
+
* kept, so refusal lifting never lifts another hold. */
|
|
104
101
|
export function withoutRefusalNotes(lines) {
|
|
105
102
|
return withoutNoteLines(lines, REFUSED_PREFIX);
|
|
106
103
|
}
|
|
107
|
-
/** `lines`
|
|
108
|
-
* withoutRefusalNotes does. */
|
|
109
|
-
export function withoutWaitingNotes(lines) {
|
|
110
|
-
return withoutNoteLines(lines, WAITING_FOR_FAILURE_PREFIX);
|
|
111
|
-
}
|
|
112
|
-
/** `lines` with every Needs-review/Needs-replan note whose `(held N)` count reached
|
|
104
|
+
/** `lines` with every Needs-review note whose `(held N)` count reached
|
|
113
105
|
* OPERATOR_HOLD_THRESHOLD removed, together with its `**Previously held N−1**` line (the prior
|
|
114
106
|
* count the increment rule recorded beside the live `(held N)`), so a `--release` lift leaves
|
|
115
107
|
* neither behind and the entry becomes eligible. Drops nothing when no note is at or past the
|
|
@@ -203,9 +195,9 @@ export function waitingPartRanges(root, title) {
|
|
|
203
195
|
return out;
|
|
204
196
|
}
|
|
205
197
|
/** Why `entry` is held against the still-`planned` entries, or null when it may be taken: a
|
|
206
|
-
* `**Refused …` line (refused), a Needs-review line (needs-review), a
|
|
207
|
-
* (
|
|
208
|
-
*
|
|
198
|
+
* `**Refused …` line (refused), a Needs-review line (needs-review), or a prerequisite
|
|
199
|
+
* `(series, part)` still among `planned` ({ blockedBy }). Series compare case-insensitively;
|
|
200
|
+
* part tokens compare verbatim.
|
|
209
201
|
* Bodies mentioning "requires" are never consulted — only the heading's trailing or
|
|
210
202
|
* still-open parenthetical. */
|
|
211
203
|
export function entryHold(entry, planned) {
|
|
@@ -217,17 +209,13 @@ export function entryHold(entry, planned) {
|
|
|
217
209
|
export function entryHoldAgainst(entry, plannedParts) {
|
|
218
210
|
if (hasNoteLine(entry.body, REFUSED_PREFIX))
|
|
219
211
|
return "refused";
|
|
220
|
-
if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX)
|
|
212
|
+
if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX)) {
|
|
221
213
|
// A note whose held count reached OPERATOR_HOLD_THRESHOLD stops out of the role loop:
|
|
222
|
-
// neither the plan role (planBacklogFull
|
|
214
|
+
// neither the plan role (planBacklogFull) nor this role takes it.
|
|
223
215
|
return heldCount(entry.body) >= OPERATOR_HOLD_THRESHOLD
|
|
224
216
|
? "held-for-operator" // vocabulary-ok: the operator-held string names the operator hold decision
|
|
225
|
-
:
|
|
226
|
-
? "needs-review"
|
|
227
|
-
: "needs-replan";
|
|
217
|
+
: "needs-review";
|
|
228
218
|
}
|
|
229
|
-
if (hasNoteLine(entry.body, WAITING_FOR_FAILURE_PREFIX))
|
|
230
|
-
return "waiting-for-failure";
|
|
231
219
|
const refs = requiredParts(entry.title);
|
|
232
220
|
if (refs.length === 0)
|
|
233
221
|
return null;
|
|
@@ -247,12 +235,8 @@ export function holdMark(hold) {
|
|
|
247
235
|
return " [blocked: refused]";
|
|
248
236
|
if (hold === "needs-review")
|
|
249
237
|
return " [blocked: needs review]";
|
|
250
|
-
if (hold === "needs-replan")
|
|
251
|
-
return " [blocked: needs replan]";
|
|
252
238
|
if (hold === "held-for-operator")
|
|
253
239
|
return " [blocked: held for operator]"; // vocabulary-ok: this hold names the operator-held wording for the operator decision
|
|
254
|
-
if (hold === "waiting-for-failure")
|
|
255
|
-
return " [blocked: waiting for the next failure]";
|
|
256
240
|
return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
|
|
257
241
|
}
|
|
258
242
|
/** The line `plan|bug --release` and `--top` print when the entry they touched is still held:
|
|
@@ -359,13 +343,13 @@ export function operatorHeldEntries(root) {
|
|
|
359
343
|
export function entryKindLabel(file) {
|
|
360
344
|
return file === "bugs" ? "Bug" : "Plan";
|
|
361
345
|
}
|
|
362
|
-
/** The planned plans and open bugs whose hold is exactly `"needs-review"
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* `
|
|
368
|
-
*
|
|
346
|
+
/** The planned plans and open bugs whose hold is exactly `"needs-review"`, in file order: the
|
|
347
|
+
* entries parked by a Needs-review note until the plan role splits or replans them (the
|
|
348
|
+
* `stalled-entries` team alert's input), each carrying its backlog file, 0-based index and the
|
|
349
|
+
* hold. Same `backlogHolds(root)` + the planned/`openBugEntries` readers `operatorHeldEntries`
|
|
350
|
+
* uses, so the alert and the doctor's `stalled entries` row classify through the same
|
|
351
|
+
* `entryHold` and cannot disagree on which holds are role-resolvable. A held-for-operator
|
|
352
|
+
* entry (a note at the held threshold) is
|
|
369
353
|
* excluded — it feeds `operatorHeldEntries` and the `operator-held` alert instead. A missing
|
|
370
354
|
* file yields `[]`. */
|
|
371
355
|
export function stalledEntries(root) {
|
|
@@ -374,7 +358,7 @@ export function stalledEntries(root) {
|
|
|
374
358
|
const entriesOf = (entries, held, file) => {
|
|
375
359
|
entries.forEach((e, i) => {
|
|
376
360
|
const hold = held[i];
|
|
377
|
-
if (hold === "needs-review"
|
|
361
|
+
if (hold === "needs-review")
|
|
378
362
|
out.push({ file, index: i, title: e.title, hold });
|
|
379
363
|
});
|
|
380
364
|
};
|
|
@@ -426,17 +410,25 @@ export function cancelledClaimNote(runner) {
|
|
|
426
410
|
? ""
|
|
427
411
|
: `${roleLabel(runner)} is still working on it — its run goes on; \`tumwater abort --role ${runner}\` stops it`;
|
|
428
412
|
}
|
|
413
|
+
/** The backlog file and section a claim-taking role's entries live under: BUGS.md's `## Open`
|
|
414
|
+
* for bugfix, PLANS.md's `## Planned` for every other base. An instance id resolves through its
|
|
415
|
+
* base role. The one home of the role → (file, section) mapping, so eligibility (eligibleEntries
|
|
416
|
+
* below) and claim staging (scheduling/claims.ts) read a claim's line range from the right file
|
|
417
|
+
* and cannot drift on which section a role owns. */
|
|
418
|
+
export function roleSection(role) {
|
|
419
|
+
return baseRoleOf(role) === "bugfix"
|
|
420
|
+
? { file: "BUGS.md", section: "Open" }
|
|
421
|
+
: { file: "PLANS.md", section: "Planned" };
|
|
422
|
+
}
|
|
429
423
|
/** The entries `role` may take now, in file order: PLANS.md's `## Planned` entries for
|
|
430
424
|
* feature, BUGS.md's `## Open` entries for bugfix, minus every entry entryHold holds, each with
|
|
431
425
|
* its stamp-free key, verbatim title and 1-based line range. Reads the primary checkout through
|
|
432
426
|
* the same stat-cached readers the index uses; a missing or unreadable file yields []. */
|
|
433
427
|
export function eligibleEntries(root, role) {
|
|
434
|
-
// An instance id (`bugfix-2`) resolves through its base role,
|
|
435
|
-
//
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
const section = bugfix ? "Open" : "Planned";
|
|
439
|
-
const entries = bugfix ? openBugEntries(root) : plannedPlanEntries(root);
|
|
428
|
+
// An instance id (`bugfix-2`) resolves through its base role, as roleSection does, so a
|
|
429
|
+
// claim's line range and the entry list are read from the right file.
|
|
430
|
+
const { file, section } = roleSection(role);
|
|
431
|
+
const entries = section === "Open" ? openBugEntries(root) : plannedPlanEntries(root);
|
|
440
432
|
const ranges = actionableEntryRangesFor(root, file, section);
|
|
441
433
|
const planned = plannedPlanEntries(root);
|
|
442
434
|
const parts = plannedPartKeys(planned);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** File brand-new BUGS.md `## Open` entries (operator bug reports, quarantined flaky tests) on
|
|
2
|
+
* the base branch as one `tumwater(operator):` commit, under the merge lock and the
|
|
3
|
+
* primary-worktree lock. Split out of backlog-archive.ts, whose name and doc cover archiving
|
|
4
|
+
* finished entries to the archive files, not appending new open entries. */
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { withLock, withPrimaryWorktreeLockAsync } from "../concurrency/lock.js";
|
|
8
|
+
import { readTextIfExists, writeTextAtomic } from "../files/files.js";
|
|
9
|
+
import { commitAuthorArgs, committerIdentArgs, git, gitTry } from "../git/git-run.js";
|
|
10
|
+
import { stampedSubject } from "../git/commit-message.js";
|
|
11
|
+
import { mergeLockDir } from "../paths.js";
|
|
12
|
+
import { plural } from "../text/phrases.js";
|
|
13
|
+
import { collapseWhitespace, errorFirstLine } from "../text/text.js";
|
|
14
|
+
import { formatDate } from "../text/datetime.js";
|
|
15
|
+
import { parseEntryDetails } from "./backlog-md.js";
|
|
16
|
+
import { appendBugEntryLocked } from "./backlog-write.js";
|
|
17
|
+
import { primaryOffBaseBranchReason } from "./backlog-archive.js";
|
|
18
|
+
/** The start of a quarantine entry's title for `test`, up to the date. appendBugEntryLocked
|
|
19
|
+
* collapses whitespace in the title, so the test part is collapsed the same way; collapsing the
|
|
20
|
+
* whole prefix would trim its trailing space. */
|
|
21
|
+
function quarantineTitlePrefix(test) {
|
|
22
|
+
return collapseWhitespace(`\`${test}\` failed, then passed on the same tree`) + " (quarantined by tumwater ";
|
|
23
|
+
}
|
|
24
|
+
/** Output fenced into a bug body: a fence of its own length cannot be closed by the quoted text. */
|
|
25
|
+
function fencedTail(tail) {
|
|
26
|
+
return ["~~~~", ...tail.replaceAll("~~~~", "~~~").split("\n").slice(-30), "~~~~"].join("\n");
|
|
27
|
+
}
|
|
28
|
+
/** File one BUGS.md `## Open` entry per given entry and commit BUGS.md on the base branch as one
|
|
29
|
+
* `tumwater(operator):` commit (the subject built from the number of entries actually appended is
|
|
30
|
+
* stamped under the operator role), under the merge lock and the primary-worktree lock exactly as
|
|
31
|
+
* archiveFinishedOnMain does. Skipped,
|
|
32
|
+
* writing nothing, when the primary checkout is off the base branch, BUGS.md has uncommitted
|
|
33
|
+
* changes, or BUGS.md exists but cannot be read; a failed append or commit restores BUGS.md.
|
|
34
|
+
* Every entry is appended verbatim as its own `## Open` heading — unless its `ifOpen` dedupe
|
|
35
|
+
* matches an existing heading, which is reused instead — and the returned headings are the
|
|
36
|
+
* entry headings in input order. */
|
|
37
|
+
export async function fileBugsOnMain(root, entries, subject, opts = {}) {
|
|
38
|
+
return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
|
|
39
|
+
const offBase = await primaryOffBaseBranchReason(root);
|
|
40
|
+
if (offBase !== null)
|
|
41
|
+
return { status: "skipped", reason: offBase };
|
|
42
|
+
const status = await gitTry(root, "status", "--porcelain", "--", "BUGS.md");
|
|
43
|
+
if (status === null)
|
|
44
|
+
return { status: "skipped", reason: "could not read git status" };
|
|
45
|
+
if (status.trim() !== "" && !status.startsWith("??")) {
|
|
46
|
+
return { status: "skipped", reason: "BUGS.md has uncommitted changes" };
|
|
47
|
+
}
|
|
48
|
+
const file = path.join(root, "BUGS.md");
|
|
49
|
+
let before;
|
|
50
|
+
try {
|
|
51
|
+
before = readTextIfExists(file);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return { status: "skipped", reason: `BUGS.md could not be read: ${errorFirstLine(err)}` };
|
|
55
|
+
}
|
|
56
|
+
const open = before === null ? [] : parseEntryDetails(before, "Open").map((e) => e.title);
|
|
57
|
+
const headings = [];
|
|
58
|
+
let appended = 0;
|
|
59
|
+
const restoreBugs = async () => {
|
|
60
|
+
await gitTry(root, "reset", "-q", "--", "BUGS.md");
|
|
61
|
+
if (before === null)
|
|
62
|
+
fs.rmSync(file, { force: true });
|
|
63
|
+
else
|
|
64
|
+
writeTextAtomic(file, before);
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (entry.ifOpen !== undefined) {
|
|
69
|
+
const existing = entry.ifOpen(open);
|
|
70
|
+
if (existing !== undefined) {
|
|
71
|
+
headings.push(existing);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
headings.push(appendBugEntryLocked(root, entry.title, entry.body));
|
|
76
|
+
appended++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
await restoreBugs();
|
|
81
|
+
return { status: "skipped", reason: `a BUGS.md entry could not be written: ${errorFirstLine(err)}` };
|
|
82
|
+
}
|
|
83
|
+
if (appended === 0)
|
|
84
|
+
return { status: "filed", headings };
|
|
85
|
+
const commitSubject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
|
|
86
|
+
subject(appended));
|
|
87
|
+
try {
|
|
88
|
+
await git(root, "add", "--", "BUGS.md");
|
|
89
|
+
await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", commitSubject, "--", "BUGS.md");
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
await restoreBugs();
|
|
93
|
+
return { status: "skipped", reason: `the commit failed: ${errorFirstLine(err)}` };
|
|
94
|
+
}
|
|
95
|
+
return { status: "filed", headings };
|
|
96
|
+
}), opts.lockTimeoutMs);
|
|
97
|
+
}
|
|
98
|
+
/** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
|
|
99
|
+
* branch as one `tumwater(operator):` commit, delegating the locked write/commit to
|
|
100
|
+
* fileBugsOnMain and preserving the existing subject. A test whose quarantine entry is already
|
|
101
|
+
* under `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading
|
|
102
|
+
* back and is not filed again — the dedupe runs inside fileBugsOnMain under the merge lock (via
|
|
103
|
+
* each entry's `ifOpen`), so concurrent quarantine filings cannot both append a duplicate, and
|
|
104
|
+
* the real skip on a dirty or off-base BUGS.md is enforced under the same lock. */
|
|
105
|
+
export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
|
|
106
|
+
const date = formatDate(new Date());
|
|
107
|
+
const toFile = [];
|
|
108
|
+
for (const { test, tail } of tests) {
|
|
109
|
+
const prefix = quarantineTitlePrefix(test);
|
|
110
|
+
const body = [
|
|
111
|
+
`The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
|
|
112
|
+
...(tail ? ["", fencedTail(tail)] : []),
|
|
113
|
+
].join("\n");
|
|
114
|
+
toFile.push({
|
|
115
|
+
title: `${prefix}${date})`,
|
|
116
|
+
body,
|
|
117
|
+
// Dedupe under the lock, against the headings fileBugsOnMain reads: the reused heading
|
|
118
|
+
// (not a duplicate) is returned in headings and zipped back to this test below.
|
|
119
|
+
ifOpen: (open) => open.find((t) => t.startsWith(prefix)),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const outcome = await fileBugsOnMain(root, toFile,
|
|
123
|
+
// Size the subject by what was actually appended: tests whose `## Open` entry was already
|
|
124
|
+
// present are reused, not filed, so a mixed batch keeps the original "File N …" wording
|
|
125
|
+
// with N the count of new entries only.
|
|
126
|
+
(appended) => `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`, opts);
|
|
127
|
+
if (outcome.status === "skipped")
|
|
128
|
+
return { status: "skipped", reason: outcome.reason };
|
|
129
|
+
// fileBugsOnMain's headings align with `tests` in order (reused headings or appended titles).
|
|
130
|
+
const bugs = new Map();
|
|
131
|
+
outcome.headings.forEach((heading, i) => bugs.set(tests[i].test, heading));
|
|
132
|
+
return { status: "filed", bugs };
|
|
133
|
+
}
|
|
@@ -66,7 +66,7 @@ export function actionableEntryRangesFor(root, file, sectionTitle) {
|
|
|
66
66
|
return (cachedTextByStat(actionableRangesCache, `${full}\u0000${sectionTitle}`, full, (md) => actionableEntryRanges(md, sectionTitle), (ranges) => ranges.slice()) ?? []);
|
|
67
67
|
}
|
|
68
68
|
/** True when `row` is a candidate for series-collapsing: it is a series part held only by an
|
|
69
|
-
* unmerged prerequisite (`{ blockedBy }`, not a Refused/needs-review
|
|
69
|
+
* unmerged prerequisite (`{ blockedBy }`, not a Refused/needs-review note) and no live
|
|
70
70
|
* claim names it. Returns the row's series part when it collapses, else null. */
|
|
71
71
|
function collapsibleSeriesPart(row) {
|
|
72
72
|
if (row.claimer !== undefined)
|
|
@@ -116,7 +116,7 @@ export function partsLabel(rows) {
|
|
|
116
116
|
* or more such parts renders as one line at the position of its first collapsed part, spanning from
|
|
117
117
|
* that part's start to the last's end, with the part list (`2/4–4/4`) and a `[blocked: each waits
|
|
118
118
|
* on an earlier part]` mark. A series with a single blocked part, an eligible part, a note-held
|
|
119
|
-
* (Refused/needs-review
|
|
119
|
+
* (Refused/needs-review) part and a claimed part keep their ordinary lines, so the
|
|
120
120
|
* eligibility rules the feature role's charter teaches are unchanged; the collapsed mark keeps the
|
|
121
121
|
* `[blocked: …]` prefix it already skips. */
|
|
122
122
|
/** The grouping half of backlog-index's collapseBlockedSeries, on any rows carrying a title,
|
|
@@ -134,7 +134,7 @@ export function nextSectionHeading(lines, from, fenced) {
|
|
|
134
134
|
/** The body lines of one `## <sectionTitle>` section with fenced content stripped: the
|
|
135
135
|
* sectionLines walk, then a fresh fenceTracker's filter — the shape every reader that
|
|
136
136
|
* classifies a section's lines as markdown structure (entry headings, bullets, dates) walks.
|
|
137
|
-
* Exactly
|
|
137
|
+
* Exactly four call sites today: entryDates and entryLags (both below), backlog-structure.ts's
|
|
138
138
|
* strandedPlanEntries, and stage-backlog-checks.ts's placeholderSections. The fresh
|
|
139
139
|
* tracker is in sync with the document here: sectionLines
|
|
140
140
|
* only recognizes a `## ` boundary outside a fence, so the section's heading line is
|
|
@@ -248,30 +248,44 @@ export function entryDates(md, sectionTitle, dateRe, first = false) {
|
|
|
248
248
|
const dates = [];
|
|
249
249
|
const pick = first ? firstDate : lastDate;
|
|
250
250
|
const lines = sectionBodyLines(md, sectionTitle);
|
|
251
|
-
|
|
252
|
-
const line = lines[i] ?? "";
|
|
253
|
-
if (!line.startsWith("### ") && !line.startsWith("- "))
|
|
254
|
-
continue;
|
|
255
|
-
const meta = headingMetadata(lines, i);
|
|
251
|
+
eachEntryStart(lines, ({ isBullet, meta, tail }) => {
|
|
256
252
|
let date = null;
|
|
257
|
-
if (
|
|
253
|
+
if (isBullet) {
|
|
258
254
|
// Epitaph guard (see the doc comment): the date must live in the line's trailing
|
|
259
255
|
// parenthetical beside a commit reference, or the bullet is body text, not an entry.
|
|
260
256
|
// Within the parenthetical the completion is the LAST dated verb, matching the heading
|
|
261
257
|
// branch: a decomposition cross-reference ("decomposed from the sibling bug fixed
|
|
262
258
|
// <date>, fixed <date>") precedes the entry's own completion record.
|
|
263
|
-
const
|
|
264
|
-
if (/\bcommits?\b/.test(
|
|
265
|
-
date = pick(
|
|
259
|
+
const bulletTail = tail ?? "";
|
|
260
|
+
if (/\bcommits?\b/.test(bulletTail))
|
|
261
|
+
date = pick(bulletTail, dateRe);
|
|
266
262
|
}
|
|
267
263
|
else {
|
|
268
264
|
date = pick(meta.text, dateRe);
|
|
269
265
|
}
|
|
270
266
|
if (date)
|
|
271
267
|
dates.push(date);
|
|
268
|
+
});
|
|
269
|
+
return dates;
|
|
270
|
+
}
|
|
271
|
+
/** One walk over the entry starts inside a `## ` section's body lines, visiting each `### `
|
|
272
|
+
* heading or `- ` bullet line in order. An entry start is a line that begins `### ` or `- `
|
|
273
|
+
* (any other line is body content, skipped); `meta` is headingMetadata's join of the entry's
|
|
274
|
+
* metadata, `tail` is a bullet's trailing `(…)` parenthetical (null for a heading), and
|
|
275
|
+
* `isBullet` tells which shape it is. Advances past each entry's metadata by
|
|
276
|
+
* headingMetadata.next, so a later entry cannot scan the earlier one's continuation as body.
|
|
277
|
+
* Shared by entryDates and entryLags, whose date readings branch on the same `### `/`- `
|
|
278
|
+
* shape, so the two walks stay in lockstep. */
|
|
279
|
+
function eachEntryStart(lines, visit) {
|
|
280
|
+
for (let i = 0; i < lines.length; i++) {
|
|
281
|
+
const line = lines[i] ?? "";
|
|
282
|
+
if (!line.startsWith("### ") && !line.startsWith("- "))
|
|
283
|
+
continue;
|
|
284
|
+
const meta = headingMetadata(lines, i);
|
|
285
|
+
const isBullet = line.startsWith("- ");
|
|
286
|
+
visit({ isBullet, meta, tail: isBullet ? trailingParenthetical(line) : null });
|
|
272
287
|
i = meta.next - 1; // The loop's ++ resumes at the first line not consumed as metadata.
|
|
273
288
|
}
|
|
274
|
-
return dates;
|
|
275
289
|
}
|
|
276
290
|
/** The filing-to-done span of each finished entry inside one `## <sectionTitle>` section,
|
|
277
291
|
* paired with the day it completed on: `{ done, lagDays }` for every entry `entryDates` counts
|
|
@@ -287,22 +301,18 @@ export function entryDates(md, sectionTitle, dateRe, first = false) {
|
|
|
287
301
|
export function entryLags(md, sectionTitle, dateRe, filingRe) {
|
|
288
302
|
const lags = [];
|
|
289
303
|
const lines = sectionBodyLines(md, sectionTitle);
|
|
290
|
-
|
|
291
|
-
const line = lines[i] ?? "";
|
|
292
|
-
if (!line.startsWith("### ") && !line.startsWith("- "))
|
|
293
|
-
continue;
|
|
294
|
-
const meta = headingMetadata(lines, i);
|
|
304
|
+
eachEntryStart(lines, ({ isBullet, meta, tail }) => {
|
|
295
305
|
let done = null;
|
|
296
306
|
let filed = null;
|
|
297
|
-
if (
|
|
307
|
+
if (isBullet) {
|
|
298
308
|
// Same epitaph guard as entryDates: the completion must live in the trailing
|
|
299
309
|
// parenthetical beside a commit reference, and the filing read follows the same
|
|
300
310
|
// rule — sectionFilingDates matches the first stamp inside that same parenthetical
|
|
301
311
|
// for a `- ` bullet, so a description-date mention before it must not pair.
|
|
302
|
-
const
|
|
303
|
-
if (/\bcommits?\b/.test(
|
|
304
|
-
done = lastDate(
|
|
305
|
-
filed = firstDate(
|
|
312
|
+
const bulletTail = tail ?? "";
|
|
313
|
+
if (/\bcommits?\b/.test(bulletTail)) {
|
|
314
|
+
done = lastDate(bulletTail, dateRe);
|
|
315
|
+
filed = firstDate(bulletTail, filingRe);
|
|
306
316
|
}
|
|
307
317
|
}
|
|
308
318
|
else {
|
|
@@ -314,8 +324,7 @@ export function entryLags(md, sectionTitle, dateRe, filingRe) {
|
|
|
314
324
|
if (lagDays >= 0)
|
|
315
325
|
lags.push({ done, lagDays });
|
|
316
326
|
}
|
|
317
|
-
|
|
318
|
-
}
|
|
327
|
+
});
|
|
319
328
|
return lags;
|
|
320
329
|
}
|
|
321
330
|
/** A `### `/`- ` entry start's METADATA, joined for matching: the start line plus, for `### `
|
|
@@ -407,10 +416,12 @@ export function lastDate(text, dateRe) {
|
|
|
407
416
|
* the filing onto a later day. This is the same first-match the canonical `filingDate`
|
|
408
417
|
* (backlog.ts) applies to a heading line, so the filing read and `plan|bug --list`'s age agree
|
|
409
418
|
* about when an entry opened. dateRe is cloned with the g flag so matchAll sees every date; a
|
|
410
|
-
* text with no date yields null.
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
|
|
419
|
+
* text with no date yields null. Exported so the heading stamp parsers that apply the same
|
|
420
|
+
* filing rule to a single title line — `filingDate` (backlog.ts) and `questionFilingDate`
|
|
421
|
+
* (question-answer.ts) — share the one first-match mechanism instead of re-stating it;
|
|
422
|
+
* entryDates selects it with `first`, so the walker stays single-homed like lastDate's
|
|
423
|
+
* completion rule. */
|
|
424
|
+
export function firstDate(text, dateRe) {
|
|
414
425
|
const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
|
|
415
426
|
const match = global.exec(text);
|
|
416
427
|
return match?.[1] ?? null;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/** The hold-lifting and reordering writes of `tumwater bug|plan --release <n>` and `--top <n>`:
|
|
2
|
-
* lift an entry's Refused
|
|
2
|
+
* lift an entry's Refused or held-for-operator note, or move
|
|
3
3
|
* it to the head of its open
|
|
4
4
|
* section. Each comes as an exiting writer (the CLI) and a non-exiting `try…` core that refuses as
|
|
5
5
|
* data (the TUI and dashboard); entry lookup and the section's file target are
|
|
6
6
|
* backlog-write.ts's. */
|
|
7
7
|
import { backlogTarget, findEntry, locateDashboardEntry, readEntryLines } from "./backlog-write.js";
|
|
8
|
-
import { withoutHeldForOperatorNotes, withoutRefusalNotes
|
|
8
|
+
import { withoutHeldForOperatorNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
|
|
9
9
|
import { writeTextAtomic } from "../files/files.js";
|
|
10
10
|
import { fail } from "../cli/cli-output.js";
|
|
11
11
|
import { trimTrailingBlankLines } from "../text/text.js";
|
|
@@ -15,29 +15,21 @@ import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
|
|
|
15
15
|
export function liftedNotePhrase(lifted) {
|
|
16
16
|
return lifted === "refused"
|
|
17
17
|
? "the Refused note"
|
|
18
|
-
:
|
|
19
|
-
? "the Waiting-for-the-next-failure note"
|
|
20
|
-
: "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
|
|
18
|
+
: "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
|
|
21
19
|
}
|
|
22
20
|
/** Drop the Refused note from `entry`'s slice of `lines`, in place, and return its kind; an
|
|
23
|
-
* entry with none loses its
|
|
24
|
-
* loses its held-for-operator Needs-review/Needs-replan note (the `(held N)` count at/above
|
|
21
|
+
* entry with none loses its held-for-operator Needs-review note (the `(held N)` count at/above
|
|
25
22
|
* threshold, plus its **Previously held N** line). Null (and `lines` untouched) when the entry
|
|
26
|
-
* carries
|
|
23
|
+
* carries neither, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
|
|
27
24
|
function liftRefusal(lines, entry) {
|
|
28
25
|
const slice = lines.slice(entry.start - 1, entry.end);
|
|
29
26
|
let kept = withoutRefusalNotes(slice);
|
|
30
27
|
if (kept.length === slice.length) {
|
|
31
|
-
kept =
|
|
32
|
-
if (kept.length === slice.length)
|
|
33
|
-
|
|
34
|
-
if (kept.length === slice.length)
|
|
35
|
-
return null;
|
|
36
|
-
lines.splice(entry.start - 1, slice.length, ...kept);
|
|
37
|
-
return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
|
|
38
|
-
}
|
|
28
|
+
kept = withoutHeldForOperatorNotes(slice);
|
|
29
|
+
if (kept.length === slice.length)
|
|
30
|
+
return null;
|
|
39
31
|
lines.splice(entry.start - 1, slice.length, ...kept);
|
|
40
|
-
return "
|
|
32
|
+
return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
|
|
41
33
|
}
|
|
42
34
|
lines.splice(entry.start - 1, slice.length, ...kept);
|
|
43
35
|
return "refused";
|
|
@@ -59,7 +51,7 @@ export function tryReleaseEntry(root, kind, n, expectedTitle) {
|
|
|
59
51
|
return { status: "released", file: fileName, title: entry.title, lifted };
|
|
60
52
|
});
|
|
61
53
|
}
|
|
62
|
-
/** Lift the Refused
|
|
54
|
+
/** Lift the Refused or held-for-operator note from the
|
|
63
55
|
* `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
|
|
64
56
|
* file, under the same lock the cancel and edit writers hold, returning which hold it lifted.
|
|
65
57
|
* An out-of-range position fails with locateEntry's wording; an entry with neither note
|
|
@@ -70,7 +62,7 @@ export function releaseEntry(root, kind, n) {
|
|
|
70
62
|
const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
|
|
71
63
|
const lifted = liftRefusal(lines, entry);
|
|
72
64
|
if (lifted === null)
|
|
73
|
-
fail(`${kind} #${n} "${entry.title}" carries no Refused
|
|
65
|
+
fail(`${kind} #${n} "${entry.title}" carries no Refused or held-for-operator note`); // vocabulary-ok: the held-for-operator note's release wording
|
|
74
66
|
writeTextAtomic(file, lines.join("\n"));
|
|
75
67
|
return { file: fileName, title: entry.title, lifted };
|
|
76
68
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { increment } from "../collections.js";
|
|
3
4
|
import { readTextOrNull } from "../files/files.js";
|
|
@@ -251,7 +252,19 @@ export async function backlogStructureReason(wt, mainBranch, files) {
|
|
|
251
252
|
const baseRev = await changeBaseRev(wt, mainBranch);
|
|
252
253
|
for (const file of touched) {
|
|
253
254
|
// A deleted file reads as empty: every heading the base had is gone — rule (b).
|
|
254
|
-
const
|
|
255
|
+
const full = path.join(wt, file);
|
|
256
|
+
// A symlink is never followed out of the worktree (it holds no heading of its own — the
|
|
257
|
+
// same lstat policy the backlog-conflict resolver and the false-fix guard pin, INV-G3), so
|
|
258
|
+
// it too reads as empty rather than bringing an arbitrary host file's headings into this
|
|
259
|
+
// gate's verdict.
|
|
260
|
+
let head = "";
|
|
261
|
+
try {
|
|
262
|
+
if (fs.lstatSync(full).isFile())
|
|
263
|
+
head = readTextOrNull(full) ?? "";
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
head = "";
|
|
267
|
+
}
|
|
255
268
|
const base = await fileContentAt(wt, baseRev, file);
|
|
256
269
|
const headCounts = sectionTitleCounts(head);
|
|
257
270
|
const baseCounts = sectionTitleCounts(base);
|
|
@@ -361,7 +361,7 @@ function appendOperatorEntry(root, fileName, template, sectionTitle, title, body
|
|
|
361
361
|
}
|
|
362
362
|
/** Append one tumwater-filed bug under BUGS.md's `## Open` with the heading as given (no operator
|
|
363
363
|
* stamp), for a caller that already holds the primary-worktree lock and commits the file itself
|
|
364
|
-
* (backlog-
|
|
364
|
+
* (backlog-file-bugs.ts's quarantine filing). Returns the heading line's title. */
|
|
365
365
|
export function appendBugEntryLocked(root, title, body) {
|
|
366
366
|
const t = collapseWhitespace(title);
|
|
367
367
|
requireBodyStructurallySound(body, "filed");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { cachedTextByStat } from "../files/stat-cache.js";
|
|
3
|
-
import { entryDates, entryLags, finishedArchiveOf, lastDate, parseEntryDetails } from "./backlog-md.js";
|
|
3
|
+
import { entryDates, entryLags, finishedArchiveOf, firstDate, lastDate, parseEntryDetails } from "./backlog-md.js";
|
|
4
4
|
import { CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
|
|
5
5
|
/** The stat-cached file readers of the project backlog data shown on both dashboards: planned
|
|
6
6
|
* features (PLANS.md), open bugs (BUGS.md), and open questions (QUESTIONS.md). These are tracked
|
|
@@ -79,11 +79,12 @@ export function sectionCompletionLags(root, fileName, sectionTitle, dateRe) {
|
|
|
79
79
|
* bugs with `(found by …)` or `(reported …)`), parsed so `plan|bug --list` shows how long an entry
|
|
80
80
|
* has waited, without opening the file. Read only the title — the whole heading, never the body,
|
|
81
81
|
* because the stamp is the entry's own and the body may name other dates.
|
|
82
|
-
* null when the heading carries no such clause.
|
|
82
|
+
* null when the heading carries no such clause. Routed through backlog-md.ts's firstDate -
|
|
83
|
+
* the one first-match mechanism - with this module's own stamp regex.
|
|
84
|
+
* questionFilingDate (question-answer.ts) is the same recipe for the `asked by` verb. */
|
|
83
85
|
const STAMP_DATE_RE = /(?:planned|reported|found by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/;
|
|
84
86
|
export function filingDate(title) {
|
|
85
|
-
|
|
86
|
-
return m ? m[1] : null;
|
|
87
|
+
return firstDate(title, STAMP_DATE_RE);
|
|
87
88
|
}
|
|
88
89
|
/** The completion stamp of a `## Done` plan heading. */
|
|
89
90
|
export const DONE_DATE_RE = /done (\d{4}-\d{2}-\d{2})/;
|