tumwater 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
/** Backlog eligibility: which PLANS.md / BUGS.md entries a work loop may take now (plans,
|
|
2
3
|
* parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused …**,
|
|
3
|
-
* **Needs review …** or **
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **Needs review …**, **Needs replan …** or **Waiting for the next failure …** note, or when
|
|
5
|
+
* its heading's trailing parenthetical
|
|
6
|
+
* (or a parenthetical left open by a wrapped heading) names a prerequisite (`requires parts
|
|
7
|
+
* 1/5–3/5 landed`) that is itself still listed under `## Planned`. The parsers are pure over
|
|
8
|
+
* markdown text; `eligibleEntries` is the stat-backed reader for a caller that wants the list.
|
|
9
|
+
* The backlog index marks every held entry so a loop
|
|
7
10
|
* skips it without re-deriving the rule. */
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { statOrNull } from "../files/files.js";
|
|
13
|
+
import { allLiveClaims } from "../loop/loop-state.js";
|
|
8
14
|
import { collapseWhitespace } from "../text/text.js";
|
|
9
|
-
import { NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX } from "../roles/role-guidance.js";
|
|
15
|
+
import { NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX, WAITING_FOR_FAILURE_PREFIX } from "../roles/role-guidance.js";
|
|
10
16
|
import { baseRoleOf } from "../roles/loop-ids.js";
|
|
11
|
-
import {
|
|
17
|
+
import { roleLabel } from "../roles/role-labels.js";
|
|
18
|
+
import { openParenthetical, trailingParenthetical } from "./backlog-md.js";
|
|
12
19
|
import { actionableEntryRangesFor, stripEntryStamp } from "./backlog-structure.js";
|
|
13
|
-
import { openBugEntries, plannedPlanEntries } from "./backlog.js";
|
|
20
|
+
import { finishedFiles, openBugEntries, plannedPlanEntries } from "./backlog.js";
|
|
14
21
|
/** The `**Refused …` note prefix a refusing tick writes. Every hold note — this one and the
|
|
15
22
|
* imported Needs-review and Needs-replan prefixes — is matched as a line prefix (hasNoteLine)
|
|
16
23
|
* so a mention in prose never holds an entry. */
|
|
@@ -21,6 +28,47 @@ const REFUSED_PREFIX = "**Refused ";
|
|
|
21
28
|
function hasNoteLine(body, prefix) {
|
|
22
29
|
return body.split("\n").some((line) => line.trimStart().startsWith(prefix));
|
|
23
30
|
}
|
|
31
|
+
/** The text of `body`'s note for `prefix`: the first line (ignoring leading indentation) that
|
|
32
|
+
* begins with it, trimmed, with the leading `**` and the first closing `**` removed. Null when
|
|
33
|
+
* no line matches — the same line-prefix rule entryHold uses. */
|
|
34
|
+
function noteText(body, prefix) {
|
|
35
|
+
const line = body.split("\n").map((l) => l.trim()).find((l) => l.startsWith(prefix));
|
|
36
|
+
return line === undefined ? null : line.replace(/^\*\*/, "").replace("**", "");
|
|
37
|
+
}
|
|
38
|
+
/** The text of `body`'s Refused note, or null when it has none (entryHold's "refused" hold). */
|
|
39
|
+
export function refusalNote(body) {
|
|
40
|
+
return noteText(body, REFUSED_PREFIX);
|
|
41
|
+
}
|
|
42
|
+
/** The text of `body`'s Waiting-for-the-next-failure note, or null when it has none. */
|
|
43
|
+
export function waitingNote(body) {
|
|
44
|
+
return noteText(body, WAITING_FOR_FAILURE_PREFIX);
|
|
45
|
+
}
|
|
46
|
+
/** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
|
|
47
|
+
* applies). When a dropped note sat between two blank lines, the blank line after it goes too,
|
|
48
|
+
* so releasing leaves one blank line where the note was. Every other line is kept. */
|
|
49
|
+
function withoutNoteLines(lines, prefix) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (!line.trimStart().startsWith(prefix)) {
|
|
54
|
+
out.push(line);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if ((lines[i - 1] ?? "x").trim() === "" && (lines[i + 1] ?? "x").trim() === "")
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** `lines` without any Refused note line. Every other line, Needs-review, needs-replan and
|
|
63
|
+
* waiting notes included, is kept, so refusal lifting never lifts another hold. */
|
|
64
|
+
export function withoutRefusalNotes(lines) {
|
|
65
|
+
return withoutNoteLines(lines, REFUSED_PREFIX);
|
|
66
|
+
}
|
|
67
|
+
/** `lines` without any Waiting-for-the-next-failure note line, collapsing blank lines as
|
|
68
|
+
* withoutRefusalNotes does. */
|
|
69
|
+
export function withoutWaitingNotes(lines) {
|
|
70
|
+
return withoutNoteLines(lines, WAITING_FOR_FAILURE_PREFIX);
|
|
71
|
+
}
|
|
24
72
|
/** One prerequisite ref, resolved against the entry's own series when it names none. */
|
|
25
73
|
function parseRef(chunk, ownSeries) {
|
|
26
74
|
const text = chunk.trim().replace(/^parts?\s+/i, "");
|
|
@@ -69,6 +117,53 @@ export function seriesPart(title) {
|
|
|
69
117
|
function partKey(series, part) {
|
|
70
118
|
return `${series.toLowerCase()}\u0000${part}`;
|
|
71
119
|
}
|
|
120
|
+
/** The Done entries of the parts `title` requires, in the order its requires clause names them:
|
|
121
|
+
* for each requiredParts ref, the first `## Done` entry whose seriesPart has the same partKey,
|
|
122
|
+
* searching PLANS.md's live section and then PLANS-DONE.md (plans/backlog-archive.md), each
|
|
123
|
+
* carrying the file it was found in. Done is newest-first, so the first match is the most recent
|
|
124
|
+
* merge. A ref with no Done match (still planned, or renamed) adds nothing, so a title with no
|
|
125
|
+
* requires clause, or missing files, gives []. Lets the assignment prompt point at the exact
|
|
126
|
+
* range to read instead of leaving the implementer to search a history that runs to thousands of
|
|
127
|
+
* lines. */
|
|
128
|
+
export function mergedPrerequisiteRanges(root, title) {
|
|
129
|
+
const refs = requiredParts(title);
|
|
130
|
+
if (refs.length === 0)
|
|
131
|
+
return [];
|
|
132
|
+
const done = finishedFiles("PLANS.md").flatMap((file) => actionableEntryRangesFor(root, file, "Done").map((e) => ({ ...e, file })));
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const ref of refs) {
|
|
135
|
+
const want = partKey(ref.series, ref.part);
|
|
136
|
+
const hit = done.find((e) => {
|
|
137
|
+
const part = seriesPart(e.title);
|
|
138
|
+
return part !== null && partKey(part.series, part.part) === want;
|
|
139
|
+
});
|
|
140
|
+
if (hit !== undefined) {
|
|
141
|
+
out.push({ key: entryKey(hit.title), title: hit.title, start: hit.start, end: hit.end, file: hit.file });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** The still-planned parts that wait on the planned entry titled `title`, with their line ranges,
|
|
147
|
+
* in file order: plansWaitingOn's indexes (transitive, so a part waiting through a chain is
|
|
148
|
+
* listed) paired by index with the Planned ranges, as eligibleEntries pairs them. [] when no
|
|
149
|
+
* planned entry has that title, the entry has no series part, or PLANS.md is missing. Lets the
|
|
150
|
+
* assignment prompt name the work a series part must leave to the parts after it. */
|
|
151
|
+
export function waitingPartRanges(root, title) {
|
|
152
|
+
const planned = plannedPlanEntries(root);
|
|
153
|
+
const ranges = actionableEntryRangesFor(root, "PLANS.md", "Planned");
|
|
154
|
+
const want = entryKey(title);
|
|
155
|
+
const index = planned.findIndex((e) => entryKey(e.title) === want);
|
|
156
|
+
if (index < 0 || seriesPart(planned[index].title) === null)
|
|
157
|
+
return [];
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const i of plansWaitingOn(planned, index)) {
|
|
160
|
+
const range = ranges[i];
|
|
161
|
+
if (range === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
out.push({ key: entryKey(planned[i].title), title: planned[i].title, start: range.start, end: range.end });
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
72
167
|
/** The prerequisite-set identities of the still-planned entries — seriesPart(title) of each,
|
|
73
168
|
* keyed by partKey — built once for a batch and reused across every entry judged against it, so
|
|
74
169
|
* judging n entries is n seriesPart parses rather than n² (entryHold rebuilt the set per entry).
|
|
@@ -83,29 +178,167 @@ export function plannedPartKeys(planned) {
|
|
|
83
178
|
}
|
|
84
179
|
return keys;
|
|
85
180
|
}
|
|
86
|
-
/** The
|
|
181
|
+
/** The doctor's count of a refusal's stranded dependents: the ascending indexes into `planned` of
|
|
182
|
+
* the entries that require `planned[index]`'s own part, directly or through a chain of parts.
|
|
183
|
+
* Matching is by partKey, as entryHoldAgainst matches. [] when that entry has no series part. A
|
|
184
|
+
* dependency cycle ends the walk, and `index` itself is never listed. */
|
|
185
|
+
export function plansWaitingOn(planned, index) {
|
|
186
|
+
const root = planned[index];
|
|
187
|
+
const own = root === undefined ? null : seriesPart(root.title);
|
|
188
|
+
if (own === null)
|
|
189
|
+
return [];
|
|
190
|
+
const dependents = new Map();
|
|
191
|
+
planned.forEach((entry, i) => {
|
|
192
|
+
if (seriesPart(entry.title) === null)
|
|
193
|
+
return;
|
|
194
|
+
for (const ref of requiredParts(entry.title)) {
|
|
195
|
+
const key = partKey(ref.series, ref.part);
|
|
196
|
+
dependents.set(key, [...(dependents.get(key) ?? []), i]);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
const seen = new Set([index]);
|
|
200
|
+
const queue = [partKey(own.series, own.part)];
|
|
201
|
+
for (let key = queue.shift(); key !== undefined; key = queue.shift()) {
|
|
202
|
+
for (const i of dependents.get(key) ?? []) {
|
|
203
|
+
if (seen.has(i))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(i);
|
|
206
|
+
const part = seriesPart(planned[i].title);
|
|
207
|
+
queue.push(partKey(part.series, part.part));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
seen.delete(index);
|
|
211
|
+
return [...seen].sort((a, b) => a - b);
|
|
212
|
+
}
|
|
213
|
+
/** The prerequisite parts a plan heading's trailing parenthetical (or, when the heading wraps
|
|
214
|
+
* and the parenthetical stays open past the line, its open parenthetical) names through the clause
|
|
87
215
|
* `requires <ref>((, | and )<ref>)* landed`, with each range (e.g. `parts 1/5–3/5`) expanded.
|
|
88
216
|
* Returns [] when the heading has no such clause or the clause does not parse — an agent judges
|
|
89
217
|
* an unparseable clause, exactly as it does today. Only the heading is read, never the body. */
|
|
90
218
|
export function requiredParts(title) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
return [];
|
|
94
|
-
const m = /requires\s+(.+?)\s+landed\b/i.exec(meta);
|
|
95
|
-
if (m === null)
|
|
219
|
+
const clause = requiresClause(title);
|
|
220
|
+
if (clause === null)
|
|
96
221
|
return [];
|
|
97
222
|
const ownSeries = seriesPart(title)?.series ?? null;
|
|
98
223
|
const refs = [];
|
|
99
|
-
for (const chunk of
|
|
224
|
+
for (const chunk of clause.split(/\s*,\s*|\s+and\s+/i))
|
|
100
225
|
refs.push(...parseRef(chunk, ownSeries));
|
|
101
226
|
return refs;
|
|
102
227
|
}
|
|
228
|
+
/** The ref text a heading's `requires <refs> landed` clause captures — read from the trailing
|
|
229
|
+
* parenthetical, or the open one of a wrapped heading — or null when the heading has no such
|
|
230
|
+
* clause. requiredParts parses it; planDependencyProblems uses it to spot a clause that parses to
|
|
231
|
+
* nothing. */
|
|
232
|
+
function requiresClause(title) {
|
|
233
|
+
let meta = trailingParenthetical(title);
|
|
234
|
+
if (meta === "")
|
|
235
|
+
meta = openParenthetical(title);
|
|
236
|
+
if (meta === "")
|
|
237
|
+
return null;
|
|
238
|
+
const m = /requires\s+(.+?)\s+landed\b/i.exec(meta); // vocabulary-ok: parses the requires … landed heading syntax
|
|
239
|
+
return m === null ? null : m[1];
|
|
240
|
+
}
|
|
241
|
+
/** A dependency verb followed by a series part ref in a heading's parenthetical — the wording the
|
|
242
|
+
* claim allocator ignores (BUGS.md 2026-10-14: a part headed "blocked by part 1/2 above" was handed
|
|
243
|
+
* out six times while part 1/2 was in flight). planDependencyProblems reports each such ref. */
|
|
244
|
+
const MISWORDED_DEPENDENCY_RE = /\b(?:blocked by|blocked on|after|follows|depends on|waits on|waits for|once)\s+(?:the\s+)?(parts?\s+[0-9]+[a-z]?\/[0-9]+)/gi;
|
|
245
|
+
/** The mistakes in the planned entries' `requires … landed` clauses that the claim allocator
|
|
246
|
+
* cannot see: `dangling` — a ref naming a part no planned or done heading carries (its hold is
|
|
247
|
+
* void), `unparsed` — titles whose clause yields no refs, and `cycle` — the first dependency cycle
|
|
248
|
+
* among planned `Series, part i/n:` entries as `Series i/n` names in walk order, first name
|
|
249
|
+
* repeated last (a self-reference is a one-hop cycle), or null — and `misworded`: a series part
|
|
250
|
+
* whose heading parenthetical names a still-planned part through a dependency verb ("blocked by
|
|
251
|
+
* part 1/2") with no requires clause holding that part, so the allocator hands it out now.
|
|
252
|
+
* Refused and needs-review entries count like the rest, since their clauses still decide when
|
|
253
|
+
* they unblock. */
|
|
254
|
+
export function planDependencyProblems(planned, doneTitles) {
|
|
255
|
+
const known = plannedPartKeys(planned);
|
|
256
|
+
for (const t of doneTitles) {
|
|
257
|
+
const part = seriesPart(t);
|
|
258
|
+
if (part !== null)
|
|
259
|
+
known.add(partKey(part.series, part.part));
|
|
260
|
+
}
|
|
261
|
+
const dangling = [];
|
|
262
|
+
const unparsed = [];
|
|
263
|
+
const misworded = [];
|
|
264
|
+
const plannedKeys = plannedPartKeys(planned);
|
|
265
|
+
const nodes = new Map();
|
|
266
|
+
for (const entry of planned) {
|
|
267
|
+
const refs = requiredParts(entry.title);
|
|
268
|
+
if (refs.length === 0 && requiresClause(entry.title) !== null)
|
|
269
|
+
unparsed.push(entry.title);
|
|
270
|
+
for (const ref of refs) {
|
|
271
|
+
if (!known.has(partKey(ref.series, ref.part)))
|
|
272
|
+
dangling.push({ title: entry.title, ref: `${ref.series} ${ref.part}/${ref.of}` });
|
|
273
|
+
}
|
|
274
|
+
const own = seriesPart(entry.title);
|
|
275
|
+
if (own === null)
|
|
276
|
+
continue;
|
|
277
|
+
let meta = trailingParenthetical(entry.title);
|
|
278
|
+
if (meta === "")
|
|
279
|
+
meta = openParenthetical(entry.title);
|
|
280
|
+
const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
|
|
281
|
+
for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
|
|
282
|
+
for (const ref of parseRef(m[1], own.series)) {
|
|
283
|
+
const key = partKey(ref.series, ref.part);
|
|
284
|
+
if (!plannedKeys.has(key) || held.has(key))
|
|
285
|
+
continue;
|
|
286
|
+
const name = `${ref.series} ${ref.part}/${ref.of}`;
|
|
287
|
+
if (!misworded.some((w) => w.title === entry.title && w.ref === name))
|
|
288
|
+
misworded.push({ title: entry.title, ref: name });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
nodes.set(partKey(own.series, own.part), {
|
|
292
|
+
name: `${own.series} ${own.part}/${own.of}`,
|
|
293
|
+
deps: refs.map((ref) => partKey(ref.series, ref.part)),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const state = new Map();
|
|
297
|
+
const stack = [];
|
|
298
|
+
const visit = (key) => {
|
|
299
|
+
state.set(key, "open");
|
|
300
|
+
stack.push(key);
|
|
301
|
+
for (const dep of nodes.get(key).deps) {
|
|
302
|
+
if (!nodes.has(dep))
|
|
303
|
+
continue;
|
|
304
|
+
const seen = state.get(dep);
|
|
305
|
+
if (seen === "open") {
|
|
306
|
+
return [...stack.slice(stack.indexOf(dep)), dep].map((k) => nodes.get(k).name);
|
|
307
|
+
}
|
|
308
|
+
if (seen === undefined) {
|
|
309
|
+
const found = visit(dep);
|
|
310
|
+
if (found !== null)
|
|
311
|
+
return found;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
stack.pop();
|
|
315
|
+
state.set(key, "done");
|
|
316
|
+
return null;
|
|
317
|
+
};
|
|
318
|
+
let cycle = null;
|
|
319
|
+
for (const key of nodes.keys()) {
|
|
320
|
+
if (state.has(key))
|
|
321
|
+
continue;
|
|
322
|
+
cycle = visit(key);
|
|
323
|
+
if (cycle !== null)
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
return { cycle, dangling, unparsed, misworded };
|
|
327
|
+
}
|
|
328
|
+
/** The prerequisite refs of `title`'s `requires … landed` clause when none of them is still in
|
|
329
|
+
* `plannedParts` (built by plannedPartKeys), else []. A heading with no parseable clause is never
|
|
330
|
+
* reported, so only a refusal whose named prerequisites have all left `## Planned` reads as
|
|
331
|
+
* possibly stale. */
|
|
332
|
+
export function prerequisitesMerged(title, plannedParts) {
|
|
333
|
+
const refs = requiredParts(title);
|
|
334
|
+
return refs.some((ref) => plannedParts.has(partKey(ref.series, ref.part))) ? [] : refs;
|
|
335
|
+
}
|
|
103
336
|
/** Why `entry` is held against the still-`planned` entries, or null when it may be taken: a
|
|
104
337
|
* `**Refused …` line (refused), a Needs-review line (needs-review), a Needs-replan line
|
|
105
338
|
* (needs-replan — the plan loop owns it), or a prerequisite `(series, part)` still among
|
|
106
339
|
* `planned` ({ blockedBy }). Series compare case-insensitively; part tokens compare verbatim.
|
|
107
|
-
* Bodies mentioning "requires" are never consulted — only the heading's trailing
|
|
108
|
-
* parenthetical. */
|
|
340
|
+
* Bodies mentioning "requires" are never consulted — only the heading's trailing or
|
|
341
|
+
* still-open parenthetical. */
|
|
109
342
|
export function entryHold(entry, planned) {
|
|
110
343
|
return entryHoldAgainst(entry, plannedPartKeys(planned));
|
|
111
344
|
}
|
|
@@ -119,6 +352,8 @@ export function entryHoldAgainst(entry, plannedParts) {
|
|
|
119
352
|
return "needs-review";
|
|
120
353
|
if (hasNoteLine(entry.body, NEEDS_REPLAN_PREFIX))
|
|
121
354
|
return "needs-replan";
|
|
355
|
+
if (hasNoteLine(entry.body, WAITING_FOR_FAILURE_PREFIX))
|
|
356
|
+
return "waiting-for-failure";
|
|
122
357
|
const refs = requiredParts(entry.title);
|
|
123
358
|
if (refs.length === 0)
|
|
124
359
|
return null;
|
|
@@ -135,34 +370,110 @@ export function holdMark(hold) {
|
|
|
135
370
|
if (hold === null)
|
|
136
371
|
return "";
|
|
137
372
|
if (hold === "refused")
|
|
138
|
-
return " [refused]";
|
|
373
|
+
return " [blocked: refused]";
|
|
139
374
|
if (hold === "needs-review")
|
|
140
|
-
return " [needs review]";
|
|
375
|
+
return " [blocked: needs review]";
|
|
141
376
|
if (hold === "needs-replan")
|
|
142
|
-
return " [needs replan]";
|
|
377
|
+
return " [blocked: needs replan]";
|
|
378
|
+
if (hold === "waiting-for-failure")
|
|
379
|
+
return " [blocked: waiting for the next failure]";
|
|
143
380
|
return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
|
|
144
381
|
}
|
|
145
|
-
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
|
|
382
|
+
/** `/api/status` polls the holds on the 1-second dashboard poll and the TUI recomputes them each
|
|
383
|
+
* frame while PLANS.md/BUGS.md change only when a loop merges an edit. The file reads beneath are
|
|
384
|
+
* already stat-cached (plannedPlanEntries/openBugEntries), but the per-entry eligibility walk
|
|
385
|
+
* (entryHoldAgainst's body scan + heading clause) is pure CPU re-derived on every call, growing
|
|
386
|
+
* with the append-only backlog. Cache the derived holds keyed on both files' stat identities — the
|
|
387
|
+
* same dev/ino/mtimeMs/size freshness check stat-cache.ts applies — so an unchanged backlog costs
|
|
388
|
+
* two stat syscalls per poll instead of the walk. Bounded (like sectionCache) so many short-lived
|
|
389
|
+
* roots in tests cannot grow it unbounded. */
|
|
390
|
+
const HOLDS_CACHE_MAX = 64;
|
|
391
|
+
const holdsCache = new Map();
|
|
392
|
+
function holdsStatKey(root) {
|
|
393
|
+
const statId = (file) => {
|
|
394
|
+
const st = statOrNull(file);
|
|
395
|
+
return st === null ? "-" : `${st.dev}:${st.ino}:${st.mtimeMs}:${st.size}`;
|
|
396
|
+
};
|
|
397
|
+
return `${statId(path.join(root, "PLANS.md"))}\u0000${statId(path.join(root, "BUGS.md"))}`;
|
|
398
|
+
}
|
|
399
|
+
const cloneHold = (hold) => hold !== null && typeof hold === "object" ? { blockedBy: [...hold.blockedBy] } : hold;
|
|
400
|
+
/** A copy of the holds so each caller owns its data, matching stat-cache's contract (mutating
|
|
401
|
+
* one result must not poison later polls). */
|
|
402
|
+
function cloneHolds(holds) {
|
|
403
|
+
return { plans: holds.plans.map(cloneHold), bugs: holds.bugs.map(cloneHold) };
|
|
404
|
+
}
|
|
405
|
+
/** The two reader calls computeBacklogHolds derives from, grouped in one object — the one
|
|
406
|
+
* deliberate mock seam in this module. Both readers are themselves stat-cached, so a repeat
|
|
407
|
+
* poll's only cost is this per-entry walk; exposing them on an object lets a test count whether
|
|
408
|
+
* a poll re-runs the walk at all, which is the holds cache's observable win (a hit serves the
|
|
409
|
+
* cached derives without calling these). */
|
|
410
|
+
export const holdsBacklogReaders = {
|
|
411
|
+
plannedPlanEntries,
|
|
412
|
+
openBugEntries,
|
|
413
|
+
};
|
|
414
|
+
/** The uncached derivation, shared by backlogHolds' miss path. */
|
|
415
|
+
function computeBacklogHolds(root) {
|
|
416
|
+
const planned = holdsBacklogReaders.plannedPlanEntries(root);
|
|
151
417
|
const parts = plannedPartKeys(planned);
|
|
152
418
|
return {
|
|
153
419
|
plans: planned.map((entry) => entryHoldAgainst(entry, parts)),
|
|
154
|
-
bugs: openBugEntries(root).map((entry) => entryHoldAgainst(entry, parts)),
|
|
420
|
+
bugs: holdsBacklogReaders.openBugEntries(root).map((entry) => entryHoldAgainst(entry, parts)),
|
|
155
421
|
};
|
|
156
422
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
423
|
+
export function backlogHolds(root) {
|
|
424
|
+
const key = holdsStatKey(root);
|
|
425
|
+
const hit = holdsCache.get(key);
|
|
426
|
+
if (hit !== undefined)
|
|
427
|
+
return cloneHolds(hit);
|
|
428
|
+
const value = computeBacklogHolds(root);
|
|
429
|
+
if (holdsCache.size >= HOLDS_CACHE_MAX)
|
|
430
|
+
holdsCache.clear();
|
|
431
|
+
holdsCache.set(key, value);
|
|
432
|
+
return cloneHolds(value);
|
|
433
|
+
}
|
|
434
|
+
/** The mark for each planned plan and open bug, two arrays parallel to the entries the
|
|
435
|
+
* observer surfaces list (backlogPayload's `plans`/`bugs`, the TUI's list and entry modes):
|
|
436
|
+
* the eligibility hold (holdMark) then the working-on-it mark (inProgressMark), the order
|
|
437
|
+
* `tumwater backlog` prints. A surface derives its marks from the one hold decision and the
|
|
438
|
+
* one claim lookup rather than re-walking the entries. An eligible entry nobody is working on
|
|
439
|
+
* renders as the empty string, so a client can append the mark without a separate check.
|
|
440
|
+
* Questions carry no holds. backlogHolds alone still drives the allocator. */
|
|
163
441
|
export function backlogMarks(root) {
|
|
164
442
|
const holds = backlogHolds(root);
|
|
165
|
-
|
|
443
|
+
const working = backlogInProgress(root);
|
|
444
|
+
const mark = (hold, runner) => holdMark(hold) + inProgressMark(runner ?? null);
|
|
445
|
+
return {
|
|
446
|
+
plans: holds.plans.map((h, i) => mark(h, working.plans[i])),
|
|
447
|
+
bugs: holds.bugs.map((h, i) => mark(h, working.bugs[i])),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/** The verbatim titles of the planned plans, then the open bugs, whose hold is `"refused"`, in
|
|
451
|
+
* file order: the entries that wait on an operator decision (the `refused` team alert's
|
|
452
|
+
* input). Pairs backlogHolds' arrays with the entries the same readers return, like
|
|
453
|
+
* backlogInProgress. A missing file yields `[]`. */
|
|
454
|
+
export function refusedEntryTitles(root) {
|
|
455
|
+
const holds = backlogHolds(root);
|
|
456
|
+
const titlesOf = (entries, held) => entries.filter((_, i) => held[i] === "refused").map((e) => e.title);
|
|
457
|
+
return [
|
|
458
|
+
...titlesOf(plannedPlanEntries(root), holds.plans),
|
|
459
|
+
...titlesOf(openBugEntries(root), holds.bugs),
|
|
460
|
+
];
|
|
461
|
+
}
|
|
462
|
+
/** The runner id holding a live claim on each planned plan and open bug, two arrays parallel
|
|
463
|
+
* to backlogHolds' (same readers, same order); `null` when no role is working on the entry.
|
|
464
|
+
* Keyed by entryKey alone, exactly as the backlog index's claim lookup is. */
|
|
465
|
+
export function backlogInProgress(root) {
|
|
466
|
+
const claims = allLiveClaims(root);
|
|
467
|
+
const runnerOf = (entry) => claims.get(entryKey(entry.title)) ?? null;
|
|
468
|
+
return {
|
|
469
|
+
plans: plannedPlanEntries(root).map(runnerOf),
|
|
470
|
+
bugs: openBugEntries(root).map(runnerOf),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/** The heading mark for an entry a role is working on: empty when nobody is, else
|
|
474
|
+
* ` [working on it: feature #2]` (the runner id through roleLabel). */
|
|
475
|
+
export function inProgressMark(runner) {
|
|
476
|
+
return runner === null ? "" : ` [working on it: ${roleLabel(runner)}]`;
|
|
166
477
|
}
|
|
167
478
|
/** The entries a `role` loop may take now, in file order: PLANS.md's `## Planned` entries for
|
|
168
479
|
* feature, BUGS.md's `## Open` entries for bugfix, minus every entry entryHold holds, each with
|
|
@@ -178,11 +489,24 @@ export function eligibleEntries(root, role) {
|
|
|
178
489
|
const ranges = actionableEntryRangesFor(root, file, section);
|
|
179
490
|
const planned = plannedPlanEntries(root);
|
|
180
491
|
const parts = plannedPartKeys(planned);
|
|
492
|
+
// The cross-role in-flight hold (BUGS.md 2026-10-09): an entry another role's base has
|
|
493
|
+
// claimed is not eligible here. Same-base claims are excluded from the hold — the scheduling
|
|
494
|
+
// pass's claim groups and tick-stage's staged claims already hold those — so the queried
|
|
495
|
+
// role's own claim (and a sibling instance's) stays eligible and its release rules are
|
|
496
|
+
// unchanged; only a plan another base currently owns is taken off this base's hands.
|
|
497
|
+
const ownBase = baseRoleOf(role);
|
|
498
|
+
const claimedElsewhere = new Set();
|
|
499
|
+
for (const [key, claimer] of allLiveClaims(root)) {
|
|
500
|
+
if (baseRoleOf(claimer) !== ownBase)
|
|
501
|
+
claimedElsewhere.add(key);
|
|
502
|
+
}
|
|
181
503
|
const out = [];
|
|
182
504
|
for (let i = 0; i < entries.length && i < ranges.length; i++) {
|
|
183
505
|
const entry = entries[i];
|
|
184
506
|
if (entryHoldAgainst(entry, parts) !== null)
|
|
185
507
|
continue;
|
|
508
|
+
if (claimedElsewhere.has(entryKey(entry.title)))
|
|
509
|
+
continue;
|
|
186
510
|
const range = ranges[i];
|
|
187
511
|
out.push({
|
|
188
512
|
key: entryKey(entry.title),
|
|
@@ -2,17 +2,36 @@
|
|
|
2
2
|
* QUESTIONS.md text, with no filesystem access — every function here takes the markdown text as
|
|
3
3
|
* an argument. Split from src/backlog/backlog.ts, which keeps the stat-cached file readers
|
|
4
4
|
* (sectionEntries, the root-based plannedPlans/openBugs/openQuestions family, and the
|
|
5
|
-
* Done/Fixed date scan): consumers who only parse markdown (question-
|
|
5
|
+
* Done/Fixed date scan): consumers who only parse markdown (question-answer.ts's QUESTIONS.md
|
|
6
6
|
* walks, backlog-write.ts's section appends, backlog-structure.ts's stranding checks)
|
|
7
7
|
* import from here without reaching the file/cache layer, and a caller who parses a string never
|
|
8
8
|
* pays for a stat. Both layers share one fenceTracker state machine, so independent readers can
|
|
9
9
|
* never disagree about what is body content. */
|
|
10
|
+
/** Where each live backlog file's finished entries go (plans/backlog-archive.md): PLANS.md's
|
|
11
|
+
* `## Done` empties into PLANS-DONE.md and BUGS.md's `## Fixed` into BUGS-FIXED.md, each under
|
|
12
|
+
* the same `## ` heading, newest first, so one section parser reads both files. The live file
|
|
13
|
+
* keeps the heading, empty: roles paste a finished entry there, and tumwater moves it into the
|
|
14
|
+
* archive (src/backlog/backlog-archive.ts). */
|
|
15
|
+
export const FINISHED_ARCHIVES = [
|
|
16
|
+
{ file: "PLANS.md", section: "Done", archive: "PLANS-DONE.md" },
|
|
17
|
+
{ file: "BUGS.md", section: "Fixed", archive: "BUGS-FIXED.md" },
|
|
18
|
+
];
|
|
19
|
+
/** The archive row for a live backlog file. */
|
|
20
|
+
export function finishedArchiveOf(file) {
|
|
21
|
+
return file === "PLANS.md" ? FINISHED_ARCHIVES[0] : FINISHED_ARCHIVES[1];
|
|
22
|
+
}
|
|
10
23
|
/** The repo-root backlog markdown files shared by every layer that knows them by name: the
|
|
11
24
|
* tracked files loops edit and readers parse by `## ` section — read by backlog-structure.ts's
|
|
12
|
-
* structural checks,
|
|
25
|
+
* structural checks, merge/backlog-conflicts.ts's mechanical insert-conflict resolver, and
|
|
13
26
|
* doctor-backlog.ts's duplicate-heading check. One definition, so the three cannot disagree
|
|
14
|
-
* about which files count.
|
|
15
|
-
|
|
27
|
+
* about which files count. The archive files count too: merges insert at their top, and their
|
|
28
|
+
* `## ` heading is as load-bearing as the live files'. */
|
|
29
|
+
export const BACKLOG_FILES = new Set([
|
|
30
|
+
"PLANS.md",
|
|
31
|
+
"BUGS.md",
|
|
32
|
+
"QUESTIONS.md",
|
|
33
|
+
...FINISHED_ARCHIVES.map((a) => a.archive),
|
|
34
|
+
]);
|
|
16
35
|
/** A per-line CommonMark fenced-code state machine, shared by every line-level parser of
|
|
17
36
|
* backlog markdown. `inside(line)` feeds one line and returns whether it is fence syntax or
|
|
18
37
|
* fenced content — never markdown structure: a fence opens at a ```` ``` ````/`~~~` line (an
|
|
@@ -67,7 +86,7 @@ export function headingTitle(line, prefix) {
|
|
|
67
86
|
* code block. The single home of the fence-aware heading guard (`!fenced.inside(line) &&
|
|
68
87
|
* line.startsWith(prefix)` plus headingTitle) that every line-level reader of the backlog
|
|
69
88
|
* docs repeats: sectionLines' section boundaries and parseEntryDetails' entry boundaries here,
|
|
70
|
-
* and question-
|
|
89
|
+
* and question-answer.ts's walks of QUESTIONS.md (the Open/Answered scan, the `### ` block
|
|
71
90
|
* split, and the Answered section's start); that file's two pure next-`## ` boundary walks
|
|
72
91
|
* go through nextSectionHeading below instead. Readers that walk
|
|
73
92
|
* whole documents collecting heading lines go through fenceAwareHeadingLines instead; callers
|
|
@@ -100,7 +119,7 @@ export function sectionLines(md, sectionTitle) {
|
|
|
100
119
|
/** The index of the next `## ` section heading at or after `from`, fence-aware through the
|
|
101
120
|
* caller's `fenced` tracker — or `lines.length` when none follows. The section-end boundary
|
|
102
121
|
* rule as an index, for readers that must cut or splice at the boundary rather than collect
|
|
103
|
-
* its content (question-
|
|
122
|
+
* its content (question-answer.ts cuts the Open section at its end and inserts a moved
|
|
104
123
|
* block before Answered's next `## `; backlog-write.ts cuts the section a filed entry is
|
|
105
124
|
* appended to). Shares fencedHeadingTitle's guard with sectionLines, so
|
|
106
125
|
* a boundary found here is the same boundary sectionLines would stop at. */
|
|
@@ -148,6 +167,29 @@ export function fenceAwareHeadingLines(md, prefix) {
|
|
|
148
167
|
* non-heading placeholders like `_None yet._` and any prose before the first heading, keeps
|
|
149
168
|
* interior blank lines within a body while trimming leading/trailing ones, and ends an open
|
|
150
169
|
* entry's body at EOF as well as at the next heading. */
|
|
170
|
+
/** The `Validation gap` tag of one already-parsed entry body — the first line outside a fence
|
|
171
|
+
* matching `` **Validation gap:** <tag> `` (an optional `- ` bullet before the marker is
|
|
172
|
+
* allowed), the tag lowercased, or `null` when no such line exists. Fence-aware because a
|
|
173
|
+
* `## Fixed` body may quote the bug template (the case BUGS.md's "a fix narrative can land on
|
|
174
|
+
* main without its fix" entry records): a quoted `**Validation gap:**` line inside a fenced
|
|
175
|
+
* block is an example, not this entry's own trace, and must not count. The consumer that owns
|
|
176
|
+
* the closed tag vocabulary (the bugfix role guidance's VALIDATION_GAP_TAGS) validates the
|
|
177
|
+
* returned tag; this reader only records that a trace line exists and what it named. Walked
|
|
178
|
+
* line by line through the module's fenceTracker so this reader can never disagree with
|
|
179
|
+
* sectionLines/parseEntryDetails about what is fenced content. Returns the FIRST such line:
|
|
180
|
+
* a body with two validation-gap lines records the first (the tally counts an entry once). */
|
|
181
|
+
export function validationGapTag(body) {
|
|
182
|
+
const fenced = fenceTracker();
|
|
183
|
+
const re = /^\s*(?:[-*]\s+)?\*\*Validation gap:\*\*\s*([A-Za-z][A-Za-z-]*)/;
|
|
184
|
+
for (const line of body.split("\n")) {
|
|
185
|
+
if (fenced.inside(line))
|
|
186
|
+
continue;
|
|
187
|
+
const m = re.exec(line);
|
|
188
|
+
if (m)
|
|
189
|
+
return m[1].toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
151
193
|
export function parseEntryDetails(md, sectionTitle) {
|
|
152
194
|
const entries = [];
|
|
153
195
|
let title = null; // The open entry's heading (null = no entry open yet).
|
|
@@ -189,14 +231,20 @@ export function parseEntryDetails(md, sectionTitle) {
|
|
|
189
231
|
*
|
|
190
232
|
* A `- ` line needs more than a date to be an entry: the sections also hold body bullets (an
|
|
191
233
|
* entry's repro steps, a plan's task breakdown), and a body bullet that merely mentions a
|
|
192
|
-
* completion — "- same shape as the sibling bug (fixed 2026-09-24)" — is not one. The epitaph
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
|
|
234
|
+
* completion — "- same shape as the sibling bug (fixed 2026-09-24)" — is not one. The epitaph shape
|
|
235
|
+
* separates them: an epitaph always closes its line with a parenthetical that records both the
|
|
236
|
+
* completion date and the merge commit ("(planned …, done …; commit abc1234)"), so a bullet counts
|
|
237
|
+
* only when its trailing `(…)` group carries the date AND a `commit` reference; a heading entry
|
|
238
|
+
* keeps the plain metadata match (headings are the primary entry format and always close their
|
|
239
|
+
* metadata parenthetical by convention).
|
|
240
|
+
*
|
|
241
|
+
* The LAST dated match is the completion rule when `first` is false (the default): found-by,
|
|
242
|
+
* decomposition, and sibling mentions precede the completion record, so a sibling's date must
|
|
243
|
+
* not steal the count. `first` selects the FIRST dated match instead — the filing rule the
|
|
244
|
+
* filing read uses, matching the canonical `filingDate` (via firstDate). */
|
|
245
|
+
export function entryDates(md, sectionTitle, dateRe, first = false) {
|
|
199
246
|
const dates = [];
|
|
247
|
+
const pick = first ? firstDate : lastDate;
|
|
200
248
|
const lines = sectionBodyLines(md, sectionTitle);
|
|
201
249
|
for (let i = 0; i < lines.length; i++) {
|
|
202
250
|
const line = lines[i] ?? "";
|
|
@@ -212,10 +260,10 @@ export function entryDates(md, sectionTitle, dateRe) {
|
|
|
212
260
|
// <date>, fixed <date>") precedes the entry's own completion record.
|
|
213
261
|
const tail = trailingParenthetical(line);
|
|
214
262
|
if (/\bcommits?\b/.test(tail))
|
|
215
|
-
date =
|
|
263
|
+
date = pick(tail, dateRe);
|
|
216
264
|
}
|
|
217
265
|
else {
|
|
218
|
-
date =
|
|
266
|
+
date = pick(meta.text, dateRe);
|
|
219
267
|
}
|
|
220
268
|
if (date)
|
|
221
269
|
dates.push(date);
|
|
@@ -262,7 +310,8 @@ export function headingMetadata(lines, start) {
|
|
|
262
310
|
* and silently dropped the epitaph's date from the day report (BUGS.md 2026-09-29). Unbalanced
|
|
263
311
|
* text (no matching open paren) yields "" — the guard then treats the bullet as body text, as
|
|
264
312
|
* before. The one home for this rule: entryDates reads it for `- ` epitaphs and
|
|
265
|
-
* backlog-eligibility.ts's requiredParts for a heading's prerequisite clause
|
|
313
|
+
* backlog-eligibility.ts's requiredParts for a heading's prerequisite clause (falling back to
|
|
314
|
+
* openParenthetical when the heading's parenthetical wraps past the line). */
|
|
266
315
|
export function trailingParenthetical(line) {
|
|
267
316
|
if (!line.endsWith(")"))
|
|
268
317
|
return "";
|
|
@@ -276,14 +325,45 @@ export function trailingParenthetical(line) {
|
|
|
276
325
|
}
|
|
277
326
|
return "";
|
|
278
327
|
}
|
|
328
|
+
/** The text after the outermost "(" still open at the end of `line`, or "" when every group
|
|
329
|
+
* closes: a wrapped `### ` heading's stamp parenthetical continues on the following lines, so
|
|
330
|
+
* the heading line has no closing ")". A ")" at depth 0 is ignored. */
|
|
331
|
+
export function openParenthetical(line) {
|
|
332
|
+
let depth = 0;
|
|
333
|
+
let open = -1;
|
|
334
|
+
for (let i = 0; i < line.length; i++) {
|
|
335
|
+
const ch = line[i];
|
|
336
|
+
if (ch === "(") {
|
|
337
|
+
if (depth++ === 0)
|
|
338
|
+
open = i;
|
|
339
|
+
}
|
|
340
|
+
else if (ch === ")" && depth > 0)
|
|
341
|
+
depth--;
|
|
342
|
+
}
|
|
343
|
+
return depth > 0 ? line.slice(open + 1) : "";
|
|
344
|
+
}
|
|
279
345
|
/** A text's LAST `<dateRe>` capture — the completion rule both branches of entryDates share:
|
|
280
346
|
* an entry's completion is its LAST dated verb, not the first, because found-by,
|
|
281
347
|
* decomposition, and sibling mentions ("decomposed from the X bug fixed <date>") all precede
|
|
282
348
|
* the completion record, so first-match let a sibling's date steal the entry's count onto the
|
|
283
|
-
* wrong day (BUGS.md 2026-09-29).
|
|
349
|
+
* wrong day (BUGS.md 2026-09-29). Also read by completionDate (backlog.ts) for the finished-entry
|
|
350
|
+
* listings. dateRe is cloned with the g flag so matchAll sees every
|
|
284
351
|
* date; a text with no date yields null. */
|
|
285
|
-
function lastDate(text, dateRe) {
|
|
352
|
+
export function lastDate(text, dateRe) {
|
|
286
353
|
const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
|
|
287
354
|
const all = [...text.matchAll(global)];
|
|
288
355
|
return all[all.length - 1]?.[1] ?? null;
|
|
289
356
|
}
|
|
357
|
+
/** A text's FIRST `<dateRe>` capture — the filing rule, the mirror of lastDate: an entry's
|
|
358
|
+
* filing month is its FIRST stamp verb, because a later re-filing or a cross-reference
|
|
359
|
+
* ("found by bugfix <date>; found by doctor <date>") records a later date that must not move
|
|
360
|
+
* the filing onto a later day. This is the same first-match the canonical `filingDate`
|
|
361
|
+
* (backlog.ts) applies to a heading line, so the filing read and `plan|bug --list`'s age agree
|
|
362
|
+
* about when an entry opened. dateRe is cloned with the g flag so matchAll sees every date; a
|
|
363
|
+
* text with no date yields null. Module-local: entryDates (its only caller) selects it with
|
|
364
|
+
* `first`, so the walker stays single-homed like lastDate's completion rule. */
|
|
365
|
+
function firstDate(text, dateRe) {
|
|
366
|
+
const global = new RegExp(dateRe.source, dateRe.flags.includes("g") ? dateRe.flags : `${dateRe.flags}g`);
|
|
367
|
+
const match = global.exec(text);
|
|
368
|
+
return match?.[1] ?? null;
|
|
369
|
+
}
|