From 930a968ff0a511449e7052e416537fc3af830926 Mon Sep 17 00:00:00 2001 From: Al <7698600+aorumbayev@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:53:41 +0200 Subject: [PATCH 1/2] Board UX fixes, intake dialog polish (#25) * refactor: gate src complexity before test support files Replace the logic/TUI two-tier split with src at MI 52 first and test/ support files at MI 40 second so the fast verify path scores all source before slower test fixtures. * refactor: restore logic/TUI complexity tiers and cohesive layout Score non-TUI logic at MI 52 and the TUI surface at 50, then fold artificial verifyx micro-splits back into cohesive modules so the gate matches the architecture instead of forcing file fragmentation. * feat: render intake gates with markdown two-column dialogs Replace host DialogSelect/Prompt/Confirm for the intake chain so assumptions, questions, and mode rationale are readable as markdown, with short actions beside the body (answer step stays full-width stacked). * fix: tolerate trailing commas in OpenCode plugin config Hand-edited opencode.json often keeps a trailing comma; strict JSON parse made plugin:install:prod fail with an opaque SyntaxError. * fix: make board keyboard selection reliable and add Tab cycling Keep selectedColumn synced with the highlighted card, auto-select on refresh, blur leftover host focus on open, and cycle root tasks with Tab/Shift+Tab. Co-authored-by: Cursor * fix: clarify intake decision action labels Approve/Reject were ambiguous when a clarifying question was on screen; Accept assumption / Override with answer name the object of the choice. * feat: lead Review menu with approve * fix: scroll task details overflow * refactor: remove test-only DI seams and dead flexibility - delete CreateTaskDependencies bag and createUpdateController's optional check/confirm/runCommand/now; tests mock modules instead - dedupe patch.ts lock/read/merge/update skeleton into withKaganUpdate - delete shellGitRunner; server uses bunGitRunner (now a const, not a factory) - board passes store instead of drilling cap/sendBackStopThreshold/checkCommand - inline buildEditorContext/listEditorSignals/listEditorDialogControls into hook - drop dead code: setupCommand field, unused checkCommand hook prop, test:ci script, HOST_DIALOG_WIDTH size param, oxfmt pass in install-plugin - fold eligible predicate into spawnHelper; store parses options once - delete update-launch.test.ts (re-implemented wiring) and duplicate gateBadges mode tests; simplify git-shell fixture (preload already isolates) * fix: strip trailing commas with string-aware scanner Raw regex fallback also removed ", }" / ", ]" sequences inside quoted config values, silently rewriting them on the reparse-and-write path. --- .specs/kagan-supervision-board/design.md | 4 +- .../kagan-supervision-board/requirements.md | 11 +- AGENTS.md | 13 +- CONTRIBUTING.md | 10 +- docs/concepts/task-lifecycle.md | 21 +- docs/quickstart.md | 6 +- docs/reference/keybindings.md | 54 +++-- package.json | 3 +- scripts/install-plugin.ts | 52 ++-- src/git/runner.ts | 38 ++- src/server.ts | 36 ++- src/server/command.ts | 35 --- src/server/create-tasks.ts | 23 +- src/server/helpers/spawn.ts | 94 +++----- src/server/serialize.ts | 17 -- src/server/session/patch.ts | 44 ++-- src/server/tools.ts | 4 +- src/tui.tsx | 16 +- src/tui/board/board.tsx | 25 +- src/tui/board/card.tsx | 11 - src/tui/board/card/body.tsx | 39 +-- src/tui/board/card/helpers.ts | 10 +- src/tui/board/card/shell-props.tsx | 16 -- src/tui/board/column.tsx | 28 ++- src/tui/board/commands.tsx | 47 +++- src/tui/board/commands/bindings.ts | 30 --- src/tui/board/commands/context.ts | 33 --- src/tui/board/commands/help.tsx | 2 +- src/tui/board/commands/hints.ts | 29 ++- src/tui/board/commands/intake.tsx | 115 +++++---- src/tui/board/commands/menu.tsx | 9 +- src/tui/board/commands/merge.tsx | 35 +-- src/tui/board/commands/nav.tsx | 18 +- src/tui/board/commands/permissions.tsx | 12 +- src/tui/board/commands/send-back.tsx | 20 +- src/tui/board/commands/task-actions.tsx | 38 ++- src/tui/board/commands/types.ts | 20 ++ src/tui/board/layout/footer-text.ts | 7 - src/tui/board/layout/footer.tsx | 10 +- src/tui/board/layout/keymap.ts | 6 - src/tui/board/layout/main.tsx | 14 +- src/tui/board/layout/notice.tsx | 2 +- src/tui/board/store.tsx | 18 +- src/tui/board/store/detection.ts | 90 ------- src/tui/board/store/navigation.ts | 44 ---- src/tui/board/store/refresh.ts | 120 ++++++++-- src/tui/board/store/selection.ts | 176 +++++++++++++- src/tui/board/store/state.ts | 31 --- src/tui/dialogs/chrome.tsx | 2 +- src/tui/dialogs/create-task.tsx | 31 +-- .../{form-actions.ts => form-actions.tsx} | 65 +---- src/tui/dialogs/create-task/form-body.tsx | 91 ------- src/tui/dialogs/create-task/form-keys.ts | 46 ++++ src/tui/dialogs/create-task/form.tsx | 94 ++++++-- .../pickers.tsx} | 5 +- src/tui/dialogs/create-task/scope-picker.tsx | 2 +- src/tui/dialogs/create-task/select.tsx | 2 +- src/tui/dialogs/create-task/types.ts | 10 - src/tui/dialogs/findings-review.tsx | 19 -- src/tui/dialogs/findings-review/actions.ts | 9 +- src/tui/dialogs/findings-review/panel.tsx | 124 +++------- .../dialogs/findings-review/use-review.tsx | 90 +++++++ src/tui/dialogs/findings-review/views.tsx | 92 ++++---- src/tui/dialogs/intake-gate-content.tsx | 51 ++++ src/tui/dialogs/intake-gate-views.tsx | 160 +++++++++++++ src/tui/dialogs/intake-gate.tsx | 92 ++++++++ src/tui/dialogs/markdown-style.tsx | 44 ++++ src/tui/dialogs/onboarding.tsx | 2 +- src/tui/dialogs/task-details-sections.tsx | 63 ++++- src/tui/dialogs/task-details-view.tsx | 122 +++++++--- src/tui/dialogs/task-details.tsx | 54 +---- src/tui/format.ts | 2 +- src/tui/routes/settings.tsx | 2 +- src/tui/routes/settings/keys.tsx | 5 +- src/tui/routes/settings/list-editor.tsx | 2 - .../routes/settings/list-editor/commands.tsx | 40 ++-- .../routes/settings/list-editor/contents.tsx | 2 +- src/tui/routes/settings/list-editor/hook.tsx | 104 ++++++-- src/tui/routes/settings/list-editor/keys.tsx | 58 ----- .../routes/settings/list-editor/models.tsx | 2 +- src/tui/routes/settings/list-editor/state.tsx | 120 +++++----- src/tui/routes/settings/list-editor/types.ts | 77 ------ src/tui/routes/settings/rows.tsx | 3 +- src/tui/tasks.ts | 6 +- src/tui/updates/action.ts | 75 +++--- src/tui/updates/launch.ts | 21 -- test/fixtures/api.ts | 62 ++++- test/fixtures/git-shell.ts | 83 ++----- test/git/runner.test.ts | 2 +- test/guards/git-isolation.test.ts | 4 +- test/guards/validation.test.ts | 6 +- test/integration/create-tasks.test.ts | 8 +- test/preload/git-isolation.ts | 2 +- test/server/command.test.ts | 2 +- test/server/config.test.ts | 3 +- test/server/events.test.ts | 44 ++-- test/tui/board/board.test.tsx | 3 +- test/tui/board/card.test.tsx | 13 +- test/tui/board/column.test.tsx | 12 +- test/tui/board/commands.test.ts | 223 +++++++++++------- test/tui/board/selection.test.ts | 136 +++++++++++ test/tui/board/store.test.ts | 42 +++- test/tui/dialogs/create-task.test.tsx | 57 +++-- test/tui/dialogs/findings-review.test.tsx | 7 +- test/tui/dialogs/intake-gate.test.tsx | 185 +++++++++++++++ test/tui/dialogs/task-details-view.test.tsx | 123 ++++++++++ test/tui/dialogs/task-details.test.ts | 13 +- test/tui/format.test.ts | 50 +--- test/tui/session/tasks.test.ts | 2 +- test/tui/update-action.test.ts | 96 +++++--- test/tui/update-launch.test.ts | 71 ------ 111 files changed, 2704 insertions(+), 1870 deletions(-) delete mode 100644 src/server/command.ts delete mode 100644 src/server/serialize.ts delete mode 100644 src/tui/board/card.tsx delete mode 100644 src/tui/board/card/shell-props.tsx delete mode 100644 src/tui/board/commands/bindings.ts delete mode 100644 src/tui/board/commands/context.ts create mode 100644 src/tui/board/commands/types.ts delete mode 100644 src/tui/board/layout/footer-text.ts delete mode 100644 src/tui/board/layout/keymap.ts delete mode 100644 src/tui/board/store/detection.ts delete mode 100644 src/tui/board/store/navigation.ts delete mode 100644 src/tui/board/store/state.ts rename src/tui/dialogs/create-task/{form-actions.ts => form-actions.tsx} (62%) delete mode 100644 src/tui/dialogs/create-task/form-body.tsx create mode 100644 src/tui/dialogs/create-task/form-keys.ts rename src/tui/dialogs/{create-task-scope.tsx => create-task/pickers.tsx} (89%) delete mode 100644 src/tui/dialogs/findings-review.tsx create mode 100644 src/tui/dialogs/findings-review/use-review.tsx create mode 100644 src/tui/dialogs/intake-gate-content.tsx create mode 100644 src/tui/dialogs/intake-gate-views.tsx create mode 100644 src/tui/dialogs/intake-gate.tsx create mode 100644 src/tui/dialogs/markdown-style.tsx delete mode 100644 src/tui/routes/settings/list-editor.tsx delete mode 100644 src/tui/routes/settings/list-editor/keys.tsx delete mode 100644 src/tui/routes/settings/list-editor/types.ts delete mode 100644 src/tui/updates/launch.ts create mode 100644 test/tui/board/selection.test.ts create mode 100644 test/tui/dialogs/intake-gate.test.tsx create mode 100644 test/tui/dialogs/task-details-view.test.tsx delete mode 100644 test/tui/update-launch.test.ts diff --git a/.specs/kagan-supervision-board/design.md b/.specs/kagan-supervision-board/design.md index 77f9751..1bb73d4 100644 --- a/.specs/kagan-supervision-board/design.md +++ b/.specs/kagan-supervision-board/design.md @@ -90,12 +90,12 @@ same-session writes so concurrent event handlers cannot clobber each other. | `tui.tsx` | TUI composition, routes, subscriptions, update discovery at launch, and the `kagan.update` command | R3, R18.1, R18.4 | | `tui/session/`, `tui/tasks/` | TUI data ops: list/create, serialized metadata patching, send-back, merge, triage, approval, helper restart | R1, R3.2, R4.9, R9.10, R11, R12, R17.8 | | `tui/dialogs/create-task.tsx` | Custom OpenTUI create dialog, including configured/custom task scope selection | R1.1–1.4, R1.9–10 | -| `tui/board/store.tsx` | Solid board store: grouping, ordering, selection, move gating, refresh, notices, and update status | R3, R7, R17.4–5, R18 | +| `tui/board/store.tsx` | Solid board store: grouping, ordering, selection (including Tab root-card cycling), move gating, refresh, notices, and update status | R3, R7, R17.4–5, R18 | | `tui/board/commands.tsx` | Key bindings and dialog flows: create, move, triage, approve/merge, send-back, helper restart, task details view | R5.2, R4.9, R9.10, R10, R11, R12, R17.3, R17.7 | | `tui/board/board.tsx` / `column.tsx` / `card.tsx` | Board layout, column headers with cap, cards with task number and badges; board footer shows version, persistent update status, and the conditional update hint | R3, R3.7–8, R7.4, R18 | | `tui/updates/` | npm `latest` dist-tag check with eligibility and TTL cache (`check.ts`), non-mutating launch discovery (`launch.ts`), the approve→stage→install controller (`action.ts`), and the bounded no-shell CLI runner (`runner.ts`) | R3.8, R18 | | `tui/format.ts` | Card badges, age/diff/subtask formatting | R3.6 | -| `tui/dialogs/task-details.tsx` | Read-only task details view from live session metadata and diff stats | R17.7 | +| `tui/dialogs/task-details.tsx` | Read-only task details and intake-notes views; large dialog, pinned summary, scrollable body | R17.7 | | `tui/dialogs/onboarding.tsx` | First-run board tour and opt-out persistence | R17.6 | | `tui/routes/settings.tsx` | Settings route for editing plugin options and saving `opencode.json` | R17.10–13 | diff --git a/.specs/kagan-supervision-board/requirements.md b/.specs/kagan-supervision-board/requirements.md index 6bcfe2a..bbbad29 100644 --- a/.specs/kagan-supervision-board/requirements.md +++ b/.specs/kagan-supervision-board/requirements.md @@ -96,6 +96,10 @@ status cues, so that I can see at a glance what needs my attention. 13. WHERE the user filters cards with a query of the form `#N`, the board SHALL match only the card whose task number equals `N` exactly, in addition to the existing title/slug substring match. +14. WHEN the user presses Tab or Shift+Tab on the board THEN selection SHALL cycle forward or + backward across root board cards in column order (Backlog → In Progress → Review → Done), + wrapping at the ends, and SHALL land only on root cards — never on child sessions such as + intake or reviewer helpers; row-wise navigation into children remains available via j/k. --- @@ -458,7 +462,12 @@ so that failures, handoffs, and supervision evidence are visible instead of hidd `kagan.tutorial` palette command (`/kagan-tutorial`), regardless of the opt-out. 7. WHEN the user views task details from the card action menu THEN Kagan SHALL display a read-only summary of the task's title, status, intake, findings, prior triage, reports, check/setup - evidence, and diff stats without mutating task state. + evidence, and diff stats without mutating task state. WHEN that content exceeds the dialog + viewport THEN Kagan SHALL keep the title and status summary pinned and scroll the body so no + section is clipped. WHEN intake has completed THEN the card action menu SHALL also offer a + read-only intake-notes view of the understanding and resolved decisions with the same scroll + rule. WHEN the selected task is in Review THEN the card action menu SHALL lead with Approve and + Send back. 8. WHEN concurrent handlers patch the same session's `kagan` metadata THEN Kagan SHALL serialize the read-modify-write operations per session so one patch cannot clobber another, and a failed patch SHALL NOT block later patches for that session. diff --git a/AGENTS.md b/AGENTS.md index a066c46..e487760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,11 +15,14 @@ spec authority and read order. `src/domain/task/metadata.ts` is the authoritativ new-comment check compares against the PR merge base. - Run one built-in check with `bunx verifyx lint`, `bunx verifyx format`, `bunx verifyx check-types`, or `bunx verifyx duplicate-code`. Use `bun run package` for package checks. -- `verify:complexity` is two-tier: pure logic (`src/{domain,server,git,checks}`, `src/server.ts`, - `src/task/`) must clear maintainability index 52; the TUI surface (`src/tui/`, `src/tui.tsx`) clears - 50 because its JSX render functions are inherently lower-scoring. Raise a score by splitting genuine - responsibilities into cohesive units — never by deleting comments, joining lines, or fragmenting a - coherent function. The exact command is pinned by `test/guards/validation.test.ts`. +- `verify:complexity` is two-tier: pure logic (`src/**/*.ts` excluding `src/tui/**` and `src/tui.tsx`) + must clear maintainability index 52; the TUI surface (`src/tui/**/*.{ts,tsx}`, `src/tui.tsx`) clears + 50 because its JSX render functions are inherently lower-scoring. Simplify the failing function + first; split only when responsibilities genuinely diverge. Merging or unsplitting is valid when + cohesion beats MI churn from artificial file boundaries. Use `verifyx complexity --sloc-scope function` + to score SLOC from each function body instead of the whole file. Never game the metric by deleting + comments, joining lines, or fragmenting a coherent function. The exact command is pinned by + `test/guards/validation.test.ts`. - Run the full suite with `bun run test`, not bare `bun test`. The script supplies `--conditions browser`; `bunfig.toml` supplies the Solid preload. Bun positional test filters can also match a local gitignored `references/` checkout because its exclude applies only to test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe513aa..35074df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,10 +36,12 @@ This runs the declared source checks: complexity, comment policy, and circular d auto-formats with oxfmt. Verifyx also runs the local `test` script automatically. The maintainability-index gate is two-tier, matching the architecture split: pure logic -(`src/{domain,server,git,checks}` plus `src/server.ts` and `src/task/`) must clear a higher bar than -the TUI surface (`src/tui/`), whose JSX render functions are inherently lower-scoring. Raise a file's -score by splitting genuine responsibilities into cohesive units, never by deleting comments, joining -lines, or fragmenting a coherent function. +(`src/**/*.ts` excluding `src/tui/**` and `src/tui.tsx`) must clear maintainability index 52; the TUI +surface (`src/tui/**/*.{ts,tsx}`, `src/tui.tsx`) clears 50 because its JSX render functions are +inherently lower-scoring. Simplify the failing function first; split only when responsibilities +genuinely diverge. Merging or unsplitting is valid when cohesion beats MI churn from artificial file +boundaries. Never game the metric by deleting comments, joining lines, or fragmenting a coherent +function. The exact command is pinned by `test/guards/validation.test.ts`. Pre-commit and CI run `bun run check`. That full check-only gate runs every built-in `verifyx` check, including its automatic test step, then validates the package. It does not rewrite files. diff --git a/docs/concepts/task-lifecycle.md b/docs/concepts/task-lifecycle.md index ac9fe82..09b2ff0 100644 --- a/docs/concepts/task-lifecycle.md +++ b/docs/concepts/task-lifecycle.md @@ -14,7 +14,7 @@ denied with the reason, on the board and server-side. Creating a task spawns a read-only **task prep** child session. It reads the codebase at your chosen base branch and returns three things: an understanding of the task, clarifying decisions -(each an assumption you must approve or override with an answer), and a refined final instruction +(each an assumption you must accept or override with an answer), and a refined final instruction for the implementing agent. It also returns an advisory mode recommendation (`autonomous`, `assisted`, or `manual`) with a one-line rationale. The recommendation is informational only and never gates a move or approval. @@ -59,14 +59,17 @@ rejected before they are saved. the same worktree, prompted with the previous iteration's report, the files already changed, the findings to address, and the intended-behavior findings to leave alone. Review state resets; the card returns to In Progress. -- **Approve** (`a`) is unlocked once the reviewer has run and every finding is triaged. It opens - the merge dialog: merge the task branch into the checked-out branch, into another branch, or - take no action (right for investigative tasks). If the base branch has advanced since the task - was created, the dialog warns that the reviewed diff may be stale. A failed merge aborts the - approval — nothing moves to Done on an error. Once a task reaches Done it stays there — moving it - back out is denied; create a follow-up task instead. Done tasks can be archived from the card - action menu; archiving removes them from the board but the session remains reachable through - OpenCode's own session list. +- **Approve** (`a`, or Enter → Approve on a Review card) opens the triage dialog first. Approve & + merge unlocks once every finding is ruled; until then the dialog states how many remain. After + unlock it opens the merge dialog: merge into the checked-out branch, into another branch, or take + no action (right for investigative tasks). If the base branch has advanced since the task was + created, the dialog warns that the reviewed diff may be stale. A failed merge aborts the approval + — nothing moves to Done on an error. Once a task reaches Done it stays there — moving it back out + is denied; create a follow-up task instead. Done tasks can be archived from the card action menu; + archiving removes them from the board but the session remains reachable through OpenCode's own + session list. Intake understanding and resolved decisions are also reachable from the card menu + as **View intake notes**. Both details views keep the header pinned and scroll long bodies with + `↑`/`↓`. If an intake or review helper fails, the card shows `intake failed` or `review failed`. Kagan retries automatically up to `helperRetries` times; after that, press `r` on the card to retry diff --git a/docs/quickstart.md b/docs/quickstart.md index 52fb428..a949c36 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -99,9 +99,9 @@ iterating, take over the session, or leave it in Review. - **Filter and reorder** — press `/` to filter cards by title or slug. Type `#3` to filter to task #3. Press `J` / `K` to reorder the selected root card within its column. - **View details and archive** — open a card's action menu with `Enter` to read a structured summary - of intake, findings, check output, and diff stats, or — for Done tasks — **archive** them so they - leave the board. Archiving is one-way; the session remains reachable through OpenCode's own session - list. + of intake, findings, check output, and diff stats (`↑`/`↓` scroll when it is long), or — for Done + tasks — **archive** them so they leave the board. Archiving is one-way; the session remains + reachable through OpenCode's own session list. - **Settings** — press `,` on the board or run `/kagan-settings` to edit Kagan's plugin options. Saving writes project `opencode.json`; restart OpenCode or reopen the project for changes to apply. diff --git a/docs/reference/keybindings.md b/docs/reference/keybindings.md index 0e80c43..09b2d21 100644 --- a/docs/reference/keybindings.md +++ b/docs/reference/keybindings.md @@ -6,26 +6,27 @@ the same as running `/kagan` or the `kagan` palette command. The keys below are active while the board route is open. Press `?` on the board for the inline help, or run `/kagan-tutorial` to replay the guided tour. -| Key | Action | -| --------- | -------------------------------------------------------------------------------------- | -| `j` / `k` | Next / previous row (card or subtask) | -| `J` / `K` | Move the selected card down / up within its column | -| `g` / `G` | Jump to the first / last row in the current column | -| `l` / `h` | Next / previous column (also `→` / `←`) | -| `m` | Move card to the next column (runs the gates) | -| `b` | Move card to the previous column; from Review this sends it back instead | -| `n` | New task (create dialog) | -| `o` | Open the selected session | -| `Enter` | Open the card action menu — options list only what applies, each with its own shortcut | -| `d` | Delete the selected session | -| `a` | Approve: triage findings, then the merge dialog | -| `s` | Send back for another iteration (Review only) | -| `r` | Restart intake (Backlog) or review (Review) after any prior helper run | -| `/` | Filter cards by title, slug, or an exact `#N` task number | -| `,` | Open Kagan settings | -| `?` | Toggle help | -| `q` | Close the board | -| `Esc` | Dismiss: close the help overlay, else clear an active filter | +| Key | Action | +| ------------------- | -------------------------------------------------------------------------------------- | +| `j` / `k` | Next / previous row (card or subtask) | +| `Tab` / `Shift+Tab` | Next / previous root task across columns (skips intake/reviewer children; wraps) | +| `J` / `K` | Move the selected card down / up within its column | +| `g` / `G` | Jump to the first / last row in the current column | +| `l` / `h` | Next / previous column (also `→` / `←`) | +| `m` | Move card to the next column (runs the gates) | +| `b` | Move card to the previous column; from Review this sends it back instead | +| `n` | New task (create dialog) | +| `o` | Open the selected session | +| `Enter` | Open the card action menu — options list only what applies, each with its own shortcut | +| `d` | Delete the selected session | +| `a` | Approve: triage findings, then the merge dialog | +| `s` | Send back for another iteration (Review only) | +| `r` | Restart intake (Backlog) or review (Review) after any prior helper run | +| `/` | Filter cards by title, slug, or an exact `#N` task number | +| `,` | Open Kagan settings | +| `?` | Toggle help | +| `q` | Close the board | +| `Esc` | Dismiss: close the help overlay, else clear an active filter | Moving a task backward is gated too: a started In Progress task can't drop back to Backlog (send it back from Review, or delete it instead), and Done tasks stay put — create a follow-up task @@ -39,8 +40,11 @@ In the create dialog: `Tab` moves between fields, `↑`/`↓` move outside the d `Enter` opens the scope/model/base-branch pickers, `Enter` submits from the title row, `Ctrl+Enter` submits from any field, and `Esc` cancels. -View details and archive live in the card action menu (`Enter`) rather than on dedicated keys. -**View details** opens a read-only summary of the task's title, status, intake, findings, prior -triage, reports, check/setup evidence, and diff stats. -Archiving a Done task removes it from the board — it stays reachable through OpenCode's own session -list, with no unarchive path back onto the board. +View details, intake notes, and archive live in the card action menu (`Enter`) rather than on +dedicated keys. On Review cards the menu leads with **Approve** and **Send back**. **View +details** opens a read-only summary of the task's title, status, intake, findings, prior triage, +reports, check/setup evidence, and diff stats; long content scrolls with `↑`/`↓` while the title +and status stay pinned. **View intake notes** opens just the intake understanding and resolved +decisions when intake has run (same scroll rule). Archiving a Done task removes it from the board +— it stays reachable through OpenCode's own session list, with no unarchive path back onto the +board. diff --git a/package.json b/package.json index 9fc5d6f..d86cbc1 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,12 @@ "prepack": "bun scripts/build.ts", "verify": "verifyx", "check": "verifyx all --check && bun run package", - "verify:complexity": "verifyx complexity --threshold 52 --ignore \"src/tui/**\" \"src/**/*.ts\" && verifyx complexity --threshold 50 --ignore \"src/domain/**\" --ignore \"src/server/**\" --ignore \"src/git/**\" --ignore \"src/checks/**\" --ignore \"src/task/**\" --ignore \"src/server.ts\" \"src/**/*.{ts,tsx}\"", + "verify:complexity": "verifyx complexity --threshold 52 --ignore \"src/tui/**\" --ignore \"src/tui.tsx\" \"src/**/*.ts\" && verifyx complexity --threshold 50 \"{src/tui.tsx,src/tui/**/*.{ts,tsx}}\"", "verify:format": "oxfmt --check .", "verify:format:fix": "oxfmt .", "verify:comments": "verifyx comments --pushback \"src/**/*.{ts,tsx}\"", "verify:circular-deps": "verifyx circular-deps -- -w src", "package": "bun scripts/package-check.ts", - "test:ci": "bun run test", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", diff --git a/scripts/install-plugin.ts b/scripts/install-plugin.ts index 65331a5..952f34c 100644 --- a/scripts/install-plugin.ts +++ b/scripts/install-plugin.ts @@ -21,19 +21,45 @@ function defaultConfigFor(file: string): Record { : { $schema: "https://opencode.ai/config.json" } } -async function readConfig(file: string): Promise> { - return (await Bun.file(file).exists()) - ? ((await Bun.file(file).json()) as Record) - : defaultConfigFor(file) +// string-aware so a `, }` inside a quoted value is never touched +function stripTrailingCommas(text: string): string { + let out = "" + let inString = false + for (let i = 0; i < text.length; i++) { + const ch = text.charAt(i) + if (inString) { + out += ch + if (ch === "\\") out += text[++i] ?? "" + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === ",") { + let j = i + 1 + while (j < text.length && /\s/.test(text.charAt(j))) j++ + if (text[j] === "}" || text[j] === "]") continue + } + out += ch + } + return out +} + +function parseConfigJson(text: string, file: string): Record { + try { + return JSON.parse(text) as Record + } catch { + // hand-edited OpenCode configs often keep a trailing comma + try { + return JSON.parse(stripTrailingCommas(text)) as Record + } catch (error) { + throw new Error(`Failed to parse ${file}: ${error instanceof Error ? error.message : String(error)}`) + } + } } -async function format(files: string[]): Promise { - const oxfmt = Bun.spawn(["bun", "x", "oxfmt", "--write", ...files], { - cwd: repoRoot, - stdout: "ignore", - stderr: "inherit", - }) - await oxfmt.exited +async function readConfig(file: string): Promise> { + if (!(await Bun.file(file).exists())) return defaultConfigFor(file) + return parseConfigJson(await Bun.file(file).text(), file) } async function addGlobalPluginSpec(spec: string): Promise { @@ -45,7 +71,6 @@ async function addGlobalPluginSpec(spec: string): Promise { await writeFile(file, `${JSON.stringify(config, null, 2)}\n`) console.log(`${file} → plugin includes "${spec}"`) } - await format(globalConfigFiles) } function pluginSpec(entry: unknown): string | undefined { @@ -64,7 +89,6 @@ function isKaganSpec(spec: string): boolean { } async function removeGlobalKaganPluginSpecs(): Promise { - const touched: string[] = [] for (const file of globalConfigFiles) { if (!(await Bun.file(file).exists())) continue const config = await readConfig(file) @@ -82,10 +106,8 @@ async function removeGlobalKaganPluginSpecs(): Promise { } else { await writeFile(file, `${JSON.stringify(config, null, 2)}\n`) console.log(`${file} → dropped kagan plugin entries`) - touched.push(file) } } - if (touched.length > 0) await format(touched) } async function run(args: string[], options?: { cwd?: string; stdout?: "pipe" | "inherit" }): Promise { diff --git a/src/git/runner.ts b/src/git/runner.ts index ce67211..5405c12 100644 --- a/src/git/runner.ts +++ b/src/git/runner.ts @@ -1,4 +1,3 @@ -import type { PluginInput } from "@opencode-ai/plugin" import { mkdir, readFile, rm, writeFile } from "node:fs/promises" import { homedir } from "node:os" import { join, resolve } from "node:path" @@ -6,29 +5,20 @@ import { join, resolve } from "node:path" export type GitResult = { code: number; stdout: string; stderr: string } export type GitRunner = (args: string[], cwd: string) => Promise -export function bunGitRunner(): GitRunner { - return async (args, cwd) => { - const proc = Bun.spawn(["git", ...args], { - cwd, - env: process.env, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - }) - const [stdout, stderr, code] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - return { code, stdout, stderr } - } -} - -export function shellGitRunner($: PluginInput["$"]): GitRunner { - return async (args, cwd) => { - const result = await $`git -C ${cwd} ${args}`.nothrow().quiet() - return { code: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() } - } +export const bunGitRunner: GitRunner = async (args, cwd) => { + const proc = Bun.spawn(["git", ...args], { + cwd, + env: process.env, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { code, stdout, stderr } } function slugifyTitle(title: string): string { diff --git a/src/server.ts b/src/server.ts index 0807e19..3c41cba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,45 @@ import type { Plugin, PluginModule } from "@opencode-ai/plugin" import type { Permission } from "@opencode-ai/sdk" +import { configuredScopes } from "./domain/task/commands" import { isSupervisedSession, ownsReadOnlyHelper, readOnlyHelperClaim } from "./domain/task/policy" import { isGitPushCommand } from "./git/runner" import { getSessionData } from "./server/data" import { createServerEvents } from "./server/events" import { createServerTools } from "./server/tools" -import { buildKaganTaskTemplate } from "./server/command" + +export function buildKaganTaskTemplate(options?: Record): string { + const scopes = configuredScopes(options) + const scopeLines = + scopes.length > 0 + ? [ + "Configured scope cwd values for this project:", + ...scopes.map((cwd) => `- \`${cwd}\``), + "When proposing tickets, pick scope values from this list (or omit scope).", + ] + : ["No configured scope cwd values — omit scope on tickets unless the user names one explicitly."] + + return [ + "You are helping the user create one or more Kagan board tasks through conversation.", + "", + "Workflow:", + "1. Decide the source of the tickets. This command runs inline in the user's current session, so its conversation is available to you. IF this session already holds substantial prior work or discussion, ask the user EXACTLY ONCE, in one short question, whether to base the tickets on the conversation so far or only on what they typed after the command, then follow their choice. IF the session is empty or has no relevant prior context, skip that question and plan directly from $ARGUMENTS. When basing tickets on the conversation, distill what was discussed or built into concrete tickets instead of asking the user to repeat it.", + "2. Read $ARGUMENTS and any follow-up from the user. Ask clarifying questions until each task has a clear title and description.", + "3. Propose a numbered ticket list (title, description, optional base branch, optional scope).", + "4. Iterate until the user confirms the list is final.", + "5. Call `kagan_create_tasks` once with every confirmed ticket.", + "6. Report the tool result to the user.", + "", + ...scopeLines, + "", + "Rules:", + "- Never create tasks without explicit user confirmation.", + "- Each ticket needs a non-blank title and description.", + "- At most 10 tickets per call.", + "- Default base branch to the current git branch when omitted.", + "", + "$ARGUMENTS", + ].join("\n") +} const PUSH_DENIED_MESSAGE = "kagan task sandboxes cannot push to a remote — merging happens through the board's merge dialog after review." diff --git a/src/server/command.ts b/src/server/command.ts deleted file mode 100644 index d0455b8..0000000 --- a/src/server/command.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { configuredScopes } from "../domain/task/commands" - -export function buildKaganTaskTemplate(options?: Record): string { - const scopes = configuredScopes(options) - const scopeLines = - scopes.length > 0 - ? [ - "Configured scope cwd values for this project:", - ...scopes.map((cwd) => `- \`${cwd}\``), - "When proposing tickets, pick scope values from this list (or omit scope).", - ] - : ["No configured scope cwd values — omit scope on tickets unless the user names one explicitly."] - - return [ - "You are helping the user create one or more Kagan board tasks through conversation.", - "", - "Workflow:", - "1. Decide the source of the tickets. This command runs inline in the user's current session, so its conversation is available to you. IF this session already holds substantial prior work or discussion, ask the user EXACTLY ONCE, in one short question, whether to base the tickets on the conversation so far or only on what they typed after the command, then follow their choice. IF the session is empty or has no relevant prior context, skip that question and plan directly from $ARGUMENTS. When basing tickets on the conversation, distill what was discussed or built into concrete tickets instead of asking the user to repeat it.", - "2. Read $ARGUMENTS and any follow-up from the user. Ask clarifying questions until each task has a clear title and description.", - "3. Propose a numbered ticket list (title, description, optional base branch, optional scope).", - "4. Iterate until the user confirms the list is final.", - "5. Call `kagan_create_tasks` once with every confirmed ticket.", - "6. Report the tool result to the user.", - "", - ...scopeLines, - "", - "Rules:", - "- Never create tasks without explicit user confirmation.", - "- Each ticket needs a non-blank title and description.", - "- At most 10 tickets per call.", - "- Default base branch to the current git branch when omitted.", - "", - "$ARGUMENTS", - ].join("\n") -} diff --git a/src/server/create-tasks.ts b/src/server/create-tasks.ts index fe7e942..37ebfad 100644 --- a/src/server/create-tasks.ts +++ b/src/server/create-tasks.ts @@ -3,9 +3,24 @@ import { commandPlan, configuredScopes, sanitizeTaskScope, type TaskScope } from import type { CommandSpec } from "../domain/task/types" import { nextTaskNumber } from "../domain/task/metadata" import { createBoardTask, type CreateSessionPayload } from "../task/create" -import { currentBranch, shellGitRunner } from "../git/runner" +import { bunGitRunner, currentBranch, type GitRunner } from "../git/runner" import { listSessions } from "./data" -import { serializeByKey } from "./serialize" + +const serializeTails = new Map>() + +function serializeByKey(key: string, task: () => Promise): Promise { + const prior = serializeTails.get(key) ?? Promise.resolve() + const run = prior.then(task, task) + const settled = run.then( + () => undefined, + () => undefined, + ) + serializeTails.set(key, settled) + void settled.then(() => { + if (serializeTails.get(key) === settled) serializeTails.delete(key) + }) + return run +} export type CreateTaskTicket = { title: string @@ -67,7 +82,7 @@ export async function runCreateTasks( if (tickets.length < 1 || tickets.length > 10) throw new Error("Provide between 1 and 10 tickets") const allowedScopes = configuredScopes(options) - const run = shellGitRunner(input.$) + const run = bunGitRunner const defaultBranch = (await currentBranch(run, input.worktree)) ?? "HEAD" const setupCommands = commandPlan(options, "setup") @@ -80,7 +95,7 @@ export async function runCreateTasks( async function createSerially( input: PluginInput, - run: ReturnType, + run: GitRunner, allowedScopes: readonly string[], defaultBranch: string, setupCommands: CommandSpec[], diff --git a/src/server/helpers/spawn.ts b/src/server/helpers/spawn.ts index 530ebdd..6a6ae47 100644 --- a/src/server/helpers/spawn.ts +++ b/src/server/helpers/spawn.ts @@ -4,7 +4,7 @@ import { helper } from "../../domain/task/policy" import { kagan } from "../../domain/task/metadata" import { runCommandPlan, truncateCheckResultForMetadata, type CheckResult } from "../../checks/runner" import { worktreeDiffs } from "../../git/diffs" -import { shellGitRunner } from "../../git/runner" +import { bunGitRunner } from "../../git/runner" import { spawnIntake } from "../intake" import { claimHelperSpawn, patchKagan } from "../session/patch" import { spawnValidator } from "../validator/spawn" @@ -19,7 +19,6 @@ async function spawnHelper( role: "intake" | "validator", sessionID: string, options: Record | undefined, - eligible: (session: Awaited>) => boolean, spawn: (session: Awaited>) => Promise, ): Promise { const key = `${sessionID}:${role}` @@ -31,8 +30,8 @@ async function spawnHelper( const metadata = session?.metadata const view = kagan(metadata) const before = helper(metadata, role) - if (view.boardTask !== true || !eligible(session) || before.outcome !== undefined || before.sessionID !== undefined) - return + const eligible = role === "intake" || (!view.role && !session?.parentID && Boolean(view.worktree)) + if (view.boardTask !== true || !eligible || before.outcome !== undefined || before.sessionID !== undefined) return if (!(await claimHelperSpawn(input.client, sessionID, role))) return const attempts = before.attempts + 1 let childID: string | undefined @@ -58,27 +57,20 @@ export async function onEnterBacklog( sessionID: string, options?: Record, ): Promise { - await spawnHelper( - input, - "intake", - sessionID, - options, - () => true, - async (session) => { - const view = kagan(session?.metadata) - return spawnIntake( - input, - sessionID, - { - title: session?.title ?? "", - description: view.description, - references: await resolveTaskRefs(input, view.description), - scope: view.scope, - }, - options, - ) - }, - ) + await spawnHelper(input, "intake", sessionID, options, async (session) => { + const view = kagan(session?.metadata) + return spawnIntake( + input, + sessionID, + { + title: session?.title ?? "", + description: view.description, + references: await resolveTaskRefs(input, view.description), + scope: view.scope, + }, + options, + ) + }) } async function collectCheck( @@ -88,7 +80,7 @@ async function collectCheck( baseBranch: string | undefined, options: Record | undefined, ): Promise<{ diffs: Awaited>; check: CheckResult | undefined }> { - const diffs = await worktreeDiffs(shellGitRunner(input.$), worktree, baseBranch ?? "HEAD") + const diffs = await worktreeDiffs(bunGitRunner, worktree, baseBranch ?? "HEAD") const commands = commandPlan(options, "check") if (commands.length === 0) return { diffs, check: undefined } const changedFiles = diffs.map((diff) => diff.file).filter((file): file is string => typeof file === "string") @@ -108,34 +100,24 @@ export async function onEnterReview( sessionID: string, options?: Record, ): Promise { - await spawnHelper( - input, - "validator", - sessionID, - options, - (session) => { - const view = kagan(session?.metadata) - return !view.role && !session?.parentID && Boolean(view.worktree) - }, - async (session) => { - const view = kagan(session?.metadata) - if (!view.worktree) return undefined - const { diffs, check } = await collectCheck(input, sessionID, view.worktree, view.baseBranch, options) - return spawnValidator( - input, - sessionID, - diffs, - { - title: session?.title ?? "", - description: view.description, - intake: view.intake, - priorTriage: view.priorTriage, - generation: view.generation, - check, - builderModel: view.model, - }, - options, - ) - }, - ) + await spawnHelper(input, "validator", sessionID, options, async (session) => { + const view = kagan(session?.metadata) + if (!view.worktree) return undefined + const { diffs, check } = await collectCheck(input, sessionID, view.worktree, view.baseBranch, options) + return spawnValidator( + input, + sessionID, + diffs, + { + title: session?.title ?? "", + description: view.description, + intake: view.intake, + priorTriage: view.priorTriage, + generation: view.generation, + check, + builderModel: view.model, + }, + options, + ) + }) } diff --git a/src/server/serialize.ts b/src/server/serialize.ts deleted file mode 100644 index a0c5517..0000000 --- a/src/server/serialize.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Serializes async work by key so overlapping bulk task-creation runs can't read the same session -// snapshot and mint duplicate task numbers. Scoped to this server process. -const tails = new Map>() - -export function serializeByKey(key: string, task: () => Promise): Promise { - const prior = tails.get(key) ?? Promise.resolve() - const run = prior.then(task, task) - const settled = run.then( - () => undefined, - () => undefined, - ) - tails.set(key, settled) - void settled.then(() => { - if (tails.get(key) === settled) tails.delete(key) - }) - return run -} diff --git a/src/server/session/patch.ts b/src/server/session/patch.ts index 4e44a80..e23984d 100644 --- a/src/server/session/patch.ts +++ b/src/server/session/patch.ts @@ -12,19 +12,32 @@ async function readSessionMetadata(client: PluginInput["client"], sessionID: str > } -export async function patchKagan( +async function withKaganUpdate( client: PluginInput["client"], sessionID: string, - partial: Record, -): Promise { + compute: (metadata: Record) => Record | undefined, +): Promise { + let updated = false await lockSessionMetadata(sessionID, async () => { const metadata = await readSessionMetadata(client, sessionID) + const patch = compute(metadata) + if (!patch) return + updated = true await client.session.update({ path: { id: sessionID }, - body: { metadata: mergeKagan(metadata, partial) }, + body: { metadata: mergeKagan(metadata, patch) }, throwOnError: true, } as Parameters[0]) }) + return updated +} + +export async function patchKagan( + client: PluginInput["client"], + sessionID: string, + partial: Record, +): Promise { + await withKaganUpdate(client, sessionID, () => partial) } export async function mutateKagan( @@ -32,16 +45,7 @@ export async function mutateKagan( sessionID: string, compute: (view: ReturnType) => Record | undefined, ): Promise { - await lockSessionMetadata(sessionID, async () => { - const metadata = await readSessionMetadata(client, sessionID) - const patch = compute(kagan(metadata)) - if (!patch) return - await client.session.update({ - path: { id: sessionID }, - body: { metadata: mergeKagan(metadata, patch) }, - throwOnError: true, - } as Parameters[0]) - }) + await withKaganUpdate(client, sessionID, (metadata) => compute(kagan(metadata))) } export async function claimHelperSpawn( @@ -49,17 +53,9 @@ export async function claimHelperSpawn( sessionID: string, role: HelperRole, ): Promise { - let claimed = false - await lockSessionMetadata(sessionID, async () => { - const metadata = await readSessionMetadata(client, sessionID) + return withKaganUpdate(client, sessionID, (metadata) => { const before = helper(metadata, role) if (before.outcome !== undefined || before.sessionID !== undefined) return - claimed = true - await client.session.update({ - path: { id: sessionID }, - body: { metadata: mergeKagan(metadata, { [`${role}Outcome`]: "pending" }) }, - throwOnError: true, - } as Parameters[0]) + return { [`${role}Outcome`]: "pending" } }) - return claimed } diff --git a/src/server/tools.ts b/src/server/tools.ts index 3191076..e509a29 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin/tool" import { worktreeDiffs } from "../git/diffs" -import { shellGitRunner } from "../git/runner" +import { bunGitRunner } from "../git/runner" import { sanitizeIntakeDecisions } from "../domain/task/intake" import { verifyFindingCitations, type Finding } from "../domain/task/findings" import { helper } from "../domain/task/policy" @@ -95,7 +95,7 @@ export function createServerTools(input: PluginInput, options?: Record { api.lifecycle.onDispose(() => disposeEvents()) api.lifecycle.onDispose(() => disposeStatusEvents()) - runUpdateDiscovery({ - api, - meta, + checkForUpdate({ + kv: api.kv, currentVersion: version, + source: meta.source, + spec: meta.spec, now: Date.now(), - setUpdateStatus: store.setUpdateStatus, - }).catch(() => {}) + }) + .then((result) => { + if (result?.kind === "available" && !api.lifecycle.signal.aborted) store.setUpdateStatus(result) + }) + .catch(() => {}) const updates = createUpdateController({ api, meta, store }) diff --git a/src/tui/board/board.tsx b/src/tui/board/board.tsx index edf2469..b8bba94 100644 --- a/src/tui/board/board.tsx +++ b/src/tui/board/board.tsx @@ -3,12 +3,19 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { maybeShowOnboarding } from "../dialogs/onboarding" import { kagan } from "../../domain/task/metadata" -import { boardBindings, createBoardCommands, footerHints, HelpOverlay, type BoardStore } from "./commands" -import { keymapKey } from "./layout/keymap" +import { boardBindings, createBoardCommands, footerHints, type BoardStore } from "./commands" +import { HelpOverlay } from "./commands/help" import { BoardMain } from "./layout/main" import { Footer } from "./layout/footer" import { Notice } from "./layout/notice" +function keymapKey(key: string): string | { name: string } { + if (key === ",") return { name: "," } + if (key === "?") return "?,shift+/" + if (key === "return") return "return,enter" + return key +} + export function Board(props: { api: TuiPluginApi; store: BoardStore }) { const store = props.store const [helpOpen, setHelpOpen] = createSignal(false) @@ -16,7 +23,11 @@ export function Board(props: { api: TuiPluginApi; store: BoardStore }) { let disposeLayer: (() => void) | undefined - onMount(() => maybeShowOnboarding(props.api)) + onMount(() => { + // Host prompt/dialog focus can survive into this route and swallow hjkl. + props.api.renderer.currentFocusedRenderable?.blur() + maybeShowOnboarding(props.api) + }) const updateAvailable = createMemo(() => store.updateStatus()?.kind === "available") @@ -50,13 +61,7 @@ export function Board(props: { api: TuiPluginApi; store: BoardStore }) { - +