tumwater 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-run.js +1 -0
- package/dist/src/agent/opencode/write-guard-plugin/index.js +11 -8
- package/dist/src/agent/subscription-account.js +68 -3
- package/dist/src/agent/write-guard.js +50 -13
- package/dist/src/backlog/backlog-eligibility.js +8 -4
- package/dist/src/backlog/backlog-index.js +2 -2
- package/dist/src/backlog/backlog-md.js +45 -19
- package/dist/src/backlog/backlog-release.js +3 -11
- package/dist/src/backlog/backlog-write.js +19 -11
- package/dist/src/baseline/main-baseline.js +47 -72
- package/dist/src/baseline/main-guardrails.js +11 -7
- package/dist/src/baseline/main-red.js +171 -19
- package/dist/src/baseline/main-watch.js +71 -24
- package/dist/src/budget/budget-fallback-view.js +96 -28
- package/dist/src/build/build-check-detect.js +22 -3
- package/dist/src/build/build-check-events.js +25 -5
- package/dist/src/build/build-check-report.js +40 -4
- package/dist/src/build/build-check-rerun.js +62 -0
- package/dist/src/build/build-check-scoped.js +13 -2
- package/dist/src/build/build-check.js +2 -1
- package/dist/src/build/build-info.js +4 -0
- package/dist/src/build/dep-install.js +1 -2
- package/dist/src/build/host-sleep.js +9 -0
- package/dist/src/change/change-render.js +2 -1
- package/dist/src/cli/cli-args.js +48 -3
- package/dist/src/cli/cli-command-args.js +12 -4
- package/dist/src/cli/cli-flag-specs.js +5 -3
- package/dist/src/cli/cli-init.js +5 -1
- package/dist/src/cli/cli-log-commands.js +2 -2
- package/dist/src/cli/cli-query-commands.js +31 -15
- package/dist/src/cli/cli-review-commands.js +3 -3
- package/dist/src/cli/cli-role-command.js +338 -244
- package/dist/src/cli/cli-start.js +10 -2
- package/dist/src/cli/completion.js +6 -2
- package/dist/src/cli/help.js +6 -3
- package/dist/src/cli.js +112 -140
- package/dist/src/collections.js +21 -0
- package/dist/src/concurrency/check-permit.js +30 -3
- package/dist/src/concurrency/lock.js +73 -14
- package/dist/src/config/config-editable-keys.js +9 -0
- package/dist/src/config/config-fallbacks.js +16 -5
- package/dist/src/config/config-schema.js +3 -3
- package/dist/src/config/config-validation.js +2 -0
- package/dist/src/config/config-views.js +55 -33
- package/dist/src/config/config-write.js +76 -16
- package/dist/src/config/config.js +12 -5
- package/dist/src/doctor/doctor-backlog-bugs.js +33 -1
- package/dist/src/doctor/doctor-backlog.js +6 -8
- package/dist/src/doctor/doctor-check-names.js +2 -0
- package/dist/src/doctor/doctor-checks.js +13 -6
- package/dist/src/doctor/doctor-claude-checks.js +6 -13
- package/dist/src/doctor/doctor-codex-checks.js +2 -2
- package/dist/src/doctor/doctor-contention-checks.js +7 -6
- package/dist/src/doctor/doctor-fallback-checks.js +37 -2
- package/dist/src/doctor/doctor-holds.js +7 -10
- package/dist/src/doctor/doctor-model-checks.js +16 -1
- package/dist/src/doctor/doctor-opencode-checks.js +2 -2
- package/dist/src/doctor/doctor-orphans.js +103 -27
- package/dist/src/doctor/doctor-project-checks.js +43 -26
- package/dist/src/doctor/doctor.js +5 -3
- package/dist/src/events/event-format.js +113 -136
- package/dist/src/events/event-labels.js +3 -1
- package/dist/src/events/events.js +3 -3
- package/dist/src/failure/failure-data.js +6 -4
- package/dist/src/failure/failure-render.js +1 -0
- package/dist/src/failure/failure-state-change.js +14 -4
- package/dist/src/failure/failure-time-spend.js +1 -0
- package/dist/src/files/file-queue.js +32 -19
- package/dist/src/files/files.js +2 -2
- package/dist/src/files/json-object.js +3 -2
- package/dist/src/gates/budget-gates.js +37 -2
- package/dist/src/gates/gate-polls.js +8 -7
- package/dist/src/gates/{maintenance-quota.js → maintenance-quota-gates.js} +1 -1
- package/dist/src/gates/startup-gates.js +18 -1
- package/dist/src/git/git.js +8 -0
- package/dist/src/git/slot-salvage.js +6 -0
- package/dist/src/git/worktree-pool.js +18 -5
- package/dist/src/gui/gui-admin-endpoints.js +11 -1
- package/dist/src/gui/gui-args.js +2 -2
- package/dist/src/gui/gui-backlog-endpoints.js +2 -1
- package/dist/src/gui/gui-history-endpoints.js +7 -7
- package/dist/src/gui/gui-operator-commands.js +2 -3
- package/dist/src/history/history.js +12 -8
- package/dist/src/{run → history}/run-detail-data.js +2 -2
- package/dist/src/{run → history}/run-detail-render.js +2 -2
- package/dist/src/{run → history}/run-detail.js +2 -2
- package/dist/src/inbox/director-request.js +92 -0
- package/dist/src/inbox/inbox-top.js +2 -1
- package/dist/src/merge/merge-backlog-conflicts.js +33 -11
- package/dist/src/merge/merge-batch.js +22 -235
- package/dist/src/merge/merge-cancel.js +2 -1
- package/dist/src/merge/merge-core.js +26 -22
- package/dist/src/merge/merge-drain.js +116 -101
- package/dist/src/merge/merge-pipeline.js +11 -8
- package/dist/src/merge/merge-post-review-drain.js +8 -2
- package/dist/src/merge/merge-questions.js +3 -3
- package/dist/src/merge/merge-queue-render.js +2 -2
- package/dist/src/merge/merge-queue.js +9 -1
- package/dist/src/merge/merge-requeue.js +67 -26
- package/dist/src/merge/merge-slot.js +41 -10
- package/dist/src/merge/merge-to-main.js +20 -165
- package/dist/src/merge/merge-top.js +2 -1
- package/dist/src/merge/merge-verify.js +18 -5
- package/dist/src/merge/merge-withdrawn.js +71 -0
- package/dist/src/model-check/model-check-gate.js +206 -0
- package/dist/src/model-check/model-check-probe.js +109 -0
- package/dist/src/model-check/model-check.js +101 -0
- package/dist/src/{run → observer}/ci-parity.js +15 -2
- package/dist/src/operator/operator-commands.js +8 -25
- package/dist/src/operator/operator-revert.js +1 -1
- package/dist/src/orchestrator/orchestrator-launch.js +6 -1
- package/dist/src/orchestrator/orchestrator-poll.js +32 -5
- package/dist/src/orchestrator/orchestrator-scheduling.js +7 -7
- package/dist/src/orchestrator/orchestrator.js +74 -13
- package/dist/src/paths.js +41 -3
- package/dist/src/pi/pi-run-result.js +16 -0
- package/dist/src/pi/pi-watchdogs.js +15 -4
- package/dist/src/process/process.js +24 -0
- package/dist/src/prompt/gate-notes.js +10 -4
- package/dist/src/prompt/gate-prompts.js +19 -15
- package/dist/src/prompt/principles.js +13 -3
- package/dist/src/prompt/prompt-followup.js +14 -5
- package/dist/src/prompt/prompt.js +11 -0
- package/dist/src/redeploy/redeploy-probes.js +53 -18
- package/dist/src/redeploy/redeploy-target.js +100 -0
- package/dist/src/redeploy/redeployer.js +135 -70
- package/dist/src/report/report-main-red.js +2 -2
- package/dist/src/report/report-render.js +2 -2
- package/dist/src/report/report.js +2 -2
- package/dist/src/review/known-flakes.js +105 -42
- package/dist/src/review/post-merge-review-accept.js +2 -1
- package/dist/src/review/post-merge-review.js +46 -34
- package/dist/src/review/review-precheck.js +72 -28
- package/dist/src/review/review-verdict.js +25 -17
- package/dist/src/roles/role-catalog.js +23 -7
- package/dist/src/roles/role-render.js +158 -83
- package/dist/src/roles/role-view.js +297 -75
- package/dist/src/roles/roles.js +1 -1
- package/dist/src/run/run-apply.js +3 -8
- package/dist/src/run/run-dismiss.js +2 -2
- package/dist/src/run/run-finalize.js +4 -0
- package/dist/src/run/run-prompt.js +4 -4
- package/dist/src/run/run-stage.js +7 -1
- package/dist/src/run/run-timing.js +2 -2
- package/dist/src/run/run-usage.js +9 -1
- package/dist/src/run/run-verdict.js +80 -52
- package/dist/src/runner/runner-leftover.js +48 -1
- package/dist/src/runner/runner-outcome.js +1 -2
- package/dist/src/runner/runner-pi.js +6 -0
- package/dist/src/runner/runner-recovery.js +5 -4
- package/dist/src/runner/runner-revision.js +2 -1
- package/dist/src/runner/runner-run.js +165 -110
- package/dist/src/runner/runner-state-load.js +115 -53
- package/dist/src/runner/runner-state.js +3 -2
- package/dist/src/runner/runner.js +39 -20
- package/dist/src/{orchestrator/scheduling-admit.js → scheduling/admit.js} +2 -2
- package/dist/src/scheduling/backoff.js +6 -6
- package/dist/src/scheduling/claims.js +103 -26
- package/dist/src/{orchestrator/scheduling-scope.js → scheduling/scope.js} +24 -60
- package/dist/src/{orchestrator/scheduling-skip.js → scheduling/skip.js} +112 -78
- package/dist/src/{run → stage}/stage-path-checks.js +3 -3
- package/dist/src/status/status-budget.js +38 -4
- package/dist/src/status/status-data.js +23 -14
- package/dist/src/status/status-polls.js +83 -26
- package/dist/src/status/status-role-notes.js +25 -7
- package/dist/src/status/status-roles.js +56 -25
- package/dist/src/team/team-orchestrator-info.js +51 -0
- package/dist/src/team/team-state.js +25 -9
- package/dist/src/text/format.js +20 -2
- package/dist/src/text/phrases.js +59 -12
- package/dist/src/text/suggest.js +14 -1
- package/dist/src/text/text.js +31 -2
- package/dist/src/ui/badges.js +102 -17
- package/dist/src/ui/gui/gui-client-drawer-config.js +12 -6
- package/dist/src/ui/gui/gui-client-model.js +5 -0
- package/dist/src/ui/gui/gui-client-operator.js +11 -14
- package/dist/src/ui/gui/gui-client-pending.js +10 -3
- package/dist/src/ui/gui/gui-client-report-chart.js +22 -15
- package/dist/src/ui/gui/gui-client-settings.js +25 -11
- package/dist/src/ui/gui/gui-client-team.js +7 -0
- package/dist/src/ui/status-model.js +21 -6
- package/dist/src/ui/status-payload.js +19 -5
- package/dist/src/ui/status-render.js +14 -8
- package/dist/src/ui/team-alert-shared.js +2 -2
- package/dist/src/ui/team-alert-standings.js +96 -0
- package/dist/src/ui/team-alert-status.js +39 -12
- package/dist/src/ui/team-alerts.js +8 -4
- package/dist/src/ui/tone.js +2 -1
- package/dist/src/ui/tui/tui-backlog.js +12 -0
- package/dist/src/ui/tui/tui-editor-attach.js +2 -6
- package/dist/src/ui/tui/tui-editor-brief.js +2 -6
- package/dist/src/ui/tui/tui-editor-interval.js +2 -2
- package/dist/src/ui/tui/tui-editor-release.js +2 -6
- package/dist/src/ui/tui/tui-editor-retire.js +2 -6
- package/dist/src/ui/tui/tui-editor-role-enabled.js +2 -6
- package/dist/src/ui/tui/tui-editor-role-model.js +5 -1
- package/dist/src/ui/tui/tui-editor-session.js +14 -0
- package/dist/src/ui/tui/tui-guard.js +16 -0
- package/dist/src/ui/tui/tui-input.js +2 -2
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-backlog.js +2 -2
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +29 -35
- package/dist/src/ui/tui/tui-pane-history.js +158 -479
- package/dist/src/ui/tui/tui-pane-pending-selection.js +346 -0
- package/dist/src/ui/tui/tui-pane-pending.js +54 -344
- package/dist/src/ui/tui/tui-pane-queued.js +2 -2
- package/dist/src/ui/tui/tui-pane-trail.js +415 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +2 -7
- package/dist/src/ui/tui/tui-pane.js +3 -3
- package/dist/src/ui/tui/tui.js +2 -1
- package/dist/src/verdict/fix-claim.js +10 -4
- package/dist/src/verdict/reply-contract.js +14 -5
- package/package.json +9 -7
- package/dist/src/merge/merge-check-failures.js +0 -212
- package/dist/src/merge/merge-stack.js +0 -235
- /package/dist/src/{run → observer}/feedback-filings.js +0 -0
- /package/dist/src/{run → observer}/qa-coverage.js +0 -0
- /package/dist/src/{run → observer}/selfcheck-digest.js +0 -0
- /package/dist/src/{run → stage}/acceptance-greps.js +0 -0
- /package/dist/src/{run → stage}/stage-backlog-checks.js +0 -0
- /package/dist/src/{run → stage}/stage-check.js +0 -0
- /package/dist/src/{run → stage}/stage-checks-shared.js +0 -0
- /package/dist/src/{run → stage}/stage-name-checks.js +0 -0
- /package/dist/src/{run → stage}/stage-prose-checks.js +0 -0
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ and is also outside `npm test`.
|
|
|
42
42
|
## Status
|
|
43
43
|
|
|
44
44
|
<!-- tumwater:status:start -->
|
|
45
|
-
**v0.4.
|
|
45
|
+
**v0.4.6**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
|
|
46
46
|
which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
|
|
47
47
|
issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
|
|
48
48
|
|
package/dist/build-info.json
CHANGED
|
@@ -122,6 +122,7 @@ export function runAgent(opts) {
|
|
|
122
122
|
extendOnProgress: opts.kind === "gate",
|
|
123
123
|
kill: () => terminateChild(child),
|
|
124
124
|
onStallWarning: opts.onStallWarning,
|
|
125
|
+
onUnansweredReply: opts.onUnansweredReply,
|
|
125
126
|
});
|
|
126
127
|
let aborted = false;
|
|
127
128
|
// A blind image read on an authoring run deliberately kills it, like the watchdogs do.
|
|
@@ -4,20 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* OpenCode 2.x has no hook that blocks a tool from running. It has a permission `evaluate` hook
|
|
6
6
|
* that may turn a decision to `deny` with a `message`, which reaches the model as the tool's error
|
|
7
|
-
* while the run goes on.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* while the run goes on. The file-writing tools ask for the `edit` action (edit in place) and the
|
|
8
|
+
* `write` action (create or overwrite a file — a new path a `../` climb can point outside the
|
|
9
|
+
* checkout) with the absolute paths they write as `resources`, and the hook runs under `--auto`
|
|
10
|
+
* too, since `--auto` only answers what would otherwise be asked. Checked live on opencode 2.1.0
|
|
11
|
+
* on 2026-10-23. A configured plugin path must be a directory, which is why this is `index.js` in
|
|
12
|
+
* a directory of its own.
|
|
12
13
|
*
|
|
13
14
|
* The plugin's context names the run's directory (`location.directory`, the worktree OpenCode
|
|
14
15
|
* runs in), which is the run directory the rule reads its checkout from. A failure of its own
|
|
15
16
|
* leaves the decision alone, so a broken guard cannot stop every edit of a run. */
|
|
16
17
|
import { strayWriteRefusal } from "../../write-guard.js";
|
|
17
|
-
/** Deny an `edit` decision whose resources include a stray path from a run in
|
|
18
|
-
* the first such path's refusal. Every other decision is left as it is. Exported
|
|
18
|
+
/** Deny an `edit` or `write` decision whose resources include a stray path from a run in
|
|
19
|
+
* `runDir`, naming the first such path's refusal. Every other decision is left as it is. Exported
|
|
20
|
+
* for tests. */
|
|
21
|
+
const FILE_WRITE_ACTIONS = new Set(["edit", "write"]);
|
|
19
22
|
export function guardEvaluate(event, runDir) {
|
|
20
|
-
if (event.action
|
|
23
|
+
if (!FILE_WRITE_ACTIONS.has(event.action) || !Array.isArray(event.resources))
|
|
21
24
|
return;
|
|
22
25
|
for (const resource of event.resources) {
|
|
23
26
|
if (typeof resource !== "string" || resource === "")
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* "Billing" and §8 "The subscription usage window"). Persisted at
|
|
4
4
|
* `.tumwater/state/<backend>-account.json` (plan design grounds/codex-backend.md "Groundwork:
|
|
5
5
|
* the subscription account is per backend", item 1), written only by the orchestrator process,
|
|
6
|
-
* so the
|
|
6
|
+
* so the free-ness rule and the window displays read one answer instead of re-deriving it per
|
|
7
|
+
* surface.
|
|
7
8
|
*
|
|
8
9
|
* The file is a cache, never a source of truth: a run that reports billing overwrites it, and a
|
|
9
10
|
* missing or unreadable file reads as "no subscription". `resetsAt` is stored as epoch
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
* keeps today's shape. */
|
|
15
16
|
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
16
17
|
import { finiteNumber, isJsonObject, setOwn } from "../files/json-object.js";
|
|
18
|
+
import { cachedByStat } from "../files/stat-cache.js";
|
|
17
19
|
import { accountPath } from "../paths.js";
|
|
18
20
|
/** True when a failed run ended on a subscription usage window the account reported as
|
|
19
21
|
* `rejected` without overage — the signal the team hold throttles on (plans/agent-backends.md
|
|
@@ -27,12 +29,74 @@ export function subscriptionWindowRejected(run) {
|
|
|
27
29
|
run.billing === "subscription" &&
|
|
28
30
|
(run.rateWindows ?? []).some((w) => w.status === "rejected" && w.isUsingOverage !== true));
|
|
29
31
|
}
|
|
32
|
+
/** Status severity for ordering: a rejected window limits, an allowed_warning nears its limit,
|
|
33
|
+
* every other reading (allowed/ok) rides at the bottom, and a reset one no longer limits. */
|
|
34
|
+
const WINDOW_RANK = { rejected: 3, allowed_warning: 2, reset: 0 };
|
|
35
|
+
/** The window type as a compact label: `five_hour` → `5h`, `seven_day` → `7d`; an unknown
|
|
36
|
+
* type falls back to its words with underscores as spaces. The one home of the window spelling
|
|
37
|
+
* the status badge and the role view share (the browser dashboard keeps its inline copy). */
|
|
38
|
+
export function windowLabel(type) {
|
|
39
|
+
const m = /^(\w+)_(hour|day|week)$/.exec(type);
|
|
40
|
+
if (!m)
|
|
41
|
+
return type.replace(/_/g, " ");
|
|
42
|
+
const words = { one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10" };
|
|
43
|
+
const unit = m[2] === "hour" ? "h" : m[2] === "day" ? "d" : "w";
|
|
44
|
+
const word = m[1] ?? "";
|
|
45
|
+
return `${words[word] ?? word}${unit}`;
|
|
46
|
+
}
|
|
47
|
+
/** The fraction of a subscription usage window consumed (0–1) as a whole-number percent: a
|
|
48
|
+
* `0.64` utilization reads `64`. The one node-side home of the fraction→percent conversion
|
|
49
|
+
* (the browser dashboard's own window line is embedded client-side and keeps its inline copy). */
|
|
50
|
+
export function utilizationPct(utilization) {
|
|
51
|
+
return Math.round(utilization * 100);
|
|
52
|
+
}
|
|
53
|
+
/** Order two window entries by the windowEntries rule: status severity, then the reported
|
|
54
|
+
* utilization (a missing reading sorts below any number), then the earlier reset, then the
|
|
55
|
+
* type name. Exported so a list of per-backend blocks can be ordered by their primary entry
|
|
56
|
+
* the same way windowEntries orders one account's windows (part 3a/9). */
|
|
57
|
+
export function compareWindowEntries(a, b) {
|
|
58
|
+
const rank = (WINDOW_RANK[b.status] ?? 1) - (WINDOW_RANK[a.status] ?? 1);
|
|
59
|
+
if (rank !== 0)
|
|
60
|
+
return rank;
|
|
61
|
+
const util = (b.utilization ?? -1) - (a.utilization ?? -1);
|
|
62
|
+
if (util !== 0)
|
|
63
|
+
return util;
|
|
64
|
+
const reset = (a.resetsAt ?? Number.POSITIVE_INFINITY) - (b.resetsAt ?? Number.POSITIVE_INFINITY);
|
|
65
|
+
if (reset !== 0)
|
|
66
|
+
return reset;
|
|
67
|
+
return a.type.localeCompare(b.type);
|
|
68
|
+
}
|
|
69
|
+
/** The account's windows as a list, most constrained first (by compareWindowEntries). Empty
|
|
70
|
+
* when the account reported none. */
|
|
71
|
+
export function windowEntries(account) {
|
|
72
|
+
return Object.entries(account.windows)
|
|
73
|
+
.map(([type, w]) => ({ type, ...w }))
|
|
74
|
+
.sort((a, b) => compareWindowEntries(a, b));
|
|
75
|
+
}
|
|
76
|
+
/** The parsed raw body of one backend's account file, keyed by that file's own stat — the same
|
|
77
|
+
* stat-keyed contract cachedByStat gives every observer that polls a slowly-changing file. The
|
|
78
|
+
* account file is written only by the orchestrator on a run's billing/window report, so it
|
|
79
|
+
* changes no faster than runs; on the status snapshot path readAccount ran per backend per poll
|
|
80
|
+
* as one more readJsonFile (read + parse) alongside the already stat-cached marker reads, and
|
|
81
|
+
* budget-gates/role-view read it on other polling surfaces. Writes go through writeJsonAtomic's
|
|
82
|
+
* tmp+rename (writeAccount), which bumps dev/ino/mtime/size, so a refresh invalidates like any
|
|
83
|
+
* edit. The `now`-dependent heal is NOT cached: only the raw body is, and readAccount reapplies
|
|
84
|
+
* its window/observedAt rules against each call's own `now` on a per-call copy, so a window
|
|
85
|
+
* whose `resetsAt` lapses still reads back as reset on the very call it does. Bounded inside
|
|
86
|
+
* cachedByStat. */
|
|
87
|
+
const accountBodyCache = new Map();
|
|
88
|
+
/** Serve `file`'s parsed raw JSON-object body from accountBodyCache: null when the file is
|
|
89
|
+
* missing, unreadable, torn, or not a plain object — the same no-data policy as readJsonFile —
|
|
90
|
+
* and a fresh per-call copy so readAccount's owns the object it reads fields off. Never throws. */
|
|
91
|
+
function readAccountBody(file) {
|
|
92
|
+
return cachedByStat(accountBodyCache, file, file, () => readJsonFile(file), (body) => ({ ...body }));
|
|
93
|
+
}
|
|
30
94
|
/** Read the persisted account state for a backend, or null when it is absent or unreadable. A
|
|
31
95
|
* file without a usable top-level `observedAt` reads as absent (the plan's torn-file rule),
|
|
32
96
|
* and a window whose `resetsAt` is at or before `now` reads back with `status: "reset"` — the
|
|
33
97
|
* reader, not every consumer, decides that the window no longer limits. */
|
|
34
98
|
export function readAccount(root, backend, now = Date.now()) {
|
|
35
|
-
const doc =
|
|
99
|
+
const doc = readAccountBody(accountPath(root, backend));
|
|
36
100
|
if (doc === null)
|
|
37
101
|
return null;
|
|
38
102
|
const billing = doc.billing === "subscription" || doc.billing === "api" ? (doc.billing) : undefined;
|
|
@@ -64,7 +128,8 @@ function readWindow(value, fileObservedAt, now) {
|
|
|
64
128
|
const resetsAt = finiteNumber(value.resetsAt, Number.NaN);
|
|
65
129
|
const resets = Number.isFinite(resetsAt);
|
|
66
130
|
// utilization heals like the two stamps above: JSON's `1e999` parses to Infinity, and a
|
|
67
|
-
// non-finite fraction would
|
|
131
|
+
// non-finite fraction would render as `Infinity%` in the badges and poison the
|
|
132
|
+
// windowEntries comparator (Infinity - Infinity is NaN).
|
|
68
133
|
const utilization = finiteNumber(value.utilization, undefined);
|
|
69
134
|
const reset = resets && resetsAt <= now;
|
|
70
135
|
const window = {
|
|
@@ -27,25 +27,62 @@ function isWithin(p, dir) {
|
|
|
27
27
|
const rel = path.relative(dir, p);
|
|
28
28
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
29
29
|
}
|
|
30
|
-
/** `p` with its
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* unchanged.
|
|
30
|
+
/** `p` with its symlinks resolved as the file it reaches: each component is peeled and a
|
|
31
|
+
* symlink is followed to its target, so a symlinked checkout or a `/tmp` that is `/private/tmp`
|
|
32
|
+
* compares as the path it reaches. The not-yet-existing tail (a file a write would create) is
|
|
33
|
+
* appended unchanged. A broken symlink — target absent, the write/create case, where
|
|
34
|
+
* `realpathSync` throws ENOENT and the old longest-existing-prefix walk read the link as its
|
|
35
|
+
* own location inside the checkout — is followed to its target instead: the write would follow
|
|
36
|
+
* it, so the guard must see where it points. */
|
|
34
37
|
function realish(p) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// Peel the path component by component, exactly as `realpath` does: a regular component is
|
|
39
|
+
// kept, a symlink is replaced by the components its target resolves to, and the first absent
|
|
40
|
+
// component ends the walk, so a not-yet-existing tail is appended unchanged. Replacing the
|
|
41
|
+
// link with its target — instead of keeping the link's own path — is what catches a broken
|
|
42
|
+
// symlink: its target is absent (the write/create case, where `realpathSync` throws ENOENT
|
|
43
|
+
// and the old longest-existing-prefix walk read the link as its location inside the
|
|
44
|
+
// checkout), yet the write would still follow it, so the guard must see where it points. A
|
|
45
|
+
// depth bound stops a symlink cycle (a → b → a) from looping instead of escaping through the
|
|
46
|
+
// tail.
|
|
47
|
+
let q = path.resolve(p).split(path.sep).filter(Boolean);
|
|
48
|
+
const out = [];
|
|
49
|
+
let depth = 0;
|
|
50
|
+
while (q.length > 0) {
|
|
51
|
+
const part = q[0]; // the loop's length check guarantees a first component
|
|
52
|
+
const here = path.join(path.sep, ...out, part);
|
|
53
|
+
let st;
|
|
38
54
|
try {
|
|
39
|
-
|
|
55
|
+
st = fs.lstatSync(here);
|
|
40
56
|
}
|
|
41
57
|
catch {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
out.push(part, ...q.slice(1)); // absent: this and the rest are the not-yet-existing tail
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (!st.isSymbolicLink()) {
|
|
62
|
+
out.push(part);
|
|
63
|
+
q = q.slice(1);
|
|
64
|
+
}
|
|
65
|
+
else if (depth++ > 100) {
|
|
66
|
+
out.push(part, ...q.slice(1)); // cycle: give up rather than loop
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readlinkSync(here); // a relative target is relative to the link's dir
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
out.push(part, ...q.slice(1)); // unreadable: treat as the not-yet-existing tail
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
// The target, absolute-resolved, becomes the whole remaining path to peel: the link's
|
|
79
|
+
// own directory is dropped and replaced by wherever the link points.
|
|
80
|
+
const target = path.isAbsolute(raw) ? raw : path.resolve(path.join(path.sep, ...out), raw);
|
|
81
|
+
q = [...target.split(path.sep).filter(Boolean), ...q.slice(1)];
|
|
82
|
+
out.length = 0;
|
|
47
83
|
}
|
|
48
84
|
}
|
|
85
|
+
return path.join(path.sep, ...out);
|
|
49
86
|
}
|
|
50
87
|
/** A tool's path argument as pi's tools resolve it (pi `resolveToCwd`): a leading `@` dropped,
|
|
51
88
|
* `~` expanded to the home directory, and a relative path taken from `base`. */
|
|
@@ -53,8 +53,11 @@ export function needsReviewNote(body) {
|
|
|
53
53
|
* which the plan role clears by splitting or replanning (role-catalog.ts `plan` find). False
|
|
54
54
|
* for the bugfix role's wait-for-the-next-failure note ("by bugfix: …"), which parks the
|
|
55
55
|
* entry for selfcheck to lift when the cluster fails again (selfcheck step 4) and which no
|
|
56
|
-
* role can act on until then
|
|
57
|
-
*
|
|
56
|
+
* role can act on until then, and for the director's user fallback ("by director: …", the
|
|
57
|
+
* director-request handoff's final park) — the director has already reviewed the entry and the
|
|
58
|
+
* park names what the user must decide, so it is not plan-role work either. entryHold returns
|
|
59
|
+
* "needs-review" for every such note (all of them park the entry), so the doctor's `stalled
|
|
60
|
+
* entries` row (checkStalledEntries) — the
|
|
58
61
|
* stalled-with-the-plan-role surface — classifies by this agreed subset, keeping the bugfix
|
|
59
62
|
* parks out of the plan-role blame. Reads the note's text after the NEEDS_REVIEW_PREFIX once,
|
|
60
63
|
* so the row cannot disagree on which notes the plan role clears; the eligibility mark
|
|
@@ -63,8 +66,9 @@ export function isPlanStalled(entry) {
|
|
|
63
66
|
const note = needsReviewNote(entry.body);
|
|
64
67
|
if (note === null)
|
|
65
68
|
return false;
|
|
66
|
-
// A "by bugfix:" note is a wait for evidence that selfcheck clears,
|
|
67
|
-
|
|
69
|
+
// A "by bugfix:" note is a wait for evidence that selfcheck clears, and a "by director:"
|
|
70
|
+
// note is the director's reviewed user fallback (INV-B8) — neither is plan-role work.
|
|
71
|
+
return !note.includes("by bugfix") && !note.includes("by director");
|
|
68
72
|
}
|
|
69
73
|
/** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
|
|
70
74
|
* applies). When a dropped note sat between two blank lines, the blank line after it goes too,
|
|
@@ -15,7 +15,7 @@ import { collapseWhitespace, truncate } from "../text/text.js";
|
|
|
15
15
|
import { actionableEntryRanges, groupBlockedSeries, partsLabel, } from "./backlog-index-data.js";
|
|
16
16
|
import { stripEntryStamp } from "./backlog-structure.js";
|
|
17
17
|
import { entryHoldAgainst, holdMark } from "./backlog-eligibility.js";
|
|
18
|
-
import { plannedPartKeys } from "./backlog-parts.js";
|
|
18
|
+
import { entryKey, plannedPartKeys } from "./backlog-parts.js";
|
|
19
19
|
import { plannedPlanEntries } from "./backlog.js";
|
|
20
20
|
/** Max characters an index title keeps before a trailing ellipsis, so one very long BUGS.md
|
|
21
21
|
* heading cannot dominate the block. */
|
|
@@ -110,7 +110,7 @@ export function renderBacklogIndexBlock(root) {
|
|
|
110
110
|
// plan) — BUGS.md and QUESTIONS.md sections keep every entry on its own line.
|
|
111
111
|
const rows = entries.map((e) => {
|
|
112
112
|
const hold = entryHoldAgainst({ title: e.title, body: e.body }, plannedParts);
|
|
113
|
-
const key =
|
|
113
|
+
const key = entryKey(e.title);
|
|
114
114
|
return {
|
|
115
115
|
start: e.start,
|
|
116
116
|
end: e.end,
|
|
@@ -52,27 +52,42 @@ export function fenceTracker() {
|
|
|
52
52
|
return fence !== null;
|
|
53
53
|
},
|
|
54
54
|
inside(line) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (fence === null) {
|
|
59
|
-
// CommonMark: an info string for a backtick fence cannot contain a backtick, so a
|
|
60
|
-
// line like ````md / ## Done / ````` quoted in prose is paragraph text, not a fence
|
|
61
|
-
// opener — taken as one, no later bare fence line can close it and the fence runs to
|
|
62
|
-
// EOF, swallowing the rest of the document as fenced content.
|
|
63
|
-
const info = line.replace(/^ {0,3}/, "").slice(marker.length);
|
|
64
|
-
if (marker.startsWith("`") && info.includes("`"))
|
|
65
|
-
return false;
|
|
66
|
-
fence = { char: marker.charAt(0), length: marker.length };
|
|
67
|
-
}
|
|
68
|
-
else if (marker.startsWith(fence.char) && marker.length >= fence.length && line.trim() === marker)
|
|
69
|
-
fence = null;
|
|
70
|
-
return true; // The fence line itself is fence syntax, never structure.
|
|
55
|
+
if (fence === null) {
|
|
56
|
+
fence = fenceOpener(line);
|
|
57
|
+
return fence !== null; // An opener is fence syntax, never structure.
|
|
71
58
|
}
|
|
72
|
-
|
|
59
|
+
if (closesFence(line, fence))
|
|
60
|
+
fence = null;
|
|
61
|
+
return true; // Inside a fence, every line (its closer too) is fence syntax or content.
|
|
73
62
|
},
|
|
74
63
|
};
|
|
75
64
|
}
|
|
65
|
+
/** A CommonMark fence line's leading marker run, or null: ```` ``` ```` or `~~~` (three or more)
|
|
66
|
+
* after at most three spaces of indent. */
|
|
67
|
+
function fenceMarkerRun(line) {
|
|
68
|
+
return /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1] ?? null;
|
|
69
|
+
}
|
|
70
|
+
/** The fence `line` opens, or null when it opens none — CommonMark's opening rule, shared by
|
|
71
|
+
* fenceTracker and review-verdict.ts's quoted-code stripping. An info string may follow the
|
|
72
|
+
* marker, except that a backtick fence's info string may not contain a backtick: a line like
|
|
73
|
+
* ````md / ## Done / ````` quoted in prose is paragraph text. Taken as an opener, no later bare
|
|
74
|
+
* fence line could close it and the fence would run to EOF, swallowing the rest of the document
|
|
75
|
+
* as fenced content. */
|
|
76
|
+
export function fenceOpener(line) {
|
|
77
|
+
const marker = fenceMarkerRun(line);
|
|
78
|
+
if (!marker)
|
|
79
|
+
return null;
|
|
80
|
+
const info = line.replace(/^ {0,3}/, "").slice(marker.length);
|
|
81
|
+
if (marker.startsWith("`") && info.includes("`"))
|
|
82
|
+
return null;
|
|
83
|
+
return { char: marker.charAt(0), length: marker.length };
|
|
84
|
+
}
|
|
85
|
+
/** Whether `line` closes `fence` — CommonMark's closing rule: a bare fence line (no info
|
|
86
|
+
* string) of the same character, at least as long as the opener. */
|
|
87
|
+
export function closesFence(line, fence) {
|
|
88
|
+
const marker = fenceMarkerRun(line);
|
|
89
|
+
return marker !== null && marker.startsWith(fence.char) && marker.length >= fence.length && line.trim() === marker;
|
|
90
|
+
}
|
|
76
91
|
/** The title of a markdown heading line at `prefix` level — or the empty string when the heading
|
|
77
92
|
* carries no text. The prefix's own length drives the slice (never a hard-coded `3`/`4`), so the
|
|
78
93
|
* two heading levels cannot drift. The single home of the heading-title slice, shared by
|
|
@@ -342,9 +357,20 @@ export function entryLags(md, sectionTitle, dateRe, filingRe) {
|
|
|
342
357
|
export function headingMetadata(lines, start) {
|
|
343
358
|
const line = lines[start] ?? "";
|
|
344
359
|
const meta = [line];
|
|
360
|
+
const j = collectHeadingContinuation(lines, start, meta, headingIsWrapped(line));
|
|
361
|
+
return { text: meta.join(" "), next: j };
|
|
362
|
+
}
|
|
363
|
+
/** Whether a `### ` heading line opens an unbalanced parenthetical: the only shape whose
|
|
364
|
+
* metadata runs beyond its first line. */
|
|
365
|
+
function headingIsWrapped(line) {
|
|
345
366
|
// A wrapped heading's first line opens a parenthetical its continuation will close; an
|
|
346
367
|
// unbalanced count is therefore the only shape that has metadata beyond the first line.
|
|
347
|
-
|
|
368
|
+
return (line.match(/\(/g) ?? []).length > (line.match(/\)/g) ?? []).length;
|
|
369
|
+
}
|
|
370
|
+
/** Append a wrapped `### ` heading's continuation lines to `meta`, stopping at the next entry
|
|
371
|
+
* start, the 3-line cap, or a line ending in `)`; returns the first line index not consumed. */
|
|
372
|
+
function collectHeadingContinuation(lines, start, meta, wrapped) {
|
|
373
|
+
const line = lines[start] ?? "";
|
|
348
374
|
let closed = line.endsWith(")");
|
|
349
375
|
let j = start + 1;
|
|
350
376
|
// Continuation is a heading-only concern (wrapped headings); bullets are single-line.
|
|
@@ -356,7 +382,7 @@ export function headingMetadata(lines, start) {
|
|
|
356
382
|
j++;
|
|
357
383
|
closed = next.endsWith(")");
|
|
358
384
|
}
|
|
359
|
-
return
|
|
385
|
+
return j;
|
|
360
386
|
}
|
|
361
387
|
/** A `- ` line's or `### ` heading's trailing parenthetical's inner text, nesting-aware: a
|
|
362
388
|
* backward scan from the text's closing ")" to its matching "(" returns the group's FULL inner
|
|
@@ -4,7 +4,7 @@
|
|
|
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
|
-
import { backlogTarget, findEntry,
|
|
7
|
+
import { backlogTarget, findEntry, readEntryLines, withLocatedEntry } from "./backlog-write.js";
|
|
8
8
|
import { withoutNeedsReviewNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
|
|
9
9
|
import { writeTextAtomic } from "../files/files.js";
|
|
10
10
|
import { fail } from "../cli/cli-output.js";
|
|
@@ -38,11 +38,7 @@ function liftRefusal(lines, entry) {
|
|
|
38
38
|
* title check, all decided inside the lock. */
|
|
39
39
|
export function tryReleaseEntry(root, kind, n, expectedTitle) {
|
|
40
40
|
const { fileName, sectionTitle } = backlogTarget(kind);
|
|
41
|
-
return
|
|
42
|
-
const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
|
|
43
|
-
if (located.status !== "ok")
|
|
44
|
-
return located;
|
|
45
|
-
const { file, md, entry } = located;
|
|
41
|
+
return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
|
|
46
42
|
const lines = md.split("\n");
|
|
47
43
|
const lifted = liftRefusal(lines, entry);
|
|
48
44
|
if (lifted === null)
|
|
@@ -104,11 +100,7 @@ function spliceEntryToTop(lines, sectionTitle, entry) {
|
|
|
104
100
|
* title check, all decided inside the lock. */
|
|
105
101
|
export function tryMoveEntryToTop(root, kind, n, expectedTitle) {
|
|
106
102
|
const { fileName, sectionTitle } = backlogTarget(kind);
|
|
107
|
-
return
|
|
108
|
-
const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
|
|
109
|
-
if (located.status !== "ok")
|
|
110
|
-
return located;
|
|
111
|
-
const { file, md, entry } = located;
|
|
103
|
+
return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
|
|
112
104
|
if (n === 1)
|
|
113
105
|
return { status: "already_first", title: entry.title };
|
|
114
106
|
const lines = md.split("\n");
|
|
@@ -147,7 +147,7 @@ export function readEntryLines(root, fileName, sectionTitle, n) {
|
|
|
147
147
|
* refuse as data without writing; on success returns the `ok` file path, raw markdown and entry
|
|
148
148
|
* for the caller to splice and write its own outcome. A pure lookup, decided inside the caller's
|
|
149
149
|
* lock. */
|
|
150
|
-
|
|
150
|
+
function locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle) {
|
|
151
151
|
const file = path.join(root, fileName);
|
|
152
152
|
const md = readTextIfExists(file) ?? "";
|
|
153
153
|
const entry = findEntry(md, sectionTitle, n);
|
|
@@ -157,6 +157,22 @@ export function locateDashboardEntry(root, fileName, sectionTitle, n, expectedTi
|
|
|
157
157
|
return { status: "moved", title: entry.title };
|
|
158
158
|
return { status: "ok", file, md, entry };
|
|
159
159
|
}
|
|
160
|
+
/** The shared lock-and-locate prologue of the four non-exiting dashboard writers (tryCancelEntry
|
|
161
|
+
* and tryEditEntry here; tryReleaseEntry and tryMoveEntryToTop in backlog-release.ts): take the
|
|
162
|
+
* cross-process lock, locate the `n`th entry by position and expected title, and refuse as data —
|
|
163
|
+
* returning `located`'s `gone`/`moved` verbatim when the lookup fails — without writing. On a
|
|
164
|
+
* match it calls `splice` with the located file, markdown and entry and returns whatever the
|
|
165
|
+
* splice returns, so each writer keeps its own `ok`-branch outcome; the return is the union of the
|
|
166
|
+
* two refusals and `splice`'s own return, which is exactly each writer's outcome type. The
|
|
167
|
+
* `splice` runs inside the lock, the same window the inline prologue held. */
|
|
168
|
+
export function withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, splice) {
|
|
169
|
+
return withPrimaryWorktreeLock(root, () => {
|
|
170
|
+
const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
|
|
171
|
+
if (located.status !== "ok")
|
|
172
|
+
return located;
|
|
173
|
+
return splice({ file: located.file, md: located.md, entry: located.entry });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
160
176
|
/** The section-remove entry point, the appendEntry analogue: the same cross-process lock, so a
|
|
161
177
|
* cancel and a merge's working-tree fast-forward cannot each clobber the other's
|
|
162
178
|
* read-modify-write. */
|
|
@@ -189,11 +205,7 @@ function spliceOutEntry(file, lines, entry) {
|
|
|
189
205
|
* `plans`/`bugs` strings, the same `### ` text as ActionableEntryRange.title). */
|
|
190
206
|
export function tryCancelEntry(root, kind, n, expectedTitle) {
|
|
191
207
|
const { fileName, sectionTitle } = backlogTarget(kind);
|
|
192
|
-
return
|
|
193
|
-
const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
|
|
194
|
-
if (located.status !== "ok")
|
|
195
|
-
return located;
|
|
196
|
-
const { file, md, entry } = located;
|
|
208
|
+
return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
|
|
197
209
|
spliceOutEntry(file, md.split("\n"), entry);
|
|
198
210
|
return { status: "cancelled", file: fileName, title: entry.title };
|
|
199
211
|
});
|
|
@@ -264,11 +276,7 @@ export function tryEditEntry(root, kind, n, expectedTitle, rawTitle, rawBody) {
|
|
|
264
276
|
const { fileName, sectionTitle } = backlogTarget(kind);
|
|
265
277
|
const title = stripEntryStamp(collapseWhitespace(rawTitle));
|
|
266
278
|
const body = rawBody.replace(/\r\n?/g, "\n").trim();
|
|
267
|
-
return
|
|
268
|
-
const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
|
|
269
|
-
if (located.status !== "ok")
|
|
270
|
-
return located;
|
|
271
|
-
const { file, md, entry } = located;
|
|
279
|
+
return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
|
|
272
280
|
const wrote = spliceInEditedEntry(file, md.split("\n"), entry, title, body);
|
|
273
281
|
return { status: wrote ? "edited" : "unchanged", file: fileName, title };
|
|
274
282
|
});
|