Repository navigation
feat(tui): bring the board to the fleet TUI grammar - #21
Conversation
One action table builds the key handling, the status bar's hints and the ? overlay, so a key the row cannot take is in none of the three. Tab cycles three views, named in a header: By project, All items, and the dependency map. All items groups every item under a header with its open and done counts, and Enter or space folds a group. --view opens on any of them, and --json answers for the two lists. A row takes two lines: the state, title and glyphs, then dim, the repo, the other projects and the number. Space opens a note in place, however tall, and the item's first line stays at the top while J, K, ctrl+d and ctrl+u scroll it. A chevron marks only a row that opens. / filters the list in place, matching titles and notes with smart case, and replaces the search overlay. Enter reads an item or project full screen in gotermboard's reader, which replaces the glamour reader. Keys follow the fleet's vocabulary: M moves and m is unbound, c marks done, e edits in $EDITOR, n renames, P files, D deletes a task, [ and ] jump past tasks, . shows closed projects and finished trees, and C opens a Claude session in the row's repo checkout, which the repo registry's path field names. The project status actions sit on the project row. Agent-Name: claude-code-unattended Agent-Session: eaef152a-8638-4371-a140-eed180910f90 Agent-Run: 01a1ba43-f2a5-7bcd-bb50-171671706e49 Worklog: 1462.1
There was a problem hiding this comment.
PR review — system-design
Used: Cloned PR 21 at head, read the diff, tui/claude_session.go and gotermboard v0.31.0 OpenWindow, ran go build (it downloads the bumped modules).
Standards: No rules cited. No infrastructure rule applied to a TUI-only change with no deploy files.
Findings: 0 correctness · 0 design · 0 prose
No findings.
Findings as JSON
{
"used": "Cloned PR 21 at head, read the diff, tui/claude_session.go and gotermboard v0.31.0 OpenWindow, ran go build (it downloads the bumped modules).",
"standards": "No rules cited. No infrastructure rule applied to a TUI-only change with no deploy files.",
"findings": []
}This review does not gate merge.
There was a problem hiding this comment.
PR review — failure-paths
Used: Read the PR diff and tui/claude_session.go, repos/repos.go and the app.go message handlers. I did not run the failure paths.
Standards: code-quality.md § "A result that can come back partial carries the signal that tells it from a whole one". It came from fleet standards search "degraded fallback result indistinguishable from a whole one".
Findings: 1 correctness · 0 design · 0 prose
Correctness
failure-paths.1 tui/claude_session.go:68 · tui/claude_session.go:62 · tui/claude_session.go:77
Reach: A user presses C outside tmux on a project whose items name zero or several repos, or on an item whose repo has no path in the repos registry. This is the default case for any registry without a path field, since the field is new in this PR. I read the code and did not run it.
Outside tmux, the cwd fallback for a Claude session is silent.
sessionDirectory returns a fallback note when the repo is unnamed or the registry has no checkout for it. It opens the session in the board's own cwd.
Only the tmux branch shows that note (line 62). The non-tmux branch (lines 68-70) drops it and runs claude with session.Dir set to the cwd. The end message, Back from the Claude session, says nothing about the directory either.
The user cannot tell a session in the right checkout from one in an unrelated directory. The comment on sessionDirectory says a session must never plan against another repo's code. The cwd fallback breaks that guarantee whenever the cwd is a different repo.
Fix: show the note before the process takes the terminal. Alternatively, put the directory in the brief, or refuse as the missing-checkout case does. In openClaudeSession, give the non-tmux path the same flash. Since tea.ExecProcess takes over the screen, add the directory and the fallback reason to the claudeSessionEndedMsg text instead. Keep directory and fallback in the message.
Findings as JSON
{
"used": "Read the PR diff and tui/claude_session.go, repos/repos.go and the app.go message handlers. I did not run the failure paths.",
"standards": "code-quality.md § \"A result that can come back partial carries the signal that tells it from a whole one\". It came from `fleet standards search \"degraded fallback result indistinguishable from a whole one\"`.",
"findings": [
{
"number": "failure-paths.1",
"section": "correctness",
"sites": [
"tui/claude_session.go:68",
"tui/claude_session.go:62",
"tui/claude_session.go:77"
],
"reach": "A user presses C outside tmux on a project whose items name zero or several repos, or on an item whose repo has no `path` in the repos registry. This is the default case for any registry without a `path` field, since the field is new in this PR. I read the code and did not run it.",
"body": "**Outside tmux, the cwd fallback for a Claude session is silent.**\n\n`sessionDirectory` returns a `fallback` note when the repo is unnamed or the registry has no checkout for it. It opens the session in the board's own cwd.\n\nOnly the tmux branch shows that note (line 62). The non-tmux branch (lines 68-70) drops it and runs `claude` with `session.Dir` set to the cwd. The end message, `Back from the Claude session`, says nothing about the directory either.\n\nThe user cannot tell a session in the right checkout from one in an unrelated directory. The comment on `sessionDirectory` says a session must never plan against another repo's code. The cwd fallback breaks that guarantee whenever the cwd is a different repo.\n\nFix: show the note before the process takes the terminal. Alternatively, put the directory in the brief, or refuse as the missing-checkout case does. In `openClaudeSession`, give the non-tmux path the same flash. Since `tea.ExecProcess` takes over the screen, add the directory and the fallback reason to the `claudeSessionEndedMsg` text instead. Keep `directory` and `fallback` in the message."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — code-architecture
Used: I cloned the repo and checked out PR 21 at 766a221. I read all of tui/action_table.go, tui/modes.go, tui/claude_session.go, tui/without_terminal.go, repos/repos.go and cli/tui.go. I also read app.go's handleKey, its per-mode handlers and the status bar and overlay code, plus cli/data_source.go and service/service.go. I used rg to find every literal key case and every literal [Key] hint outside the table, every site that decides a view name, and every caller of repos.Load. I did not build or run anything, because every finding is about structure.
Standards: I cited tui.md "One action table can build a TUI's key bindings, its footer and its ? overlay" and code-quality.md "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site". I found both with fleet standards search.
Findings: 0 correctness · 3 design · 0 prose
Design and architecture
code-architecture.1 tui/action_table.go:40-52 · tui/app.go:1141-1155 · tui/app.go:1159-1214 · tui/app.go:1217-1250 · tui/app.go:1252-1258 · tui/app.go:1390-1404 · tui/app.go:1808-1863 · tui/app.go:1981 · tui/app.go:1995 · tui/app.go:2071-2073 · tui/app.go:2113-2115 · tui/projpick.go:160
Reach: Any key added to one of six modes costs two hand-kept edits: a case in its handler and a literal hint string. The six modes are filter, picker, help, dep-link/unlink, text input and confirm. This PR is that change for the normal and move modes, and it leaves the other six as the next case. The help overlay is already out of step: handleHelpKey closes on q, but tui/app.go:1995 says only "[Esc] or [?] Close".
The action table covers three of the board's nine input modes, and the other six still keep their keys in two hand-kept lists.
actions() switches on appMode, but it only knows modeNormal, modeMove and modeMoveProject. handleKey sends every other mode to its own string switch:
handleInputKeycovers add item, add project, add task, rename and drop.handlePickerKeycovers the project picker.handleHelpKeycovers the help overlay.handleFilterKeycovers the filter.handleDepLinkKeycovers dep-link and dep-unlink.
Each of these modes writes its hints as a literal string:
"[Enter]keep [Esc]clear"instatusBarHints." [Space] Toggle [Enter] Confirm [Esc] Cancel"in projpick.go."[Enter] Link [/] Filter [Esc] Cancel"for dep-link."[Esc] or [?] Close"for help.
That is the handler-plus-literal-footer pattern the PR body rejects as drifting "at the first key added". The drift is already visible in the help overlay's close hint, which leaves out q. tui.md § "One action table can build a TUI's key bindings, its footer and its ? overlay" asks that the handler and footer "none of them lists a key of its own". These six modes do.
Fix: give each mode a table and let actions() dispatch on every appMode:
- Add
filterActions,pickerActions,depLinkActions,inputActionsandhelpActions. - Give the text modes a fallthrough to
updateFocusedInputwhenrunKeyreports no binding. - Draw each overlay's hint line from
footerHints(), not from a literal.
code-architecture.2 cli/tui.go:39-40 · cli/tui.go:68 · tui/modes.go:13-16 · tui/without_terminal.go:21-32 · cli/schemas/board-snapshot.schema.json:21-27
Reach: Renaming a view, or adding a fourth, costs edits at four sites kept in step by hand: - The view const and the parallel viewNames and viewLabels slices in tui/modes.go. - cli/tui.go's viewDependencies copy. - The schema enum. If tui renames "dependencies", cli/tui.go:68 stops matching. --json --view <new name> then passes the guard and prints a snapshot of the projects, which the schema has no value for. This PR is the change that moved view names into tui, so the next view lands on this split.
The CLI keeps its own copy of a view name the tui package owns, and decides which views have a JSON snapshot.
tui/modes.go viewNames now owns the view names, and cli/tui.go reads them through tui.ViewNames() for validation and help text. Line 40 then re-declares const viewDependencies = "dependencies", which is the same name as tui's viewNames[viewDependencies] written a second time. Line 68 uses that copy to decide that one view has no snapshot.
The tui package decides what each view reads for a snapshot in Read and Shown, in tui/without_terminal.go. So the question "which views answer --json" belongs with tui's view set. The CLI answers it from a copied string. The schema enum ["projects","items"] is a third copy of the same subset, and no test ties it to ViewNames().
code-quality.md § "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site".
Fix: have tui export the subset, as tui.SnapshotViews() or (*App).HasSnapshot(). Then:
- Make cli/tui.go:68 ask that function and delete the const.
- Add a test that the schema's
viewenum equalsSnapshotViews().
Keeping one view table of {name, label, snapshot} structs in modes.go also removes the parallel-slice indexing.
code-architecture.3 tui/claude_session.go:76-83 · cli/data_source.go:191 · cli/data_source.go:211-219 · service/service.go:101-105 · tui/app.go:119-122
Reach: Any change to how a registry is chosen now edits the tui package, which is a second owner of that choice. Examples are honoring a per-deployment registry or an injected path in a test. cli/data_source.go:211-219 already treats this choice as mode-dependent, giving API mode an empty registry. The tui path skips that rule, so the next policy change has to be made in both places.
The board picks and loads the repo registry itself, past the data-source layer that owns that choice.
There are two sites that decide which registry a process reads:
- cli/data_source.go builds the service with
repos.DefaultPath()at line 191, and decides inrepoRegistry()that API mode gets an empty registry. - service/service.go reads only the path it was given.
The new sessionDirectory in tui/claude_session.go:78 calls repos.Load(repos.DefaultPath()) directly on every C press. So the presentation layer resolves config and environment to a file on its own. It does not get the registry the way it gets everything else, through NewApp(backend, Where).
Fix: have cli pass a lookup into the board, alongside DatabasePath and APIHost:
- Add
Checkout func(repo string) (string, bool)(or a*repos.Registry) totui.Where. - Build it in cli from the same source decision as
repoRegistry(). - Have
sessionDirectorycall the lookup it was handed.
Findings as JSON
{
"used": "I cloned the repo and checked out PR 21 at 766a221. I read all of tui/action_table.go, tui/modes.go, tui/claude_session.go, tui/without_terminal.go, repos/repos.go and cli/tui.go. I also read app.go's handleKey, its per-mode handlers and the status bar and overlay code, plus cli/data_source.go and service/service.go. I used rg to find every literal key case and every literal `[Key]` hint outside the table, every site that decides a view name, and every caller of repos.Load. I did not build or run anything, because every finding is about structure.",
"standards": "I cited tui.md \"One action table can build a TUI's key bindings, its footer and its ? overlay\" and code-quality.md \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\". I found both with `fleet standards search`.",
"findings": [
{
"number": "code-architecture.1",
"section": "design",
"sites": [
"tui/action_table.go:40-52",
"tui/app.go:1141-1155",
"tui/app.go:1159-1214",
"tui/app.go:1217-1250",
"tui/app.go:1252-1258",
"tui/app.go:1390-1404",
"tui/app.go:1808-1863",
"tui/app.go:1981",
"tui/app.go:1995",
"tui/app.go:2071-2073",
"tui/app.go:2113-2115",
"tui/projpick.go:160"
],
"reach": "Any key added to one of six modes costs two hand-kept edits: a `case` in its handler and a literal hint string. The six modes are filter, picker, help, dep-link/unlink, text input and confirm. This PR is that change for the normal and move modes, and it leaves the other six as the next case. The help overlay is already out of step: `handleHelpKey` closes on `q`, but tui/app.go:1995 says only \"[Esc] or [?] Close\".",
"body": "**The action table covers three of the board's nine input modes, and the other six still keep their keys in two hand-kept lists.**\n\n`actions()` switches on `appMode`, but it only knows `modeNormal`, `modeMove` and `modeMoveProject`. `handleKey` sends every other mode to its own string `switch`:\n- `handleInputKey` covers add item, add project, add task, rename and drop.\n- `handlePickerKey` covers the project picker.\n- `handleHelpKey` covers the help overlay.\n- `handleFilterKey` covers the filter.\n- `handleDepLinkKey` covers dep-link and dep-unlink.\n\nEach of these modes writes its hints as a literal string:\n- `\"[Enter]keep [Esc]clear\"` in `statusBarHints`.\n- `\" [Space] Toggle [Enter] Confirm [Esc] Cancel\"` in projpick.go.\n- `\"[Enter] Link [/] Filter [Esc] Cancel\"` for dep-link.\n- `\"[Esc] or [?] Close\"` for help.\n\nThat is the handler-plus-literal-footer pattern the PR body rejects as drifting \"at the first key added\". The drift is already visible in the help overlay's close hint, which leaves out `q`. tui.md § \"One action table can build a TUI's key bindings, its footer and its ? overlay\" asks that the handler and footer \"none of them lists a key of its own\". These six modes do.\n\n**Fix:** give each mode a table and let `actions()` dispatch on every `appMode`:\n- Add `filterActions`, `pickerActions`, `depLinkActions`, `inputActions` and `helpActions`.\n- Give the text modes a fallthrough to `updateFocusedInput` when `runKey` reports no binding.\n- Draw each overlay's hint line from `footerHints()`, not from a literal."
},
{
"number": "code-architecture.2",
"section": "design",
"sites": [
"cli/tui.go:39-40",
"cli/tui.go:68",
"tui/modes.go:13-16",
"tui/without_terminal.go:21-32",
"cli/schemas/board-snapshot.schema.json:21-27"
],
"reach": "Renaming a view, or adding a fourth, costs edits at four sites kept in step by hand:\n- The `view` const and the parallel `viewNames` and `viewLabels` slices in tui/modes.go.\n- cli/tui.go's `viewDependencies` copy.\n- The schema enum.\n\nIf tui renames \"dependencies\", cli/tui.go:68 stops matching. `--json --view <new name>` then passes the guard and prints a snapshot of the projects, which the schema has no value for. This PR is the change that moved view names into tui, so the next view lands on this split.",
"body": "**The CLI keeps its own copy of a view name the tui package owns, and decides which views have a JSON snapshot.**\n\ntui/modes.go `viewNames` now owns the view names, and cli/tui.go reads them through `tui.ViewNames()` for validation and help text. Line 40 then re-declares `const viewDependencies = \"dependencies\"`, which is the same name as tui's `viewNames[viewDependencies]` written a second time. Line 68 uses that copy to decide that one view has no snapshot.\n\nThe tui package decides what each view reads for a snapshot in `Read` and `Shown`, in tui/without_terminal.go. So the question \"which views answer `--json`\" belongs with tui's view set. The CLI answers it from a copied string. The schema enum `[\"projects\",\"items\"]` is a third copy of the same subset, and no test ties it to `ViewNames()`.\n\ncode-quality.md § \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\".\n\n**Fix:** have tui export the subset, as `tui.SnapshotViews()` or `(*App).HasSnapshot()`. Then:\n- Make cli/tui.go:68 ask that function and delete the const.\n- Add a test that the schema's `view` enum equals `SnapshotViews()`.\n\nKeeping one view table of `{name, label, snapshot}` structs in modes.go also removes the parallel-slice indexing."
},
{
"number": "code-architecture.3",
"section": "design",
"sites": [
"tui/claude_session.go:76-83",
"cli/data_source.go:191",
"cli/data_source.go:211-219",
"service/service.go:101-105",
"tui/app.go:119-122"
],
"reach": "Any change to how a registry is chosen now edits the tui package, which is a second owner of that choice. Examples are honoring a per-deployment registry or an injected path in a test. cli/data_source.go:211-219 already treats this choice as mode-dependent, giving API mode an empty registry. The tui path skips that rule, so the next policy change has to be made in both places.",
"body": "**The board picks and loads the repo registry itself, past the data-source layer that owns that choice.**\n\nThere are two sites that decide which registry a process reads:\n- cli/data_source.go builds the service with `repos.DefaultPath()` at line 191, and decides in `repoRegistry()` that API mode gets an empty registry.\n- service/service.go reads only the path it was given.\n\nThe new `sessionDirectory` in tui/claude_session.go:78 calls `repos.Load(repos.DefaultPath())` directly on every `C` press. So the presentation layer resolves config and environment to a file on its own. It does not get the registry the way it gets everything else, through `NewApp(backend, Where)`.\n\n**Fix:** have cli pass a lookup into the board, alongside `DatabasePath` and `APIHost`:\n- Add `Checkout func(repo string) (string, bool)` (or a `*repos.Registry`) to `tui.Where`.\n- Build it in cli from the same source decision as `repoRegistry()`.\n- Have `sessionDirectory` call the lookup it was handed."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — stale-prose
Used: I checked out PR 21 into my own clone and built the binary there, with HOME, XDG and the DB path set to directories I made. I ran projects tui --view nope and --json --view dependencies, which both exit 2 with the reason, and --json --view items, which answers "view": "items". I called sessionDirectory from a throwaway test with a registry naming a checkout that does not exist, then deleted the test. I read every sentence the diff adds in README.md, CLAUDE.md, cli/tui.go, the schema and the tui comments against the code at the head.
Standards: I applied pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite. I also cited documentation.md/A comment describes the code as it is, never the change or history that produced it, found with fleet standards search "comment describing prior state". The search "count written out where code owns it" found no closer rule.
Findings: 0 correctness · 0 design · 3 prose
Prose
stale-prose.1 README.md:215
False: the README says C falls back to the board's directory, but a named checkout that is missing opens no session.
README.md:215 reads: "It runs in the checkout the repo registry names for the item's repo tag, or for a project's one repo, and in the board's own directory otherwise."
sessionDirectory in tui/claude_session.go falls back to the working directory only when the registry names no checkout. When it names one that is not on disk, sessionDirectory returns an error and opens nothing. I called it with a registry of {"repos":[{"name":"r","path":"/nonexistent/checkout"}]} and got dir="" fallback="" err=r is not checked out at /nonexistent/checkout, so no session opened on 61. A reader of the README expects a session in the current directory, and gets an error flash.
Rewrite: "It runs in the checkout the repo registry names for the item's repo tag, or for a project's one repo. With no checkout named, it runs in the board's own directory and says so. A checkout named but missing opens no session, so a session never works against the wrong repo's code."
stale-prose.2 README.md:210
The change narrated: "d deletes nothing" makes sense only to someone who knew the old binding.
README.md:209-210 reads: "t adds a task, D deletes one, and d deletes nothing."
The action table at the head has no d row. The clause is there only because d used to delete a task, and the PR body's "Was / Now" table records exactly that. A reader without the diff has no reason to be told about a key that does nothing. This is prior state written into the file, under documentation.md § "A comment describes the code as it is, never the change or history that produced it".
Rewrite: "t adds a task, and D deletes one." The move/m-is-unbound golden already guards the unbound m. If the unbound d needs guarding, a test should do it, not a README sentence.
stale-prose.3 cli/tui.go:50-51 · README.md:165 · README.md:169
A value the code holds: the view count and the view names are written out where viewNames and viewLabels own them.
The sentences are:
- cli/tui.go:50-51 (
--helpLong): "Tab cycles\nthe three views, and --view opens on one." - README.md:165 (heading): "The TUI — three views, one key table, every key one press away".
- README.md:169 (key table): "Tab the next view: By project, All items, Dependencies".
tui/modes.go owns both the list and its order. --view's own flag help and its error message already build the names from tui.ViewNames(). My run of projects tui --view nope printed --view takes projects, items, dependencies. The sentences that hard-code the count or the list will be wrong the moment a fourth view is added.
Rewrites:
- cli/tui.go: "Tab cycles the views, and --view opens on one." The flag help already lists the names.
- README.md:165: "The TUI — views, one key table, every key one press away". Or drop the count and keep "one key table".
- README.md:169: "Tab the next view, in the order the header names them". The prose below can keep the bold By project, All items and Dependencies paragraphs. Each one describes its own view, so a new view adds a paragraph instead of making an existing one wrong.
Findings as JSON
{
"used": "I checked out PR 21 into my own clone and built the binary there, with HOME, XDG and the DB path set to directories I made. I ran `projects tui --view nope` and `--json --view dependencies`, which both exit 2 with the reason, and `--json --view items`, which answers `\"view\": \"items\"`. I called `sessionDirectory` from a throwaway test with a registry naming a checkout that does not exist, then deleted the test. I read every sentence the diff adds in README.md, CLAUDE.md, cli/tui.go, the schema and the tui comments against the code at the head.",
"standards": "I applied pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite. I also cited documentation.md/A comment describes the code as it is, never the change or history that produced it, found with `fleet standards search \"comment describing prior state\"`. The search \"count written out where code owns it\" found no closer rule.",
"findings": [
{
"number": "stale-prose.1",
"section": "prose",
"sites": [
"README.md:215"
],
"reach": "",
"body": "**False: the README says `C` falls back to the board's directory, but a named checkout that is missing opens no session.**\n\nREADME.md:215 reads: \"It runs in the checkout the repo registry names for the item's repo tag, or for a project's one repo, and in the board's own directory otherwise.\"\n\n`sessionDirectory` in `tui/claude_session.go` falls back to the working directory only when the registry names no checkout. When it names one that is not on disk, `sessionDirectory` returns an error and opens nothing. I called it with a registry of `{\"repos\":[{\"name\":\"r\",\"path\":\"/nonexistent/checkout\"}]}` and got `dir=\"\" fallback=\"\" err=r is not checked out at /nonexistent/checkout, so no session opened on 61`. A reader of the README expects a session in the current directory, and gets an error flash.\n\nRewrite: \"It runs in the checkout the repo registry names for the item's repo tag, or for a project's one repo. With no checkout named, it runs in the board's own directory and says so. A checkout named but missing opens no session, so a session never works against the wrong repo's code.\""
},
{
"number": "stale-prose.2",
"section": "prose",
"sites": [
"README.md:210"
],
"reach": "",
"body": "**The change narrated: \"`d` deletes nothing\" makes sense only to someone who knew the old binding.**\n\nREADME.md:209-210 reads: \"`t` adds a task, `D` deletes one, and `d` deletes nothing.\"\n\nThe action table at the head has no `d` row. The clause is there only because `d` used to delete a task, and the PR body's \"Was / Now\" table records exactly that. A reader without the diff has no reason to be told about a key that does nothing. This is prior state written into the file, under `documentation.md` § \"A comment describes the code as it is, never the change or history that produced it\".\n\nRewrite: \"`t` adds a task, and `D` deletes one.\" The `move/m-is-unbound` golden already guards the unbound `m`. If the unbound `d` needs guarding, a test should do it, not a README sentence."
},
{
"number": "stale-prose.3",
"section": "prose",
"sites": [
"cli/tui.go:50-51",
"README.md:165",
"README.md:169"
],
"reach": "",
"body": "**A value the code holds: the view count and the view names are written out where `viewNames` and `viewLabels` own them.**\n\nThe sentences are:\n- cli/tui.go:50-51 (`--help` Long): \"Tab cycles\\nthe three views, and --view opens on one.\"\n- README.md:165 (heading): \"The TUI — three views, one key table, every key one press away\".\n- README.md:169 (key table): \"Tab the next view: By project, All items, Dependencies\".\n\n`tui/modes.go` owns both the list and its order. `--view`'s own flag help and its error message already build the names from `tui.ViewNames()`. My run of `projects tui --view nope` printed `--view takes projects, items, dependencies`. The sentences that hard-code the count or the list will be wrong the moment a fourth view is added.\n\nRewrites:\n- cli/tui.go: \"Tab cycles the views, and --view opens on one.\" The flag help already lists the names.\n- README.md:165: \"The TUI — views, one key table, every key one press away\". Or drop the count and keep \"one key table\".\n- README.md:169: \"Tab the next view, in the order the header names them\". The prose below can keep the bold **By project**, **All items** and **Dependencies** paragraphs. Each one describes its own view, so a new view adds a paragraph instead of making an existing one wrong."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — contracts
Used: I built the PR head into my own directory, with config, data, state and the repo registry all pointed inside it. I seeded one project and one item through the CLI. Then I ran projects tui --help, tui --json with --view set to projects, items, dependencies, nope and empty, and tui --once --view items. I also read tui/action_table.go against tui.md, and searched every registry repo for callers of projects tui, the --json keys, Backend.Search, the projects Go packages and PROJECTS_REPOS_REGISTRY.
Standards: Cited tui.md § "A TUI key means the same thing in every fleet TUI" and tui.md § "A tool with a CLI opens its TUI as tui, one program with a tab per view". Also read cli-design.md § "A change to a --json shape sweeps the consumers own reads of the key, not only the flags they pass". No consumer reads the changed keys, so it raised no finding.
Findings: 0 correctness · 2 design · 0 prose
Design and architecture
contracts.1 tui/action_table.go:171 · tui/action_table.go:199 · tui/action_table.go:200 · fleet:views/inbox/keys.go:41
Reach: Anyone who moves between fleet's inbox board and projects tui reaches it. In fleet's inbox, n opens $EDITOR on the row's note (fleet:views/inbox/keys.go:41). In this board, n on an item or a project opens an inline rename prompt instead. The next fleet TUI that adds notes will follow the table, and this board will disagree with it too.
n renames here, but the fleet key table says n writes the row's note in $EDITOR.
The second table in tui.md § "A TUI key means the same thing in every fleet TUI" binds n to "writes a note on the row in $EDITOR". This PR binds it like this:
n→startRenameItem("rename the item") at tui/action_table.go:200.n→startRenameProjectat tui/action_table.go:171.- The item's note in
$EDITORgoes oneat tui/action_table.go:199.
The PR body's table states the swap: e was rename and is now the note, and n was the note and is now rename. Before this PR, n already did what the fleet table says, so the rebinding moves the board away from it.
So the letter now means two things across fleet TUIs:
- In fleet's inbox,
nedits a note in$VISUAL/$EDITOR. - In projects,
nopens a rename prompt on the same kind of row.
The rule's Why names this exact harm: a habit from one tool does something else in another.
Fix: keep n as "edit the item's note in $EDITOR", as it was. Move rename to a letter the fleet tables leave to each tool. e can stay on a project's description, because e "edits in $EDITOR". Update the README's key list (README.md:209) and the PR's table to match.
contracts.2 cli/tui.go:68 · cli/tui.go:40
Reach: An agent or web app that reads each view's snapshot reaches it. One that loops over the names --help lists (projects, items, dependencies) gets exit 2 on the third. No current caller passes --view dependencies with --json: I searched every registry repo and the only projects tui callers are an alias, docs and fleet test fixtures. The cost lands on the next consumer that treats --view as the list of snapshots.
--json refuses one of the views --view accepts.
tui.md § "A tool with a CLI opens its TUI as tui, one program with a tab per view" says --view <name> opens at a tab, and --json prints "that tab's snapshot". The head breaks that pairing for one view. I ran:
$ projects tui --json --view dependencies
error: the dependency map's --json is projects items dependencies tree --json
exit 2
--view dependencies is accepted, and --once --view dependencies is even the help's example. So --json now depends on which view you pick, and a script must special-case one name.
The PR's reason is that another command already prints the map. That reason fits a schema that says so. It does not fit a flag pair where one combination is a usage error.
Fix: let --json --view dependencies emit view: "dependencies" with the map's data, reusing the tree command's wire shape. Add "dependencies" to the view enum in cli/schemas/board-snapshot.schema.json. If that is deferred, list the exception in the --view flag help. The help now lists all three names as equal.
Findings as JSON
{
"used": "I built the PR head into my own directory, with config, data, state and the repo registry all pointed inside it. I seeded one project and one item through the CLI. Then I ran `projects tui --help`, `tui --json` with `--view` set to `projects`, `items`, `dependencies`, `nope` and empty, and `tui --once --view items`. I also read `tui/action_table.go` against `tui.md`, and searched every registry repo for callers of `projects tui`, the `--json` keys, `Backend.Search`, the `projects` Go packages and `PROJECTS_REPOS_REGISTRY`.",
"standards": "Cited `tui.md` § \"A TUI key means the same thing in every fleet TUI\" and `tui.md` § \"A tool with a CLI opens its TUI as <tool> tui, one program with a tab per view\". Also read `cli-design.md` § \"A change to a --json shape sweeps the consumers own reads of the key, not only the flags they pass\". No consumer reads the changed keys, so it raised no finding.",
"findings": [
{
"number": "contracts.1",
"section": "design",
"sites": [
"tui/action_table.go:171",
"tui/action_table.go:199",
"tui/action_table.go:200",
"fleet:views/inbox/keys.go:41"
],
"reach": "Anyone who moves between fleet's inbox board and `projects tui` reaches it. In fleet's inbox, `n` opens `$EDITOR` on the row's note (fleet:views/inbox/keys.go:41). In this board, `n` on an item or a project opens an inline rename prompt instead. The next fleet TUI that adds notes will follow the table, and this board will disagree with it too.",
"body": "**`n` renames here, but the fleet key table says `n` writes the row's note in `$EDITOR`.**\n\nThe second table in `tui.md` § \"A TUI key means the same thing in every fleet TUI\" binds `n` to \"writes a note on the row in `$EDITOR`\". This PR binds it like this:\n\n- `n` → `startRenameItem` (\"rename the item\") at tui/action_table.go:200.\n- `n` → `startRenameProject` at tui/action_table.go:171.\n- The item's note in `$EDITOR` goes on `e` at tui/action_table.go:199.\n\nThe PR body's table states the swap: `e` was rename and is now the note, and `n` was the note and is now rename. Before this PR, `n` already did what the fleet table says, so the rebinding moves the board away from it.\n\nSo the letter now means two things across fleet TUIs:\n\n- In fleet's inbox, `n` edits a note in `$VISUAL`/`$EDITOR`.\n- In projects, `n` opens a rename prompt on the same kind of row.\n\nThe rule's Why names this exact harm: a habit from one tool does something else in another.\n\n**Fix:** keep `n` as \"edit the item's note in `$EDITOR`\", as it was. Move rename to a letter the fleet tables leave to each tool. `e` can stay on a project's description, because `e` \"edits in `$EDITOR`\". Update the README's key list (README.md:209) and the PR's table to match."
},
{
"number": "contracts.2",
"section": "design",
"sites": [
"cli/tui.go:68",
"cli/tui.go:40"
],
"reach": "An agent or web app that reads each view's snapshot reaches it. One that loops over the names `--help` lists (`projects, items, dependencies`) gets exit 2 on the third. No current caller passes `--view dependencies` with `--json`: I searched every registry repo and the only `projects tui` callers are an alias, docs and fleet test fixtures. The cost lands on the next consumer that treats `--view` as the list of snapshots.",
"body": "**`--json` refuses one of the views `--view` accepts.**\n\n`tui.md` § \"A tool with a CLI opens its TUI as <tool> tui, one program with a tab per view\" says `--view <name>` opens at a tab, and `--json` prints \"that tab's snapshot\". The head breaks that pairing for one view. I ran:\n\n```text\n$ projects tui --json --view dependencies\nerror: the dependency map's --json is projects items dependencies tree --json\nexit 2\n```\n\n`--view dependencies` is accepted, and `--once --view dependencies` is even the help's example. So `--json` now depends on which view you pick, and a script must special-case one name.\n\nThe PR's reason is that another command already prints the map. That reason fits a schema that says so. It does not fit a flag pair where one combination is a usage error.\n\n**Fix:** let `--json --view dependencies` emit `view: \"dependencies\"` with the map's data, reusing the tree command's wire shape. Add `\"dependencies\"` to the `view` enum in `cli/schemas/board-snapshot.schema.json`. If that is deferred, list the exception in the `--view` flag help. The help now lists all three names as equal."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — fleet-patterns
Used: I checked out PR 21 at 766a221 and read its diff and body. I made shallow clones of gotermboard, slate, prs, fleet, forge, indy, goclikit and tuidrive. I diffed projects repos/repos.go against slate's cli/internal/repos/repos.go, compared tui/claude_session.go with slate's tui_initiatives_handovers.go, compared tui/action_table.go with gotermboard's actionTable, and checked the key bindings against slate's, prs's and fleet's boards. I built and ran nothing.
Standards: Cited: go.md/Ask the library to export what you would otherwise copy out of it; code-quality.md/Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site; tui.md/A TUI key means the same thing in every fleet TUI; tui.md/One action table can build a TUIs key bindings, its footer and its ? overlay; data.md/A registry field that selects what tools act on is required, never defaulted. Searches for a repo-registry reader rule and a Claude-session opener rule found nothing beyond these.
Findings: 0 correctness · 4 design · 0 prose
Design and architecture
fleet-patterns.1 tui/claude_session.go:54 · tui/claude_session.go:70 · tui/claude_session.go:76 · tui/app.go:922 · slate:cli/internal/cli/tui_initiatives_handovers.go:29 · slate:cli/internal/cli/tui_initiatives_handovers.go:54 · slate:cli/internal/cli/tui_initiatives_handovers.go:67 · gotermboard:hand_over.go:167
Reach: C on any item or project row in projects tui. The next fix to the session opener or its brief, made in slate or in projects, will be missed in the other. They already disagree on what a failed session reports outside tmux.
The Claude session opener is slate's code copied line for line, and the copies already disagree on errors.
openClaudeSession and sessionDirectory match slate's claude and sessionDirectory in these steps and messages:
- the
LookPath("claude")guard - the
TMUXbranch callinggotermboard.OpenWindow - the
os.Statrefusal of a missing checkout - the two fallback suffixes (", since %s names no one repo" and ", since the repo registry names no checkout of %s")
- the brief template ("Read it, say what it asks for and what you would do, then wait before changing anything.")
The outside-tmux path differs:
- slate hands the terminal over with
m.HandOverand reportsgotermboard.LastLine(stderr, err). A failedclaudeshows the last line it wrote. - projects uses
tea.ExecProcessand flashes%vof the error atapp.go:922. The same failure shows onlyexit status 1.
The PR body gives no reason for copying instead of sharing. code-quality.md § "Ask whatever owns a fact…" says: "Across two tools, the owner is a library both import."
Fix: move the opener into gotermboard, which already owns OpenWindow, HandOver and LastLine. Export it as a function that is not tied to *Model, taking a name, a directory and a brief, so that projects (a host that does not adopt Model) and slate both call it. The checkout lookup in sessionDirectory belongs with the shared registry reader in the next finding.
fleet-patterns.2 repos/repos.go:22 · repos/repos.go:85 · repos/repos.go:90 · slate:cli/internal/repos/repos.go:27 · slate:cli/internal/repos/repos.go:95 · slate:cli/internal/repos/repos.go:102 · prs:internal/registry/registry.go:43 · prs:internal/registry/registry.go:65 · fleet:registry/registry.go:39 · forge:config/syncer.go:50 · indy:src/indy/repos.py:41
Reach: sessionDirectory, through registry.Checkout, is the caller. Any change to how repos.json states a checkout lands in five Go parsers one at a time. These are status handling, owner-qualified names and tilde rules. The projects copy is already out of step on status.
Registry.Checkout was added as a copy of slate's, and the copy reads the registry differently from prs, fleet, forge and indy.
The diff of repos/repos.go against slate's cli/internal/repos/repos.go shows the two match except for comments and variable names. The new Path field, the checkouts map and Checkout are the same in both. The PR's change was made twice, once per repo.
The registry's path field is now parsed by five Go readers:
- projects
repos - slate
repos - prs
internal/registry - fleet
registry - forge
config
indy's repos.py reads it in Python as well.
They disagree on status:
- prs and indy refuse an entry with no
statusand keep onlyactiveentries. - projects and slate read every entry, so
Checkoutreturns a retired repo's path andValidateaccepts a retired name.
data.md § "A registry field that selects what tools act on is required, never defaulted" sides with prs and indy.
Fix: read the registry through one Go package that every tool imports, carrying Load, Checkout, Validate, Knows and the active-only filter. Have projects import it instead of extending its copy. Until that package exists, at least filter Checkout to status: active the way prs:internal/registry/registry.go:65 does.
fleet-patterns.3 tui/action_table.go:13 · tui/action_table.go:39 · tui/action_table.go:64 · tui/action_table.go:94 · tui/action_table.go:117 · tui/action_table.go:191 · gotermboard:model.go:504 · gotermboard:model.go:525 · gotermboard:model.go:563 · gotermboard:model.go:622 · gotermboard:model.go:1354
Reach: Every universal key in projects tui is involved: j k g G ctrl+d ctrl+u J K [ ] ? q. The next change to gotermboard's actionTable will not reach projects. gotermboard is already at v0.35.0 and this PR pins v0.31.0. The two tables already disagree on Enter.
The action-table machinery and the universal rows are retyped from gotermboard, and Enter already behaves differently.
tui/action_table.go rebuilds gotermboard's Action:
- the same fields: keys, label, footer hint, run, applies
- the same
actions()filter onApplies - the same footer and
?overlay built from the table
It also retypes gotermboard's grammar rows with the same labels, for example "half a screen down the open note, or down the list" against gotermboard's "half a screen down the open text, or down the rows".
The PR body explains why it does not adopt gotermboard's Model, because the board keeps its own look. It gives no reason for not asking gotermboard to export the table so a host can use it. go.md § "Ask the library to export what you would otherwise copy out of it" covers that case.
The copies already differ:
- projects: Enter on an item always opens the full-screen reader (
action_table.go:191). - gotermboard: Enter opens the row in place and goes full screen only where the document is tall (
model.go:563). tui.md§ "A TUI key means the same thing in every fleet TUI": Enter opens "in place when it is a few lines, full screen when it can run past the screen".
Fix: ask gotermboard to export a host-agnostic action type and its universal rows. One way is a generic Action[H] with rows written against a small host interface (move by, half screen, scroll by, on open text). projects would then append only its own rows. Enter would follow the shared rule because it would come from the shared row.
fleet-patterns.4 tui/action_table.go:171 · tui/action_table.go:200 · fleet:views/inbox/keys.go:41
Reach: n on an item or project row in projects tui. A user coming from fleet's inbox, where n writes a note in $EDITOR, gets a rename prompt here. Only one other repo binds n, so the evidence is weak. The two already disagree.
This PR rebinds n to rename, against the fleet key table and fleet's inbox.
Before this PR, n on an item edited its note in $EDITOR. This PR moves the note to e and gives n the rename prompt on both items and projects.
tui.md § "A TUI key means the same thing in every fleet TUI" reserves n as "writes a note on the row in $EDITOR". fleet:views/inbox/keys.go:41 binds it that way ("write a note on the row in $VISUAL or $EDITOR").
The PR body's decision ("e edits in $EDITOR, n renames") does not mention the table's n row.
Fix: keep n on the item's note in $EDITOR. Moving the note to e is fine, since the table also gives e to editing in $EDITOR. Put rename on a letter neither table names, which the standard leaves to each tool.
Findings as JSON
{
"used": "I checked out PR 21 at 766a221 and read its diff and body. I made shallow clones of gotermboard, slate, prs, fleet, forge, indy, goclikit and tuidrive. I diffed projects `repos/repos.go` against slate's `cli/internal/repos/repos.go`, compared `tui/claude_session.go` with slate's `tui_initiatives_handovers.go`, compared `tui/action_table.go` with gotermboard's `actionTable`, and checked the key bindings against slate's, prs's and fleet's boards. I built and ran nothing.",
"standards": "Cited: go.md/Ask the library to export what you would otherwise copy out of it; code-quality.md/Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site; tui.md/A TUI key means the same thing in every fleet TUI; tui.md/One action table can build a TUIs key bindings, its footer and its ? overlay; data.md/A registry field that selects what tools act on is required, never defaulted. Searches for a repo-registry reader rule and a Claude-session opener rule found nothing beyond these.",
"findings": [
{
"number": "fleet-patterns.1",
"section": "design",
"sites": [
"tui/claude_session.go:54",
"tui/claude_session.go:70",
"tui/claude_session.go:76",
"tui/app.go:922",
"slate:cli/internal/cli/tui_initiatives_handovers.go:29",
"slate:cli/internal/cli/tui_initiatives_handovers.go:54",
"slate:cli/internal/cli/tui_initiatives_handovers.go:67",
"gotermboard:hand_over.go:167"
],
"reach": "`C` on any item or project row in `projects tui`. The next fix to the session opener or its brief, made in slate or in projects, will be missed in the other. They already disagree on what a failed session reports outside tmux.",
"body": "**The Claude session opener is slate's code copied line for line, and the copies already disagree on errors.**\n\n`openClaudeSession` and `sessionDirectory` match slate's `claude` and `sessionDirectory` in these steps and messages:\n- the `LookPath(\"claude\")` guard\n- the `TMUX` branch calling `gotermboard.OpenWindow`\n- the `os.Stat` refusal of a missing checkout\n- the two fallback suffixes (\", since %s names no one repo\" and \", since the repo registry names no checkout of %s\")\n- the brief template (\"Read it, say what it asks for and what you would do, then wait before changing anything.\")\n\nThe outside-tmux path differs:\n- slate hands the terminal over with `m.HandOver` and reports `gotermboard.LastLine(stderr, err)`. A failed `claude` shows the last line it wrote.\n- projects uses `tea.ExecProcess` and flashes `%v` of the error at `app.go:922`. The same failure shows only `exit status 1`.\n\nThe PR body gives no reason for copying instead of sharing. `code-quality.md` § \"Ask whatever owns a fact…\" says: \"Across two tools, the owner is a library both import.\"\n\n**Fix:** move the opener into gotermboard, which already owns `OpenWindow`, `HandOver` and `LastLine`. Export it as a function that is not tied to `*Model`, taking a name, a directory and a brief, so that projects (a host that does not adopt `Model`) and slate both call it. The checkout lookup in `sessionDirectory` belongs with the shared registry reader in the next finding."
},
{
"number": "fleet-patterns.2",
"section": "design",
"sites": [
"repos/repos.go:22",
"repos/repos.go:85",
"repos/repos.go:90",
"slate:cli/internal/repos/repos.go:27",
"slate:cli/internal/repos/repos.go:95",
"slate:cli/internal/repos/repos.go:102",
"prs:internal/registry/registry.go:43",
"prs:internal/registry/registry.go:65",
"fleet:registry/registry.go:39",
"forge:config/syncer.go:50",
"indy:src/indy/repos.py:41"
],
"reach": "`sessionDirectory`, through `registry.Checkout`, is the caller. Any change to how `repos.json` states a checkout lands in five Go parsers one at a time. These are status handling, owner-qualified names and tilde rules. The projects copy is already out of step on status.",
"body": "**`Registry.Checkout` was added as a copy of slate's, and the copy reads the registry differently from prs, fleet, forge and indy.**\n\nThe diff of `repos/repos.go` against slate's `cli/internal/repos/repos.go` shows the two match except for comments and variable names. The new `Path` field, the `checkouts` map and `Checkout` are the same in both. The PR's change was made twice, once per repo.\n\nThe registry's `path` field is now parsed by five Go readers:\n- projects `repos`\n- slate `repos`\n- prs `internal/registry`\n- fleet `registry`\n- forge `config`\n\nindy's `repos.py` reads it in Python as well.\n\nThey disagree on `status`:\n- prs and indy refuse an entry with no `status` and keep only `active` entries.\n- projects and slate read every entry, so `Checkout` returns a retired repo's path and `Validate` accepts a retired name.\n\n`data.md` § \"A registry field that selects what tools act on is required, never defaulted\" sides with prs and indy.\n\n**Fix:** read the registry through one Go package that every tool imports, carrying `Load`, `Checkout`, `Validate`, `Knows` and the active-only filter. Have projects import it instead of extending its copy. Until that package exists, at least filter `Checkout` to `status: active` the way `prs:internal/registry/registry.go:65` does."
},
{
"number": "fleet-patterns.3",
"section": "design",
"sites": [
"tui/action_table.go:13",
"tui/action_table.go:39",
"tui/action_table.go:64",
"tui/action_table.go:94",
"tui/action_table.go:117",
"tui/action_table.go:191",
"gotermboard:model.go:504",
"gotermboard:model.go:525",
"gotermboard:model.go:563",
"gotermboard:model.go:622",
"gotermboard:model.go:1354"
],
"reach": "Every universal key in `projects tui` is involved: `j` `k` `g` `G` `ctrl+d` `ctrl+u` `J` `K` `[` `]` `?` `q`. The next change to gotermboard's `actionTable` will not reach projects. gotermboard is already at v0.35.0 and this PR pins v0.31.0. The two tables already disagree on Enter.",
"body": "**The action-table machinery and the universal rows are retyped from gotermboard, and Enter already behaves differently.**\n\n`tui/action_table.go` rebuilds gotermboard's `Action`:\n- the same fields: keys, label, footer hint, run, applies\n- the same `actions()` filter on `Applies`\n- the same footer and `?` overlay built from the table\n\nIt also retypes gotermboard's grammar rows with the same labels, for example \"half a screen down the open note, or down the list\" against gotermboard's \"half a screen down the open text, or down the rows\".\n\nThe PR body explains why it does not adopt gotermboard's `Model`, because the board keeps its own look. It gives no reason for not asking gotermboard to export the table so a host can use it. `go.md` § \"Ask the library to export what you would otherwise copy out of it\" covers that case.\n\nThe copies already differ:\n- **projects:** Enter on an item always opens the full-screen reader (`action_table.go:191`).\n- **gotermboard:** Enter opens the row in place and goes full screen only where the document is tall (`model.go:563`).\n- **`tui.md` § \"A TUI key means the same thing in every fleet TUI\":** Enter opens \"in place when it is a few lines, full screen when it can run past the screen\".\n\n**Fix:** ask gotermboard to export a host-agnostic action type and its universal rows. One way is a generic `Action[H]` with rows written against a small host interface (move by, half screen, scroll by, on open text). projects would then append only its own rows. Enter would follow the shared rule because it would come from the shared row."
},
{
"number": "fleet-patterns.4",
"section": "design",
"sites": [
"tui/action_table.go:171",
"tui/action_table.go:200",
"fleet:views/inbox/keys.go:41"
],
"reach": "`n` on an item or project row in `projects tui`. A user coming from fleet's inbox, where `n` writes a note in `$EDITOR`, gets a rename prompt here. Only one other repo binds `n`, so the evidence is weak. The two already disagree.",
"body": "**This PR rebinds `n` to rename, against the fleet key table and fleet's inbox.**\n\nBefore this PR, `n` on an item edited its note in `$EDITOR`. This PR moves the note to `e` and gives `n` the rename prompt on both items and projects.\n\n`tui.md` § \"A TUI key means the same thing in every fleet TUI\" reserves `n` as \"writes a note on the row in `$EDITOR`\". `fleet:views/inbox/keys.go:41` binds it that way (\"write a note on the row in $VISUAL or $EDITOR\").\n\nThe PR body's decision (\"`e` edits in `$EDITOR`, `n` renames\") does not mention the table's `n` row.\n\n**Fix:** keep `n` on the item's note in `$EDITOR`. Moving the note to `e` is fine, since the table also gives `e` to editing in `$EDITOR`. Put rename on a letter neither table names, which the standard leaves to each tool."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — useless-test
Used: I cloned PR 21 and checked out 766a221. I read every edited test and every flow script next to tui/action_table.go and tui/app.go. I ran go test -count=1 ./... with HOME and XDG_CONFIG_HOME pointed into my directory and PROJECTS_DB unset: tui and repos passed, and TestRealBinaryFlows passed in 319s. The service and store/postgres failures come from SQLite lock contention and the missing Docker, not from this diff. I scripted a check for goldens that no frame line names, and found none.
Standards: testing.md § "Test behavior the user cares about, not what this commit happened to change"; pull-requests.md § "Tests the diff adds are checked for whether they test the program or the change"; testing.md § "Never assert on rendered output — assert the value it was built from".
Findings: 0 correctness · 2 design · 0 prose
Design and architecture
useless-test.1 testdata/flows/move.keys:7 · testdata/flows/move.keys:11 · testdata/TestRealBinaryFlows/move/m-is-unbound.txt:1 · testdata/TestRealBinaryFlows/move/m-is-unbound.style:1 · testdata/flows/add-task.keys:1 · testdata/flows/add-task.keys:22
Reach: TestRealBinaryFlows runs both scripts on every go test of the root package (move and add-task subtests). They fail only if someone binds m or d again on purpose.
Two flow steps only check that this diff unbound a key it used to bind. This is the shape "it asserts the diff was made" from testing.md § "Test behavior the user cares about, not what this commit happened to change". Nobody who knew the board but not this change would write "pressing m does nothing".
Delete each of these:
moveflow,testdata/flows/move.keyslines 7–12:press m,press j,wait /> +✓ Dump the database/,gone MOVING,frame m-is-unbound,press k. Also delete the goldensmove/m-is-unbound.txtandmove/m-is-unbound.style, and the "m is unbound, so it changes nothing" half of the header comment.add-taskflow,testdata/flows/add-task.keysline 22:press d. Also delete "d deletes nothing." from the header on line 1.
No replacement is needed:
- The
grabbed,carried-down,droppedandput-backframes in the same flow pin moving. - The
task-deletedframe andwait [1/3]pin deletion withD. jmoving the cursor is pinned bybrowse/cursor-on-task.
Keeping these steps freezes "m and d are free", even though the PR body says those letters are being left free for later use.
useless-test.2 testdata/flows/help.keys:7 · testdata/flows/help.keys:15 · testdata/flows/help.keys:23 · testdata/flows/help.keys:32 · testdata/TestRealBinaryFlows/help-taller-than-the-window/from-a-task.txt:1 · testdata/TestRealBinaryFlows/help-taller-than-the-window/while-moving.txt:1 · testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.txt:1 · testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.txt:1
Reach: TestRealBinaryFlows/help-taller-than-the-window runs on every go test of the root package. Each golden holds every label string typed into projectActions, itemActions, taskActions, viewActions, everywhereActions and moveActions in tui/action_table.go. It also waits on the section constants On a task and Moving a row. Rewording any one label fails up to four frames.
The ? overlay goldens snapshot help prose that the action table declares. helpLines (tui/action_table.go:94) prints each action's label literal next to keysText(a.keys). The while-moving frame is moveActions() printed row by row, and that table is static, with no applies at all. This is the help-text shape testing.md § "Never assert on rendered output — assert the value it was built from" names. The overlay's wording belongs in the diff review, not in a golden.
Delete these frames:
from-the-project-panefrom-the-item-panefrom-a-taskwhile-moving
Delete their .txt and .style goldens with them, which leaves help.keys with nothing to check.
The frames do pin two real behaviors that no other test pins, so replace them with:
- Which actions apply to the row. Add a
tuiunit test that sets each context and asserts the key setm.actions()returns. The contexts are: a project row, an item row, a task row, and a grabbed row (modeMove). On a task row the set must includeDand excludee,nandx. While grabbed it must be exactlyj k g G enter esc ? ctrl+c. Then press a key from outside the set throughsendand assert nothing changed. For example,qwhile grabbed must leave the app running inmodeMove. This testsrunKey, which reads the same table, instead of the overlay's print of it. - An overlay taller than the window keeps its bottom rows.
Viewcuts the screen withlastRowsso the close hint stays visible, and no unit test covers that. Open?in a 24-row window and assert thatlipgloss.Heightofapp.View().Contentis at mostapp.height. Also assert that its last row is the last row ofapp.screen().
Findings as JSON
{
"used": "I cloned PR 21 and checked out 766a221. I read every edited test and every flow script next to `tui/action_table.go` and `tui/app.go`. I ran `go test -count=1 ./...` with HOME and XDG_CONFIG_HOME pointed into my directory and PROJECTS_DB unset: `tui` and `repos` passed, and `TestRealBinaryFlows` passed in 319s. The `service` and `store/postgres` failures come from SQLite lock contention and the missing Docker, not from this diff. I scripted a check for goldens that no `frame` line names, and found none.",
"standards": "testing.md § \"Test behavior the user cares about, not what this commit happened to change\"; pull-requests.md § \"Tests the diff adds are checked for whether they test the program or the change\"; testing.md § \"Never assert on rendered output — assert the value it was built from\".",
"findings": [
{
"number": "useless-test.1",
"section": "design",
"sites": [
"testdata/flows/move.keys:7",
"testdata/flows/move.keys:11",
"testdata/TestRealBinaryFlows/move/m-is-unbound.txt:1",
"testdata/TestRealBinaryFlows/move/m-is-unbound.style:1",
"testdata/flows/add-task.keys:1",
"testdata/flows/add-task.keys:22"
],
"reach": "`TestRealBinaryFlows` runs both scripts on every `go test` of the root package (`move` and `add-task` subtests). They fail only if someone binds `m` or `d` again on purpose.",
"body": "**Two flow steps only check that this diff unbound a key it used to bind.** This is the shape \"it asserts the diff was made\" from `testing.md` § \"Test behavior the user cares about, not what this commit happened to change\". Nobody who knew the board but not this change would write \"pressing `m` does nothing\".\n\nDelete each of these:\n\n- `move` flow, `testdata/flows/move.keys` lines 7–12: `press m`, `press j`, `wait /> +✓ Dump the database/`, `gone MOVING`, `frame m-is-unbound`, `press k`. Also delete the goldens `move/m-is-unbound.txt` and `move/m-is-unbound.style`, and the \"m is unbound, so it changes nothing\" half of the header comment.\n- `add-task` flow, `testdata/flows/add-task.keys` line 22: `press d`. Also delete \"d deletes nothing.\" from the header on line 1.\n\nNo replacement is needed:\n\n- The `grabbed`, `carried-down`, `dropped` and `put-back` frames in the same flow pin moving.\n- The `task-deleted` frame and `wait [1/3]` pin deletion with `D`.\n- `j` moving the cursor is pinned by `browse/cursor-on-task`.\n\nKeeping these steps freezes \"`m` and `d` are free\", even though the PR body says those letters are being left free for later use."
},
{
"number": "useless-test.2",
"section": "design",
"sites": [
"testdata/flows/help.keys:7",
"testdata/flows/help.keys:15",
"testdata/flows/help.keys:23",
"testdata/flows/help.keys:32",
"testdata/TestRealBinaryFlows/help-taller-than-the-window/from-a-task.txt:1",
"testdata/TestRealBinaryFlows/help-taller-than-the-window/while-moving.txt:1",
"testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.txt:1",
"testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.txt:1"
],
"reach": "`TestRealBinaryFlows/help-taller-than-the-window` runs on every `go test` of the root package. Each golden holds every `label` string typed into `projectActions`, `itemActions`, `taskActions`, `viewActions`, `everywhereActions` and `moveActions` in `tui/action_table.go`. It also waits on the section constants `On a task` and `Moving a row`. Rewording any one label fails up to four frames.",
"body": "**The `?` overlay goldens snapshot help prose that the action table declares.** `helpLines` (`tui/action_table.go:94`) prints each action's `label` literal next to `keysText(a.keys)`. The `while-moving` frame is `moveActions()` printed row by row, and that table is static, with no `applies` at all. This is the help-text shape `testing.md` § \"Never assert on rendered output — assert the value it was built from\" names. The overlay's wording belongs in the diff review, not in a golden.\n\nDelete these frames:\n\n- `from-the-project-pane`\n- `from-the-item-pane`\n- `from-a-task`\n- `while-moving`\n\nDelete their `.txt` and `.style` goldens with them, which leaves `help.keys` with nothing to check.\n\nThe frames do pin two real behaviors that no other test pins, so replace them with:\n\n1. **Which actions apply to the row.** Add a `tui` unit test that sets each context and asserts the key set `m.actions()` returns. The contexts are: a project row, an item row, a task row, and a grabbed row (`modeMove`). On a task row the set must include `D` and exclude `e`, `n` and `x`. While grabbed it must be exactly `j k g G enter esc ? ctrl+c`. Then press a key from outside the set through `send` and assert nothing changed. For example, `q` while grabbed must leave the app running in `modeMove`. This tests `runKey`, which reads the same table, instead of the overlay's print of it.\n2. **An overlay taller than the window keeps its bottom rows.** `View` cuts the screen with `lastRows` so the close hint stays visible, and no unit test covers that. Open `?` in a 24-row window and assert that `lipgloss.Height` of `app.View().Content` is at most `app.height`. Also assert that its last row is the last row of `app.screen()`."
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — G1
Used: Built the branch and main side by side. Ran go test -short ./...: tui passed, and service TestAnEditSurvivesASecondWriter hit SQLITE_BUSY on a loaded box, which this diff does not touch. Ran go test -run TestRealBinaryFlows ., which passed. Drove projects tui in a private tmux server over a scratch SQLite database: views, filter, M, notes in place, the reader, c, R, and C with a stand-in claude and a scratch repo registry. Ran projects tui --json/--once --view items|dependencies.
Standards: Cited tui.md § "A tool with a CLI opens its TUI as <tool> tui, one program with a tab per view". Searches on cursor identity across a reread, telling the user about a fallback, and schema enum versioning found no other rule that reaches these findings.
Findings: 4 correctness · 0 design · 1 prose
Correctness
G1.1 tui/item_rows.go:125 · tui/item_rows.go:132 · tui/app.go:1052
Reach: Any item filed in two projects, shown in All items or under two marked projects. The cursor jumps on every c, R, task toggle or fold, and in API mode on the reread timer with no key pressed.
The cursor leaves the second copy of a multi-project item on every reread. keyOf names an item row item:<id> and a task row task:<id>. An item in two projects shows two rows with the same key, and rebuildRows(keep) lands on the first.
Ran: All items on a database where "Pick tile" is in Garden and Kitchen remodel. Put the cursor on the Kitchen remodel copy and pressed c. The cursor came back on the Garden copy. Pressed c again on the second copy, then R. Same jump. In API mode rereadTickMsg runs the same path, so the cursor moves under a user who pressed nothing. The next c, x or space then acts on a row in another group.
main kept the cursor's row index and never jumped. The id key is new here.
Fix: key grouped rows by their group too, e.g. item:<projectID>/<id> and task:<projectID>/<taskID>. Pin it with an app_test.go case that presses c on the second copy and asserts the cursor's group.
G1.2 tui/without_terminal.go:46 · cli/tui.go:119 · cli/schemas/board-snapshot.schema.json:135
Reach: projects tui --json --view items, which this PR adds, on any database where one item sits in two projects or two projects hold items.
--json --view items drops the grouping the frame shows. Shown returns m.items flat. No item carries the project it is listed under, and the snapshot has no groups.
Ran on a 5-item database: projects tui --json --view items | jq -c '.items[] | {title, position}' printed 6 rows. "Pick tile" appeared twice, both at position: 0, and positions restart at 0 for each project with nothing marking the boundary. The --once frame of the same view shows a ── Garden ── ○2 ✓0 header over each group.
The schema's item definition still says "An item as one project holds it" without saying which project. .items | length overcounts, and an agent cannot tell which project an item or a position belongs to.
tui.md § "A tool with a CLI opens its TUI as <tool> tui, one program with a tab per view" says --json exists "so an agent reads what a person reads".
Fix: add the group's project_id and project name to each item in the items view, or emit groups: [{project, items}], and say so in the schema.
G1.3 tui/claude_session.go:58 · tui/claude_session.go:67
Reach: C outside tmux on any item or project whose repo has no path in the registry. That includes every entry shaped like the README's own registry example, { "repos": [{ "name": "projects" }] }.
Outside tmux, C falls back to the board's directory without saying so. sessionDirectory returns a fallback reason, but only the tmux branch flashes it. The tea.ExecProcess branch drops it and hands the terminal to Claude in the current directory. On return, the flash says only "Back from the Claude session on 2".
Ran with a stand-in claude on PATH, TMUX unset, on item 2, which has no repo tag. The stand-in logged cwd=<board's directory>, and no message ever named the fallback. The PR body says "A registry without it opens in the current directory and says so". That holds only inside tmux.
The brief tells Claude to plan the work. A session started in the wrong checkout plans against the wrong code.
Fix: carry fallback into claudeSessionEndedMsg and flash it on return. Better, show it before the exec. Or refuse outside tmux when there is a repo tag and no checkout, as the missing-checkout case already refuses.
G1.4 tui/app.go:1996 · tui/action_table.go:168 · tui/action_table.go:127
Reach: Every ? from the project pane. Every ? from an item or task row too, via the [ label. The overlay is capped at 70 columns, so the window size changes nothing.
? wraps two labels onto a line of their own, under the key column. overlayBox(..., 50, 70) caps the box at 70 columns. padRight(keys, 18) plus a label longer than about 50 characters wraps the label's last word to the box's left edge, where keys go.
The goldens accept two such lines. help-taller-than-the-window/from-the-project-pane.txt shows space mark the project, to list several projects' with items alone on the next line. from-a-task.txt shows tasks alone the same way, from "jump to the previous item or group, skipping tasks". A reader scanning the key column reads items and tasks as keys.
Fix: indent wrapped label lines to the label column, e.g. wrap the label at inside-20 and prefix continuation lines with 20 spaces. Or shorten the labels to fit ("mark the project, to list several", "jump to the previous item or group").
Prose
G1.5 CLAUDE.md:172
CLAUDE.md overstates what the action table covers. It says: "Every key the board takes is a row in tui/action_table.go. The key handler, the status bar's hints and the ? overlay all read it". Only modeNormal, modeMove and modeMoveProject go through runKey. These modes keep literal switch keys: handleInputKey, handlePickerKey, handleHelpKey, handleFilterKey and handleDepLinkKey. statusBarHints hard-codes [Enter]keep [Esc]clear, and the hosted reader takes its own keys. A contributor adding a key to the picker or the filter prompt will look for its row and not find one.
Rewrite: "Every key the board takes on its lists, the dependency map and a grabbed row is a row in tui/action_table.go. The key handler, the status bar's hints and the ? overlay read it, and a row's applies keeps it out of all three where it does not act. A prompt, the project picker, the link overlay and the hosted reader take their own keys."
Findings as JSON
{
"used": "Built the branch and `main` side by side. Ran `go test -short ./...`: `tui` passed, and `service` `TestAnEditSurvivesASecondWriter` hit SQLITE_BUSY on a loaded box, which this diff does not touch. Ran `go test -run TestRealBinaryFlows .`, which passed. Drove `projects tui` in a private tmux server over a scratch SQLite database: views, filter, `M`, notes in place, the reader, `c`, `R`, and `C` with a stand-in `claude` and a scratch repo registry. Ran `projects tui --json/--once --view items|dependencies`.",
"standards": "Cited tui.md § \"A tool with a CLI opens its TUI as `<tool> tui`, one program with a tab per view\". Searches on cursor identity across a reread, telling the user about a fallback, and schema enum versioning found no other rule that reaches these findings.",
"findings": [
{
"number": "G1.1",
"section": "correctness",
"sites": [
"tui/item_rows.go:125",
"tui/item_rows.go:132",
"tui/app.go:1052"
],
"reach": "Any item filed in two projects, shown in All items or under two marked projects. The cursor jumps on every `c`, `R`, task toggle or fold, and in API mode on the reread timer with no key pressed.",
"body": "**The cursor leaves the second copy of a multi-project item on every reread.** `keyOf` names an item row `item:<id>` and a task row `task:<id>`. An item in two projects shows two rows with the same key, and `rebuildRows(keep)` lands on the first.\n\nRan: All items on a database where \"Pick tile\" is in Garden and Kitchen remodel. Put the cursor on the Kitchen remodel copy and pressed `c`. The cursor came back on the Garden copy. Pressed `c` again on the second copy, then `R`. Same jump. In API mode `rereadTickMsg` runs the same path, so the cursor moves under a user who pressed nothing. The next `c`, `x` or `space` then acts on a row in another group.\n\n`main` kept the cursor's row index and never jumped. The id key is new here.\n\nFix: key grouped rows by their group too, e.g. `item:<projectID>/<id>` and `task:<projectID>/<taskID>`. Pin it with an `app_test.go` case that presses `c` on the second copy and asserts the cursor's group."
},
{
"number": "G1.2",
"section": "correctness",
"sites": [
"tui/without_terminal.go:46",
"cli/tui.go:119",
"cli/schemas/board-snapshot.schema.json:135"
],
"reach": "`projects tui --json --view items`, which this PR adds, on any database where one item sits in two projects or two projects hold items.",
"body": "**`--json --view items` drops the grouping the frame shows.** `Shown` returns `m.items` flat. No item carries the project it is listed under, and the snapshot has no groups.\n\nRan on a 5-item database: `projects tui --json --view items | jq -c '.items[] | {title, position}'` printed 6 rows. \"Pick tile\" appeared twice, both at `position: 0`, and positions restart at 0 for each project with nothing marking the boundary. The `--once` frame of the same view shows a `── Garden ── ○2 ✓0` header over each group.\n\nThe schema's item definition still says \"An item as one project holds it\" without saying which project. `.items | length` overcounts, and an agent cannot tell which project an item or a position belongs to.\n\ntui.md § \"A tool with a CLI opens its TUI as `<tool> tui`, one program with a tab per view\" says `--json` exists \"so an agent reads what a person reads\".\n\nFix: add the group's `project_id` and project name to each item in the items view, or emit `groups: [{project, items}]`, and say so in the schema."
},
{
"number": "G1.3",
"section": "correctness",
"sites": [
"tui/claude_session.go:58",
"tui/claude_session.go:67"
],
"reach": "`C` outside tmux on any item or project whose repo has no `path` in the registry. That includes every entry shaped like the README's own registry example, `{ \"repos\": [{ \"name\": \"projects\" }] }`.",
"body": "**Outside tmux, `C` falls back to the board's directory without saying so.** `sessionDirectory` returns a `fallback` reason, but only the tmux branch flashes it. The `tea.ExecProcess` branch drops it and hands the terminal to Claude in the current directory. On return, the flash says only \"Back from the Claude session on 2\".\n\nRan with a stand-in `claude` on `PATH`, `TMUX` unset, on item 2, which has no repo tag. The stand-in logged `cwd=<board's directory>`, and no message ever named the fallback. The PR body says \"A registry without it opens in the current directory and says so\". That holds only inside tmux.\n\nThe brief tells Claude to plan the work. A session started in the wrong checkout plans against the wrong code.\n\nFix: carry `fallback` into `claudeSessionEndedMsg` and flash it on return. Better, show it before the exec. Or refuse outside tmux when there is a repo tag and no checkout, as the missing-checkout case already refuses."
},
{
"number": "G1.4",
"section": "correctness",
"sites": [
"tui/app.go:1996",
"tui/action_table.go:168",
"tui/action_table.go:127"
],
"reach": "Every `?` from the project pane. Every `?` from an item or task row too, via the `[` label. The overlay is capped at 70 columns, so the window size changes nothing.",
"body": "**`?` wraps two labels onto a line of their own, under the key column.** `overlayBox(..., 50, 70)` caps the box at 70 columns. `padRight(keys, 18)` plus a label longer than about 50 characters wraps the label's last word to the box's left edge, where keys go.\n\nThe goldens accept two such lines. `help-taller-than-the-window/from-the-project-pane.txt` shows `space mark the project, to list several projects'` with `items` alone on the next line. `from-a-task.txt` shows `tasks` alone the same way, from \"jump to the previous item or group, skipping tasks\". A reader scanning the key column reads `items` and `tasks` as keys.\n\nFix: indent wrapped label lines to the label column, e.g. wrap the label at `inside-20` and prefix continuation lines with 20 spaces. Or shorten the labels to fit (\"mark the project, to list several\", \"jump to the previous item or group\")."
},
{
"number": "G1.5",
"section": "prose",
"sites": [
"CLAUDE.md:172"
],
"reach": "",
"body": "**CLAUDE.md overstates what the action table covers.** It says: \"**Every key the board takes is a row in `tui/action_table.go`.** The key handler, the status bar's hints and the `?` overlay all read it\". Only `modeNormal`, `modeMove` and `modeMoveProject` go through `runKey`. These modes keep literal `switch` keys: `handleInputKey`, `handlePickerKey`, `handleHelpKey`, `handleFilterKey` and `handleDepLinkKey`. `statusBarHints` hard-codes `[Enter]keep [Esc]clear`, and the hosted reader takes its own keys. A contributor adding a key to the picker or the filter prompt will look for its row and not find one.\n\nRewrite: \"**Every key the board takes on its lists, the dependency map and a grabbed row is a row in `tui/action_table.go`.** The key handler, the status bar's hints and the `?` overlay read it, and a row's `applies` keeps it out of all three where it does not act. A prompt, the project picker, the link overlay and the hosted reader take their own keys.\""
}
]
}This review does not gate merge.
There was a problem hiding this comment.
PR review — synthesis
Used: the 20 findings of round 1, from 8 review(s), and nothing else
Standards: none, because this step reads only the findings
Findings: 0 correctness · 3 design · 0 prose
Design and architecture
synthesis.1 explains G1.3 · failure-paths.1
Outside tmux, C (the Claude session opener in tui/claude_session.go) drops the cwd fallback note that the tmux branch flashes. G1.3 and failure-paths.1 both show it. stale-prose.1 shows the README describing the fallback behavior wrongly.
synthesis.2 explains fleet-patterns.4 · contracts.1
The n key is rebound to rename in tui/action_table.go, against the fleet key table, where n writes a note in $EDITOR.
synthesis.3 explains code-architecture.2 · stale-prose.3 · fleet-patterns.1 · fleet-patterns.2 · fleet-patterns.3 · code-architecture.3
Facts the tui package owns (views, key tables, registry and the session opener) are kept as hand-kept copies elsewhere. The copies drift. The cli copies the dependencies view name. Prose copies the view count. The tui retypes gotermboard's action table. The registry is read from a copy of slate's code. The session opener is copied from slate.
One defect, raised more than once
G1.3 · failure-paths.1: Outside tmux, C in tui/claude_session.go drops the fallback-to-cwd note and the user is never told.
fleet-patterns.4 · contracts.1: n is bound to rename in tui/action_table.go where the fleet key table reserves it for writing a note in $EDITOR.
Shapes as JSON
{
"shapes": [
{
"number": "synthesis.1",
"section": "design",
"explains": [
"G1.3",
"failure-paths.1"
],
"body": "Outside tmux, `C` (the Claude session opener in `tui/claude_session.go`) drops the cwd fallback note that the tmux branch flashes. G1.3 and failure-paths.1 both show it. stale-prose.1 shows the README describing the fallback behavior wrongly."
},
{
"number": "synthesis.2",
"section": "design",
"explains": [
"fleet-patterns.4",
"contracts.1"
],
"body": "The `n` key is rebound to rename in `tui/action_table.go`, against the fleet key table, where `n` writes a note in `$EDITOR`."
},
{
"number": "synthesis.3",
"section": "design",
"explains": [
"code-architecture.2",
"stale-prose.3",
"fleet-patterns.1",
"fleet-patterns.2",
"fleet-patterns.3",
"code-architecture.3"
],
"body": "Facts the tui package owns (views, key tables, registry and the session opener) are kept as hand-kept copies elsewhere. The copies drift. The cli copies the `dependencies` view name. Prose copies the view count. The tui retypes gotermboard's action table. The registry is read from a copy of slate's code. The session opener is copied from slate."
}
],
"duplicates": [
{
"defect": "Outside tmux, `C` in `tui/claude_session.go` drops the fallback-to-cwd note and the user is never told.",
"findings": [
"G1.3",
"failure-paths.1"
]
},
{
"defect": "`n` is bound to rename in `tui/action_table.go` where the fleet key table reserves it for writing a note in `$EDITOR`.",
"findings": [
"fleet-patterns.4",
"contracts.1"
]
}
]
}This review does not gate merge.
PR review round 1Ran: G1, useless-test, code-architecture, fleet-patterns, system-design, stale-prose, failure-paths, contracts, synthesis
Round record{
"grammar": 6,
"round": 1,
"repo": "datapointchris/projects",
"pr": 21,
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"experiment": "none",
"dispatch": "20261108T150231Z-pr-review",
"roster": "9fa7d955aca301b4f2edd5447426a0d838639046",
"harness": "e2021ee745e26b03295b925fe94235f5ec19039f",
"machine": "queue",
"started": "2026-11-08T15:03:11Z",
"finished": "2026-11-08T15:25:04Z",
"reviewers": [
{
"label": "G1",
"agent": "pr-reviewer",
"model": "opus",
"effort": "high",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/general.md",
"prompt_sha": "00271016dd7d",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 1265,
"tokens": {
"input": 134,
"cache_write": 117496,
"cache_read": 5266226,
"output": 25378
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 134,
"cache_write": 117496,
"cache_read": 5266226,
"output": 25378
}
},
"session": "01a1bbae-c72a-778f-b038-6772d97d3e27",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818439290",
"review_id": 5818439290,
"findings": {
"correctness": 4,
"design": 0,
"prose": 1
},
"numbers": [
"G1.1",
"G1.2",
"G1.3",
"G1.4",
"G1.5"
],
"rules": {
"proposed": 1,
"keys": [
"rule/tui/a-cursor-kept-across-a-reread-names-the-rows-place-not-only-its-record"
],
"filed": true,
"why": "",
"path": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records/G1.rules.json"
},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:23Z",
"finished": "2026-11-08T15:24:32Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "useless-test",
"agent": "pr-useless-test-reviewer",
"model": "opus",
"effort": "high",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/useless-test.md",
"prompt_sha": "f0b52bbf8a48",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 893,
"tokens": {
"input": 40,
"cache_write": 60091,
"cache_read": 926227,
"output": 12381
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 40,
"cache_write": 60091,
"cache_read": 926227,
"output": 12381
}
},
"session": "01a1bbae-d57c-7b5e-b5dc-99e5f68c00f4",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818419315",
"review_id": 5818419315,
"findings": {
"correctness": 0,
"design": 2,
"prose": 0
},
"numbers": [
"useless-test.1",
"useless-test.2"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:22Z",
"finished": "2026-11-08T15:18:20Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "code-architecture",
"agent": "pr-code-architecture-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "283f4014efc3",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 122,
"tokens": {
"input": 26,
"cache_write": 43773,
"cache_read": 448362,
"output": 8763
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 26,
"cache_write": 43773,
"cache_read": 448362,
"output": 8763
}
},
"session": "01a1bbae-dbed-72f4-88ab-9e131b9fe00b",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818377504",
"review_id": 5818377504,
"findings": {
"correctness": 0,
"design": 3,
"prose": 0
},
"numbers": [
"code-architecture.1",
"code-architecture.2",
"code-architecture.3"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:22Z",
"finished": "2026-11-08T15:05:30Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "fleet-patterns",
"agent": "pr-fleet-patterns-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "efa99c642e6d",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 282,
"tokens": {
"input": 58,
"cache_write": 74467,
"cache_read": 1452697,
"output": 18472
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 58,
"cache_write": 74467,
"cache_read": 1452697,
"output": 18472
}
},
"session": "01a1bbae-cc96-78f9-ab32-56c6639807a6",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818386099",
"review_id": 5818386099,
"findings": {
"correctness": 0,
"design": 4,
"prose": 0
},
"numbers": [
"fleet-patterns.1",
"fleet-patterns.2",
"fleet-patterns.3",
"fleet-patterns.4"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:22Z",
"finished": "2026-11-08T15:08:08Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "system-design",
"agent": "pr-system-design-reviewer",
"model": "sonnet",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "bc2eb8615f41",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 38,
"tokens": {
"input": 12,
"cache_write": 11850,
"cache_read": 89342,
"output": 1354
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 12,
"cache_write": 11850,
"cache_read": 89342,
"output": 1354
}
},
"session": "01a1bbae-d02a-7622-935a-f2df08e3ac36",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818372610",
"review_id": 5818372610,
"findings": {
"correctness": 0,
"design": 0,
"prose": 0
},
"numbers": [],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:22Z",
"finished": "2026-11-08T15:04:04Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "stale-prose",
"agent": "pr-stale-prose-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "4359b874a436",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 128,
"tokens": {
"input": 28,
"cache_write": 42536,
"cache_read": 462425,
"output": 6350
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 28,
"cache_write": 42536,
"cache_read": 462425,
"output": 6350
}
},
"session": "01a1bbae-c963-73c8-a4c1-981e90e92bd9",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818377757",
"review_id": 5818377757,
"findings": {
"correctness": 0,
"design": 0,
"prose": 3
},
"numbers": [
"stale-prose.1",
"stale-prose.2",
"stale-prose.3"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:22Z",
"finished": "2026-11-08T15:05:34Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "failure-paths",
"agent": "pr-failure-paths-reviewer",
"model": "sonnet",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "4239ed2be477",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 46,
"tokens": {
"input": 14,
"cache_write": 20648,
"cache_read": 128280,
"output": 2438
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 14,
"cache_write": 20648,
"cache_read": 128280,
"output": 2438
}
},
"session": "01a1bbae-e349-7782-b8e2-849545769395",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818373189",
"review_id": 5818373189,
"findings": {
"correctness": 1,
"design": 0,
"prose": 0
},
"numbers": [
"failure-paths.1"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:22Z",
"finished": "2026-11-08T15:04:13Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
},
{
"label": "contracts",
"agent": "pr-contracts-reviewer",
"model": "opus",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/persona.md",
"prompt_sha": "3cf737a7c111",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 160,
"tokens": {
"input": 42,
"cache_write": 48685,
"cache_read": 761302,
"output": 9377
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 42,
"cache_write": 48685,
"cache_read": 761302,
"output": 9377
}
},
"session": "01a1bbaf-3c1c-783b-a8af-28f3d1ad267c",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818378826",
"review_id": 5818378826,
"findings": {
"correctness": 0,
"design": 2,
"prose": 0
},
"numbers": [
"contracts.1",
"contracts.2"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:03:11Z",
"finished": "2026-11-08T15:05:54Z",
"account": "live",
"api_key_source": "none",
"machine": "archlinux"
},
{
"label": "synthesis",
"agent": null,
"model": "sonnet",
"effort": "medium",
"experiment": "none",
"arm": "default",
"reason": "STATIC",
"prompt_profile": "pr-reviewer-prompts/synthesis.md",
"prompt_sha": "8832c4b84e09",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 8,
"tokens": {
"input": 2,
"cache_write": 13989,
"cache_read": 1499,
"output": 602
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 2,
"cache_write": 13989,
"cache_read": 1499,
"output": 602
}
},
"session": "01a1bbc3-02d1-78fa-b944-d8b9a4de65e6",
"worklog": "1462.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/21#pullrequestreview-5818441065",
"review_id": 5818441065,
"findings": {
"correctness": 0,
"design": 3,
"prose": 0
},
"numbers": [
"synthesis.1",
"synthesis.2",
"synthesis.3"
],
"rules": {},
"head": "766a221022df76055349f253bd10b4263b6e5a97",
"kept": false,
"records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-projects-pr21-round1-20261108T150231Z/records",
"started": "2026-11-08T15:24:52Z",
"finished": "2026-11-08T15:25:04Z",
"account": "live",
"api_key_source": "none",
"machine": "queue"
}
],
"rules": {
"proposed": 1,
"keys": [
"rule/tui/a-cursor-kept-across-a-reread-names-the-rows-place-not-only-its-record"
],
"unfiled": []
},
"kept": []
} |
…napshot Every mode now reads its keys from tui/action_table.go: the prompts, the project picker, the filter, the ? overlay and the link and unlink overlays. A key no row binds falls through to the mode's text box, and each overlay's hint line is the table's footer. In ?, q quits. n and e write an item's note or a project's description in $EDITOR, as the fleet key table has it. r renames. c on a shelved or closed project reopens it. The ? overlay wraps a long label under its own column, so a wrapped word never reads as a key. A row's key carries the project it is listed under, so the cursor stays on the second copy of an item filed in two projects across a reread. C takes the checkout from the board's data source rather than reading the registry itself. Where it falls back to the board's directory, the session's brief and the flash on return say where it ran and why. projects tui --json answers every view. The dependencies view carries the map in the dependency-tree shape, and each item names the project it is listed under. One view table in tui/modes.go holds every name and label, and the both-modes test checks each view's snapshot against the schema. The flows no longer press a key only to show it does nothing, and the ? flow keeps one frame, the one that shows a label wrapping. Agent-Name: claude-code-unattended Agent-Session: eaef152a-8638-4371-a140-eed180910f90 Agent-Run: 01a1bbae-c09c-7ea7-9995-3f56a0271c63 Worklog: 1462.1
The board's keys were three hand-kept copies: a key handler with a handled-bool, a literal footer per pane, and a literal
?overlay. Its letters meant something different from every other terminal board this author runs:mmoved,Ctoggled closed projects,spacemarked done,Tabswitched panes, and/opened a search overlay that lost its place. Reading a note meant opening a separate reader built onglamour.This rebuilds the key handling around one action table, in every mode, and gives the board the shared grammar:
Tabcycles three views,/filters in place,Mmoves, rows take two lines, and a note opens in place or full screen in gotermboard's reader. The look stays this board's own: two bordered panes, the○ ✓ ≡glyphs, the[d/t]task count and the footer.Session: eaef152a-8638-4371-a140-eed180910f90
What to look at
tui/action_table.gois the table.actions()picks one table per mode: the lists, the map, a grabbed row,?, the filter, a prompt, the project picker, and the link and unlink overlays. Check that every key the old handlers took has a row, and that each row'sapplieskeeps it off rows it cannot act on. The handler (runKey), the footer and every overlay's hint line (footerHints) and?(helpLines) read onlyactions().tui/app.gohandleKey: a key no row binds falls through to the mode's text box (typesText,typeKey).Updateforwards keys, pastes and window sizes to the hosted reader while one shows.tui/item_rows.goholds the item pane's rows, layout and drawing. CheckitemPaneLayoutagainstrenderRowBlock: the scroll counts lines, so the two must agree, andTestLayoutHeightMatchesWhatGetsRenderedholds them to it. CheckstickyBandandplaceItemScroll, which keep an item's first line and its group's header at the top while its note scrolls.keyOfnames a grouped row by its project too.tui/claude_session.gochooses the directoryCopens in, throughWhere.Checkout, whichcli/data_source.gocheckoutbuilds fromrepos.Registry.Checkout.cli/tui.goandcli/schemas/board-snapshot.schema.json:--viewtakes three names, and--jsonanswers for each. The dependencies view carriestree, in the dependency-tree shape by$ref, and every item carrieslisted_under.How it was verified
go test -short ./...passes in every package.go test -run TestRealBinaryFlows .replays 15 key scripts against the built binary on a pty over a seeded SQLite file, and every frame matches its golden, in 15.2s. The goldens were written from empty directories, then compared again without update. The new scripts cover what the change claims:viewscycles the three views and folds a group by its header.filternarrows by a title, by a word only a note holds ("replica"), by a project name, and in All items, and esc clears each.movegrabs withM, carries, drops, and puts back.note-in-placeopens the 66-line note in place, scrolls it with the item's line still on top, and closes it.help-taller-than-the-windowopens?on a project, an item, a task and a grabbed row. Its one frame shows the close hint kept at the foot of a short window and a long label wrapped under its own column.TestTheCursorStaysOnTheSecondCopyOfAnItemFiledInTwoProjectsputs the cursor on the second project's copy of an item filed in two, pressesc, and checks the cursor is still under that project. It fails on the previous head withcursor under "work".TestRealDatabaseSurvivesEverySizeAndProjectwalked a real database of 4 projects and 8 items, each with a note up to 180 lines, at six sizes from 40×10 to 200×50. It walked every list twice, notes closed and then every note open. Each row under the cursor was on screen, and no view outgrew its window.Cwas driven outside tmux on the built binary with a stand-inclaudeonPATH.On an item tagged with a repo the registry checks out, it ran in the checkout, and the return flash read
Back from the Claude session on 61, which ran in /tmp/tmp.PVqzKPKXr8/checkout.On a project naming no repo, it ran in the board's directory, and both the brief and the flash said why:
Inside tmux, with a stand-in
tmux, on a project whose items name that repo, tmux receivednew-window -n claude Learn Kubernetes -c <checkout> claude <brief>.TestTheCommandsAnswerTheSameInBothModeschecksprojects tui --json --view <name>against the schema for every nametui.ViewNames()holds. On the dev database, the dependencies snapshot held 11 nodes, 6 edges and 5 roots, the same asprojects items dependencies tree --jsononce sorted.--view nopeexits 2 with the reason.golangci-lint run ./...reports 0 issues.What changes
Every key that moved, old beside new:
Tabswitches panehandlswitch pane.TabandShift+Tabcycle By project, All items and DependenciesAllrow in the project paneJKjump item to item][jump past tasks.JKscroll an open notespacemarks an item donecmarks it done.spaceopens its note in placeerenames an itemrnedits a note in$EDITORn, andetoopchooses an item's projectsPmmoves a rowM.mdoes nothingddeletes a taskD.ddoes nothing/opens a search overlay/filters the list in place, notes includedCshows shelved and closed projects..Copens a Claude session on the rowedcxSrrrenames,norewrites the description,ccompletes an active project or reopens a shelved or closed one,xdrops,SshelvesTopens the dependency browser,esccloses itTopens the Dependencies view on the item.Tableaves it, andescbacks out a drill or a filterashows finished trees.qorhgoes backescorspacegoes back.qquits?,qcloses itescor?closes it.qquitsEnterorspacefolds a group.[], search with/, and copy withy. It loses the item keys it used to take, so an item is edited from its row.pathis read, andCopens there. Where the registry names no checkout,Copens in the board's directory, and the session's brief and the flash on return say so. A checkout named but missing opens nothing.projects tui --viewtakesprojects,itemsordependencies, and--jsonanswers for each. The snapshot'sviewfield gainsitemsanddependencies. It gainstree, null outside the dependencies view, and each item gainslisted_under, naming the project it is listed under.Backend.Search,apiclient.Backend.Searchandservice.Searchare gone, with their test. The board was their one caller.SearchItemsand the API's search route stay.glamour.Decisions, and what they rejected
Readerrather than adopt itsModel. The board keeps its own panes and drawing, soAppforwards messages to the reader while one shows. gotermboard v0.31.0 addsReader.ClosesWithfor a host like this. Before it, esc and space did nothing in a reader not opened by aModel. Adopting theModelwas rejected because it draws the table its own way, and this board keeps its look.[]jump to the next row at the cursor's depth or shallower. From a group header that is the next group, and from an item the next item or group, so tasks are skipped either way, as Vim's[[moves by section..shows closed projects and, in the map, finished trees. That is the hidden-rows toggle yazi and nnn use.nandewrite in$EDITOR,rrenames,Pfiles an item,cmarks done. The fleet key table givesnto a note andeto editing in$EDITOR, and names no rename key, so rename tookr.pis left free for a side panel.con a shelved or closed project reopens it.cis the done toggle on every row, so on a closed project it undoes the close. That freesrfor rename.Enteron an item opens the reader full screen, however short the note. The reader shows what the note in place cannot: the created date, what blocks the item, what it blocks, and its tasks.spaceopens the note alone in place.C's checkout lookup reads this machine's registry in both modes. The board's validation registry is empty in API mode, because an API serves repo tags written from every machine. A checkout is a path on this machine, so the lookup reads the local registry wherever the data comes from.The review
Round 1: #21 (comment)
keyOfintui/item_rows.gonames a grouped rowitem:<project>/<id>andtask:<project>/<id>, so a reread lands on the copy under the cursor's project.TestTheCursorStaysOnTheSecondCopyOfAnItemFiledInTwoProjectspressescon the second copy and checks the cursor's group.listed_under, the id and name of the project it is listed under, and the schema says so.claude.helpLineswraps each label at the overlay's content width and indents what wraps under the label column. Thefrom-the-project-panegolden shows it.actions()has a table for every mode:helpActions,filterActions,promptActions,pickerActionsanddepLinkActions.handleKeysends every key throughrunKey, and a key no row binds falls through to the text box. Every overlay's hint line isfooterHints(), so?now shows[q]quit.viewstable intui/modes.goholds each view's name and label. The CLI's copy of the name and its guard are gone, because every view has a snapshot. The both-modes test checks the snapshot of each nametui.ViewNames()holds against the schema, so a view the enum lacks fails it.tui.WheregainsCheckout, which the CLI builds from its data source, andtuino longer importsrepos.--help, the README heading and the key table no longer write the count or the names.nwrites the note in$EDITORagain,edoes too, andrrenames items and projects.--json --view dependenciesanswersview: "dependencies"with the map undertree, in the dependency-tree schema by$ref, and the enum gains the name.Modeland for a host that draws its own screen. gotermboard keeps its hand-over and stderr tail unexported, so projects cannot report a failed session's last line until it exports them.repos.jsonthat projects, slate, prs, fleet and forge import. Rejected: the interim active-only filter onCheckout. A dormant repo's checkout is still on disk, and the filter would send its items' sessions to the board's directory.Enterin place. The reader shows the created date, the blockers, what the item blocks and its tasks, which the note in place does not, andspacealready opens the note in place.manddsteps, them-is-unboundgoldens and the header clauses are gone.from-the-project-panestays, as the only check that?in a short window keeps its close hint and wraps a long label under its column. Rejected: a unit test of each context's key set. The flows' waits on[D]eleteon a task and[Enter]dropon a grabbed row pin it already, read through the sameappliesfilter.Risk and rollback
A merge cuts a minor release of the binary. A revert restores the old keys and reader with no data change, because nothing here writes to a store.
backend.BackendlosesSearch, so code outside this module implementing or calling it stops compiling. The snapshot's new fields are additions, and no consumer reads the snapshot today.What this does not do
ReorderProjectwrites one row's position, so the moved project ties with its neighbor. Themoveflow grabs and puts back a project, and drops only an item.handlmove between two panes only.