Repository navigation
feat!: give projects a command line that runs on the API or on SQLite - #19
Conversation
Bare `projects` prints help, and the board moves to `projects tui`. The project verbs sit at the root, and the item verbs under `projects items`, with tasks, dependencies and memberships beneath. Every read takes --json, and each document the tool composes has a schema under cli/schemas. The config file's `api` key, or --api, sends every command to the projects API. `projects auth login <url>` logs in with the device grant, and PROJECTS_CLIENT_SECRET switches to client credentials. Without `api`, the commands run the API's own routes in process over the SQLite database. A refusal, an exit code and a JSON answer are then the same in both modes. `api` beside `[sync] enabled = true` refuses every command. With sync on, a command that writes refuses, because the write would land in the copy with nothing queued and the next pull would delete it. In API mode the board reads through apiclient.Backend, rereads on each sync interval and on R, and shows API and the host in its status bar. It reads every task in one request rather than one per listed item. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b7cb-45d0-73ce-a2e4-69fe97cd3689 Worklog: 2402.1
The --no-input refusal named one missing flag and still said to pass them. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b7cb-45d0-73ce-a2e4-69fe97cd3689 Worklog: 2402.1
A 500 exits 4 at once, and only a read retries an answer it lost. The table read as if every 5xx were tried three times. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b7cb-45d0-73ce-a2e4-69fe97cd3689 Worklog: 2402.1
datapointchris
left a comment
There was a problem hiding this comment.
PR review — failure-paths
Used: Built the head and read the retry transport, the error mapping and the client setup. Ran a probe test (since deleted) against an httptest server that always answers 500, through Retrying: a POST hit the server once and returned the APIError.
Standards: Did not run fleet standards search, so no rule is cited. The findings rest on the code and the probe.
Findings: 2 correctness · 0 design · 0 prose
Correctness
failure-paths.1 cli/api_errors.go:43 · cli/api_errors.go:45 · cli/api_errors.go:111 · apiclient/transports.go:100
Reach: A write (POST, PATCH or DELETE) that gets a 500, 502, 503 or 504, or times out after it was sent, reaches this path. I ran a POST against a server that always answers 500: the server was hit once and the client returned the APIError at once. explain then maps any 5xx to noAnswerError (exit 4) and words it with a fixed '%d attempts', which is 3.
A failed write exits 4 with a false message. The message says 'no answer from the API ... 3 attempts'. In fact the API did answer, and the write was sent once. Retrying never retries a write after it was sent.
Exit 4 is documented as 'the same command later can succeed'. For a 500 or a timeout after send, the server may already have committed the write. A script that retries on exit 4 can create a duplicate item or task, which is the double write the retry policy exists to prevent.
Fix: give noAnswerError a flag for 'the request was sent'. A write that got a 5xx or a timeout after send should exit 1 with a message such as 'the API may or may not have applied this; check before repeating'. Report the real attempt count from the transport instead of the constant apiclient.Attempts. Keep exit 4 for failures where the request was never sent, and for reads.
failure-paths.2 cli/data_source.go:121 · apiclient/transports.go:57 · apiclient/transports.go:112
Reach: A token fetch that fails sits under the retry wrapper. Retrying wraps the oauth2 transport, so each retry re-enters oauth2.Transport and may fetch the token again. A GET whose token fetch fails with a net.Error (a timeout of the token client, which is 30s) is retried up to 3 times. Each attempt can take up to 30s, but the whole call is capped at 60s by the outer client timeout. I read this path and did not run it.
The retry loop cannot tell a token-endpoint failure from an API failure. The first retry repeats a 30s token timeout. The 60s cap then cuts the call off with DeadlineExceeded, and that is worded 'no answer from the API at '. The cause is the login provider, not the API. A credential-provider outage is misreported as an API outage, and the user is sent to check the wrong host.
Fix: put the oauth2 transport outside Retrying, so the token is fetched once per call. Alternatively wrap token errors in a distinct type that worthRetrying never retries, and word that type with the issuer's host.
Findings as JSON
{
"used": "Built the head and read the retry transport, the error mapping and the client setup. Ran a probe test (since deleted) against an httptest server that always answers 500, through Retrying: a POST hit the server once and returned the APIError.",
"standards": "Did not run `fleet standards search`, so no rule is cited. The findings rest on the code and the probe.",
"findings": [
{
"number": "failure-paths.1",
"section": "correctness",
"sites": [
"cli/api_errors.go:43",
"cli/api_errors.go:45",
"cli/api_errors.go:111",
"apiclient/transports.go:100"
],
"reach": "A write (POST, PATCH or DELETE) that gets a 500, 502, 503 or 504, or times out after it was sent, reaches this path. I ran a POST against a server that always answers 500: the server was hit once and the client returned the APIError at once. explain then maps any 5xx to noAnswerError (exit 4) and words it with a fixed '%d attempts', which is 3.",
"body": "A failed write exits 4 with a false message. The message says 'no answer from the API ... 3 attempts'. In fact the API did answer, and the write was sent once. Retrying never retries a write after it was sent.\n\nExit 4 is documented as 'the same command later can succeed'. For a 500 or a timeout after send, the server may already have committed the write. A script that retries on exit 4 can create a duplicate item or task, which is the double write the retry policy exists to prevent.\n\nFix: give noAnswerError a flag for 'the request was sent'. A write that got a 5xx or a timeout after send should exit 1 with a message such as 'the API may or may not have applied this; check before repeating'. Report the real attempt count from the transport instead of the constant apiclient.Attempts. Keep exit 4 for failures where the request was never sent, and for reads."
},
{
"number": "failure-paths.2",
"section": "correctness",
"sites": [
"cli/data_source.go:121",
"apiclient/transports.go:57",
"apiclient/transports.go:112"
],
"reach": "A token fetch that fails sits under the retry wrapper. Retrying wraps the oauth2 transport, so each retry re-enters oauth2.Transport and may fetch the token again. A GET whose token fetch fails with a net.Error (a timeout of the token client, which is 30s) is retried up to 3 times. Each attempt can take up to 30s, but the whole call is capped at 60s by the outer client timeout. I read this path and did not run it.",
"body": "The retry loop cannot tell a token-endpoint failure from an API failure. The first retry repeats a 30s token timeout. The 60s cap then cuts the call off with DeadlineExceeded, and that is worded 'no answer from the API at <api>'. The cause is the login provider, not the API. A credential-provider outage is misreported as an API outage, and the user is sent to check the wrong host.\n\nFix: put the oauth2 transport outside Retrying, so the token is fetched once per call. Alternatively wrap token errors in a distinct type that worthRetrying never retries, and word that type with the issuer's host."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — system-design
Used: Cloned PR 19 at head, read the diff, cli/data_source.go, apiclient transports and client, cli/api_login.go, the in-repo API routes and ServiceScopes, goclilogin's LoadDeployment, and other local repos for callers of the bare projects binary. go build ./... passes.
Standards: No rule cited. I did not run fleet standards search, and I propose no rule.
Findings: 1 correctness · 0 design · 0 prose
Correctness
system-design.1 cli/root.go · dotfiles:shell/common/aliases.sh:97
Reach: The user upgrades the binary through the dotfiles install (install/packages.yml projects). They then run pj, which is alias pj='projects' in ~/dotfiles/shell/common/aliases.sh. Before this change that opened the board. After it, bare projects prints help and exits. The board is now projects tui. Nothing in the dotfiles repo is updated by this PR, and the release gives the alias no warning.
This PR moves the board from bare projects to projects tui. A launcher outside this repo still calls the bare name. The pj alias in the dotfiles repo does. Anything else that execs projects expecting a board has the same break. The break is soft: exit 0, help text, no board.
Fix: ship the change in the dotfiles repo with this release, as alias pj='projects tui'. Or have bare projects open the board when stdin and stdout are a terminal and print help otherwise. The second keeps the old habit working.
Findings as JSON
{
"used": "Cloned PR 19 at head, read the diff, cli/data_source.go, apiclient transports and client, cli/api_login.go, the in-repo API routes and ServiceScopes, goclilogin's LoadDeployment, and other local repos for callers of the bare `projects` binary. `go build ./...` passes.",
"standards": "No rule cited. I did not run `fleet standards search`, and I propose no rule.",
"findings": [
{
"number": "system-design.1",
"section": "correctness",
"sites": [
"cli/root.go",
"dotfiles:shell/common/aliases.sh:97"
],
"reach": "The user upgrades the binary through the dotfiles install (install/packages.yml `projects`). They then run `pj`, which is `alias pj='projects'` in ~/dotfiles/shell/common/aliases.sh. Before this change that opened the board. After it, bare `projects` prints help and exits. The board is now `projects tui`. Nothing in the dotfiles repo is updated by this PR, and the release gives the alias no warning.",
"body": "This PR moves the board from bare `projects` to `projects tui`. A launcher outside this repo still calls the bare name. The `pj` alias in the dotfiles repo does. Anything else that execs `projects` expecting a board has the same break. The break is soft: exit 0, help text, no board.\n\nFix: ship the change in the dotfiles repo with this release, as `alias pj='projects tui'`. Or have bare `projects` open the board when stdin and stdout are a terminal and print help otherwise. The second keeps the old habit working."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — stale-prose
Used: I cloned PR 19 at 4305425 and built projects into a scratch directory, with every XDG path and PROJECTS_DB pointed inside it. I ran --help on the root, items and auth and compared the output with the README verb tree. I ran go list -deps . | rg 'pgx|goose|goapiauth|goclilogin'. I compared the README, CLAUDE.md, config.example.toml and 628 added Go comment lines against transports.go, api_errors.go, focus_queue.go, cli/schemas and the prompt package.
Standards: I applied pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite. I propose no new rules.
Findings: 0 correctness · 0 design · 4 prose
Prose
stale-prose.1 README.md:91 · prompt/prompt.go:230 · cli/focus_queue.go:16
False: three added sentences say what the code at the head does not do.
README.md:91: "show,items show,next,items dependencies treeandtui --jsonprint documents that carryschema_version." Six documents carry it.cli/auth.go:240givesauth status --jsonaschema_version, andcli/schemas/auth-status.schema.jsonis its schema. The list leavesauth statusout, so a reader concludes thatauth status --jsonis the API's own shape.
Rewrite: "A--jsondocument this tool composes carriesschema_version, and cli/schemas holds its JSON Schema. Every other--jsonanswer is the API's own shape, under api/schemas." That drops the list. The directory is the list.prompt/prompt.go:230-231: "TestForm_RunningOneFormTwiceDoesNotCarryTheFirstRunsEscapeOveris what holds it." No such test exists in this repo.rg TestForm_Runningmatches only this comment.prompt/has no_test.gofile. The test stayed behind in the repo the package was copied from.
Fix: copy that test intoprompt/, or delete the sentence.cli/focus_queue.go:16: "defaultNextItemLimit caps the itemsprojects nextprints; --limit raises it."--limitreplaces the cap, and the README's own exampleprojects next --limit 1lowers it.
Rewrite: "defaultNextItemLimit caps the itemsprojects nextprints when --limit is not given."
stale-prose.2 README.md:74-84 · README.md:94 · README.md:113 · README.md:384
A value the code holds: the README writes out counts and lists that the code owns. Each is true today. Each is wrong after the next change to the constant or the command tree, and nothing fails when it goes wrong.
README.md:74-84: the verb tree, fromprojects list | next | show <project>throughprojects sync | version | update | completion.projects --helpprints this tree fromcli/root.go. It matches today. A new verb needs a second edit here.
Rewrite: keep the heading's rule and replace the block with "projects --helplists every command, andprojects items --helplists the item verbs." Add one or two example lines if the shape needs showing.README.md:94: "nextprints ten items unless--limitsays otherwise." Ten isdefaultNextItemLimitincli/focus_queue.go:17.--helpalready prints the default.
Rewrite: "nextprints a capped list, and--limitsets the cap."README.md:113: "A read tries three times before it exits 4". AlsoREADME.md:384: "+-- api: HTTP, three attempts". Three isapiclient.Attemptsinapiclient/transports.go:35. CLAUDE.md already names the constant rather than its value.
Rewrite: "A read is retried before it exits 4, on a failed connection, an answer cut short, or a429,502,503or504." In the diagram, write+-- api: HTTP, retried.
stale-prose.3 README.md:261 · prompt/prompt.go:393-394 · prompt/prompt.go:428-430
The change narrated: three sentences describe history instead of what the file holds.
README.md:261: "Sync is the older way to reach an API." This tells the reader the order the features arrived in. The sentence after it says what sync is and stands without it.
Fix: delete the sentence, so the section opens "[sync] enabled = truekeeps a database on this machine as a copy ofsync.api_url…"prompt/prompt.go:393-394: "…because printing eighty repo names unasked is the wall of text this form exists to replace." "Exists to replace" refers to the source repo's earlier interface. This repo never had that wall of text.
Rewrite: "Past it the field says how many there are instead, because a list that long scrolls past the prompt it introduces."prompt/prompt.go:428-430: "…a field whose introduction wraps to two lines is a poor advertisement for a change about fitting output to the width." The sentence names the change that wrote it, in another repo.
Rewrite: "Kept inside 80 columns: it prints directly above the prompt, and wrapping would split the introduction from the field it introduces."
stale-prose.4 prompt/terminal.go:78 · prompt/terminal.go:170
Will go stale: the copied prompt package carries measurements from its source repo, with no command to reproduce them.
prompt/terminal.go:78: "…and walking 54 projects one keypress at a time is not finding out." Fifty-four is one machine's project count at the time of the original change. Nothing in this repo keeps the number current.
Rewrite: "…and walking a long list one keypress at a time is not finding out."prompt/terminal.go:170: "Handing it a tabwriter directly costs one per cell and per pad, which measured 123 for 54 choices." No test or command in this repo produces the 123.
Rewrite: "Handing it a tabwriter directly costs one repaint per cell and per pad." The reasoning that follows stands without the number.
Findings as JSON
{
"used": "I cloned PR 19 at 4305425 and built `projects` into a scratch directory, with every XDG path and PROJECTS_DB pointed inside it. I ran `--help` on the root, `items` and `auth` and compared the output with the README verb tree. I ran `go list -deps . | rg 'pgx|goose|goapiauth|goclilogin'`. I compared the README, CLAUDE.md, config.example.toml and 628 added Go comment lines against transports.go, api_errors.go, focus_queue.go, cli/schemas and the prompt package.",
"standards": "I applied pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite. I propose no new rules.",
"findings": [
{
"number": "stale-prose.1",
"section": "prose",
"sites": [
"README.md:91",
"prompt/prompt.go:230",
"cli/focus_queue.go:16"
],
"reach": "",
"body": "**False: three added sentences say what the code at the head does not do.**\n\n- `README.md:91`: \"`show`, `items show`, `next`, `items dependencies tree` and `tui --json` print documents that carry `schema_version`.\" Six documents carry it. `cli/auth.go:240` gives `auth status --json` a `schema_version`, and `cli/schemas/auth-status.schema.json` is its schema. The list leaves `auth status` out, so a reader concludes that `auth status --json` is the API's own shape.\n Rewrite: \"A `--json` document this tool composes carries `schema_version`, and [cli/schemas](cli/schemas) holds its JSON Schema. Every other `--json` answer is the API's own shape, under [api/schemas](api/schemas).\" That drops the list. The directory is the list.\n- `prompt/prompt.go:230-231`: \"`TestForm_RunningOneFormTwiceDoesNotCarryTheFirstRunsEscapeOver` is what holds it.\" No such test exists in this repo. `rg TestForm_Running` matches only this comment. `prompt/` has no `_test.go` file. The test stayed behind in the repo the package was copied from.\n Fix: copy that test into `prompt/`, or delete the sentence.\n- `cli/focus_queue.go:16`: \"defaultNextItemLimit caps the items `projects next` prints; --limit raises it.\" `--limit` replaces the cap, and the README's own example `projects next --limit 1` lowers it.\n Rewrite: \"defaultNextItemLimit caps the items `projects next` prints when --limit is not given.\""
},
{
"number": "stale-prose.2",
"section": "prose",
"sites": [
"README.md:74-84",
"README.md:94",
"README.md:113",
"README.md:384"
],
"reach": "",
"body": "**A value the code holds: the README writes out counts and lists that the code owns.** Each is true today. Each is wrong after the next change to the constant or the command tree, and nothing fails when it goes wrong.\n\n- `README.md:74-84`: the verb tree, from `projects list | next | show <project>` through `projects sync | version | update | completion`. `projects --help` prints this tree from `cli/root.go`. It matches today. A new verb needs a second edit here.\n Rewrite: keep the heading's rule and replace the block with \"`projects --help` lists every command, and `projects items --help` lists the item verbs.\" Add one or two example lines if the shape needs showing.\n- `README.md:94`: \"`next` prints ten items unless `--limit` says otherwise.\" Ten is `defaultNextItemLimit` in `cli/focus_queue.go:17`. `--help` already prints the default.\n Rewrite: \"`next` prints a capped list, and `--limit` sets the cap.\"\n- `README.md:113`: \"A read tries three times before it exits 4\". Also `README.md:384`: \"+-- api: HTTP, three attempts\". Three is `apiclient.Attempts` in `apiclient/transports.go:35`. CLAUDE.md already names the constant rather than its value.\n Rewrite: \"A read is retried before it exits 4, on a failed connection, an answer cut short, or a `429`, `502`, `503` or `504`.\" In the diagram, write `+-- api: HTTP, retried`."
},
{
"number": "stale-prose.3",
"section": "prose",
"sites": [
"README.md:261",
"prompt/prompt.go:393-394",
"prompt/prompt.go:428-430"
],
"reach": "",
"body": "**The change narrated: three sentences describe history instead of what the file holds.**\n\n- `README.md:261`: \"Sync is the older way to reach an API.\" This tells the reader the order the features arrived in. The sentence after it says what sync is and stands without it.\n Fix: delete the sentence, so the section opens \"`[sync] enabled = true` keeps a database on this machine as a copy of `sync.api_url` …\"\n- `prompt/prompt.go:393-394`: \"…because printing eighty repo names unasked is the wall of text this form exists to replace.\" \"Exists to replace\" refers to the source repo's earlier interface. This repo never had that wall of text.\n Rewrite: \"Past it the field says how many there are instead, because a list that long scrolls past the prompt it introduces.\"\n- `prompt/prompt.go:428-430`: \"…a field whose introduction wraps to two lines is a poor advertisement for a change about fitting output to the width.\" The sentence names the change that wrote it, in another repo.\n Rewrite: \"Kept inside 80 columns: it prints directly above the prompt, and wrapping would split the introduction from the field it introduces.\""
},
{
"number": "stale-prose.4",
"section": "prose",
"sites": [
"prompt/terminal.go:78",
"prompt/terminal.go:170"
],
"reach": "",
"body": "**Will go stale: the copied prompt package carries measurements from its source repo, with no command to reproduce them.**\n\n- `prompt/terminal.go:78`: \"…and walking 54 projects one keypress at a time is not finding out.\" Fifty-four is one machine's project count at the time of the original change. Nothing in this repo keeps the number current.\n Rewrite: \"…and walking a long list one keypress at a time is not finding out.\"\n- `prompt/terminal.go:170`: \"Handing it a tabwriter directly costs one per cell and per pad, which measured 123 for 54 choices.\" No test or command in this repo produces the 123.\n Rewrite: \"Handing it a tabwriter directly costs one repaint per cell and per pad.\" The reasoning that follows stands without the number."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — useless-test
Used: I cloned the repo and checked out PR 19. I ran TestRealBinaryFlows/api-mode and TestTheCommandsAnswerTheSameInBothModes, and both pass. I removed the reread from R's API branch in tui/app.go and ran api-mode again: it still passed. I diffed the three api-mode goldens against each other.
Standards: Cited: testing.md/Test behavior the user cares about, not what this commit happened to change; testing.md/Never assert on rendered output — assert the value it was built from; pull-requests.md/Tests the diff adds are checked for whether they test the program or the change.
Findings: 0 correctness · 1 design · 0 prose
Design and architecture
useless-test.1 testdata/flows/api-mode.keys:18 · testdata/TestRealBinaryFlows/api-mode/read-again.txt:30 · testdata/TestRealBinaryFlows/api-mode/read-again.style:106 · real_binary_flows_test.go:50
Reach: TestRealBinaryFlows/api-mode runs this step on every go test .. It covers the R branch of handleNormalKey in tui/app.go:1632, where API mode rereads the board. No other test presses R in API mode.
Delete the read-again step of the api-mode flow. It pins a flash sentence, and the reread it claims to show is never tested.
The step to delete is press R / wait Read again from / frame read-again in testdata/flows/api-mode.keys. Delete the goldens read-again.txt and read-again.style with it.
Nothing changes on the served database between item-added and R. Only the status line differs between the two frames. It holds the literal Read again from plus the host, which tui/app.go types into m.flash(...). The frame proves that R printed a flash. It does not prove that R read anything.
I ran the flow with R's fetchProjectsCmd removed, so the branch only flashed:
sed -i 's|return m, tea.Batch(fetchProjectsCmd(...), m.flash(...))|return m, m.flash(...)|' tui/app.go
go test -run 'TestRealBinaryFlows/api-mode' .
ok github.com/datapointchris/projects 1.598s
The test passed. It fails only if someone rewords the flash. testing.md § "Never assert on rendered output — assert the value it was built from" and § "Test behavior the user cares about, not what this commit happened to change" both reach it.
The behavior is real: R rereads the API. Nothing else pins it. A replacement test would write an item to the served database from outside the board after frame item-added. It could call cli.LocalService on served.db, or POST to serveOverHTTP's URL. The flow would then press R and wait for that item's title in the item pane. That test fails when R stops rereading, and survives any rewording of the flash.
The opened and item-added frames stay. They pin that the board shows the API's data and that an added item reaches it. TestTheCommandsAnswerTheSameInBothModes stays, and so do the projects.db absence checks. They run the binary and assert exit codes, schemas and listed titles. The NewApp(..., Where{...}) and cli.LocalService edits in tui/app_test.go, tui/realdata_test.go and former_name_test.go are signature updates to behavior tests, so those tests stay.
Findings as JSON
{
"used": "I cloned the repo and checked out PR 19. I ran `TestRealBinaryFlows/api-mode` and `TestTheCommandsAnswerTheSameInBothModes`, and both pass. I removed the reread from `R`'s API branch in `tui/app.go` and ran api-mode again: it still passed. I diffed the three api-mode goldens against each other.",
"standards": "Cited: testing.md/Test behavior the user cares about, not what this commit happened to change; testing.md/Never assert on rendered output — assert the value it was built from; pull-requests.md/Tests the diff adds are checked for whether they test the program or the change.",
"findings": [
{
"number": "useless-test.1",
"section": "design",
"sites": [
"testdata/flows/api-mode.keys:18",
"testdata/TestRealBinaryFlows/api-mode/read-again.txt:30",
"testdata/TestRealBinaryFlows/api-mode/read-again.style:106",
"real_binary_flows_test.go:50"
],
"reach": "`TestRealBinaryFlows/api-mode` runs this step on every `go test .`. It covers the `R` branch of `handleNormalKey` in `tui/app.go:1632`, where API mode rereads the board. No other test presses `R` in API mode.",
"body": "**Delete the `read-again` step of the api-mode flow. It pins a flash sentence, and the reread it claims to show is never tested.**\n\nThe step to delete is `press R` / `wait Read again from` / `frame read-again` in `testdata/flows/api-mode.keys`. Delete the goldens `read-again.txt` and `read-again.style` with it.\n\nNothing changes on the served database between `item-added` and `R`. Only the status line differs between the two frames. It holds the literal `Read again from` plus the host, which `tui/app.go` types into `m.flash(...)`. The frame proves that `R` printed a flash. It does not prove that `R` read anything.\n\nI ran the flow with `R`'s `fetchProjectsCmd` removed, so the branch only flashed:\n\n```\nsed -i 's|return m, tea.Batch(fetchProjectsCmd(...), m.flash(...))|return m, m.flash(...)|' tui/app.go\ngo test -run 'TestRealBinaryFlows/api-mode' .\nok github.com/datapointchris/projects 1.598s\n```\n\nThe test passed. It fails only if someone rewords the flash. `testing.md` § \"Never assert on rendered output — assert the value it was built from\" and § \"Test behavior the user cares about, not what this commit happened to change\" both reach it.\n\nThe behavior is real: `R` rereads the API. Nothing else pins it. A replacement test would write an item to the served database from outside the board after `frame item-added`. It could call `cli.LocalService` on `served.db`, or POST to `serveOverHTTP`'s URL. The flow would then `press R` and `wait` for that item's title in the item pane. That test fails when `R` stops rereading, and survives any rewording of the flash.\n\nThe `opened` and `item-added` frames stay. They pin that the board shows the API's data and that an added item reaches it. `TestTheCommandsAnswerTheSameInBothModes` stays, and so do the `projects.db` absence checks. They run the binary and assert exit codes, schemas and listed titles. The `NewApp(..., Where{...})` and `cli.LocalService` edits in `tui/app_test.go`, `tui/realdata_test.go` and `former_name_test.go` are signature updates to behavior tests, so those tests stay."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — code-architecture
Used: I cloned PR 19 at head 4305425 and read every changed module in full: cli/data_source.go, api_errors.go, api_login.go, auth.go, sync.go, tui.go, root.go, apiclient/*, backend/backend.go and config/config.go. I also read service/service.go, the goclilogin v0.8.0 source (deployment.go), and the main branch's go.mod. I traced every implementer of ListAllTasks and every site that decides the mode or the api-with-sync conflict. I ran nothing against real config, keyring or services.
Standards: Cited: code-quality.md/"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site". Cited: go.md/"Ask the library to export what you would otherwise copy out of it". Cited: data.md/"Filtering is server-side". Cited: cli-design.md/"One fact gets one answer on every command that reaches it". Searches run: "one owner for a fact config keys copied from library", "adapter re-implements rules the server owns filtering sorting", "mode decided in several places predicate duplicated".
Findings: 0 correctness · 3 design · 0 prose
Design and architecture
code-architecture.1 apiclient/backend.go:34 · apiclient/backend.go:105 · apiclient/backend.go:264 · apiclient/backend.go:276 · service/service.go:170 · service/service.go:465 · service/service.go:1084 · service/service.go:1103 · cli/data_source.go:204
Reach: The next change to any board-view rule edits service and apiclient.Backend together. Such changes include the order of archived items, which items search hides, and the board's project order. This PR already shows the cost once: one new backend.Backend method, ListAllTasks, needed edits to the interface, service.Service, sync.SyncBackend and apiclient.Backend.
Two modules decide the board's list rules
The board's view rules each live in two places:
- Archived items left out of a project's list:
service.ListItemsByProjectandapiclient.Backend.ListItemsByProject. - Archived items left out of search:
service.Searchandapiclient.Backend.Search. - Archived items sorted newest-updated first:
service.ListArchivedandapiclient.Backend.ListArchived. - Board order of projects:
service.ListProjectsByStatusandapiclient.Backend.ListProjectsByStatus.
In each pair, the apiclient.Backend version repeats the same slices.DeleteFunc or SortStableFunc line on the route's answer. boardBackend (cli/data_source.go:204) gives the board service in database mode and apiclient.Backend in API mode. Each mode runs its own copy of every rule, and nothing compares the copies.
This cuts against the PR's own decision. The PR says commands are written once against apiclient.Client so both modes answer alike. The board did not follow that decision.
Rules that reach it:
- code-quality.md § "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site"
- data.md § "Filtering is server-side"
Fix: give each rule one owner. The smallest version follows the model.SortProjectsForBoard pattern, which both sides already call. Lift the archived filter and the archived ordering into model functions, such as model.WithoutArchived and model.SortArchivedForBoard, and call them from both service and apiclient.Backend. A larger fix runs the board in unsynced database mode through apiclient.NewBackend over the in-process client. Then only the synced mode keeps a second path.
code-architecture.2 config/config.go:34 · config/config.go:35 · config/config.go:36 · config/config.go:37 · config/config.go:136 · config/config.go:191 · cli/data_source.go:84 · cli/sync.go:40 · cli/api_login.go:64
Reach: Any goclilogin upgrade that adds a key to its configFile breaks every projects command until config.Config gains a matching field. config.Load fails with "parsing config" on that key because of ErrorUnused. This PR already did this once by hand: it copied in four fields, including token_command, which login never writes. The copy exists only to get past that check.
Two parsers own the same config keys and the same config path
goclilogin's configFile/loginKeys says it owns the keys api, issuer, client_id and token_command. config.Config (config/config.go:34-37) copies all four as fields. Its own comment says why: ErrorUnused would otherwise refuse them.
The mode is decided from viper's parse of the file:
cfg.APIatcli/data_source.go:84cfg.APIatcli/sync.go:40
The deployment that commands then actually use comes from goclilogin's separate TOML parse.
The file's path is also decided twice:
config.newViper/userConfigDirreads from one path.goclilogin.ConfigPathproduces the path every message prints (configFilePath,cli/api_login.go:64).
Before this PR, newViper said it was shared "so they cannot drift on where that file is". The PR adds a third reader that does not share it.
Rule: go.md § "Ask the library to export what you would otherwise copy out of it".
Fix: make one parser the owner of the login keys and the path.
- Ask goclilogin to export its key set, and have
config.Loadskip those keys in itsErrorUnusedcheck, or decode into a struct that embeds goclilogin's exported one. - Decide the mode from
goclilogin.LoadDeployment'sAPIsetting rather thancfg.API. - Have
config.newVipertake its path fromgoclilogin.ConfigPath("projects").
code-architecture.3 cli/data_source.go:105 · cli/auth.go:139 · cli/sync.go:40 · cli/data_source.go:84
Reach: The next command that depends on the mode adds a fifth site, with its own check and its own refusal sentence. Examples: a config verb that prints the resolved values, or another setup command. This PR adds three such sites in one change.
Four sites decide the api-versus-sync choice
Whether this run uses the API or the database, and whether api and [sync] conflict, is decided at four sites. Each has its own predicate and its own sentence:
openDataSource:apiOverride != "" || cfg.API != ""(cli/data_source.go:84).openAPI: refusesapibesidecfg.Sync.Enabled(cli/data_source.go:105).refuseLoginBesideSync: refuses[sync]beforeapiis written (cli/auth.go:139).- The
synccommand: checkscfg.API != ""itself, before it callsopenDataSource(cli/sync.go:40).
No single function answers "which mode does this config select, or is it refused". Each command decides the question again.
Rule: cli-design.md § "One fact gets one answer on every command that reaches it".
Fix: add one function, either in config or beside openDataSource, that returns the mode or the refusal. Have openDataSource, sync and auth login all call it. Login would call it with the api it is about to write.
Findings as JSON
{
"used": "I cloned PR 19 at head 4305425 and read every changed module in full: `cli/data_source.go`, `api_errors.go`, `api_login.go`, `auth.go`, `sync.go`, `tui.go`, `root.go`, `apiclient/*`, `backend/backend.go` and `config/config.go`. I also read `service/service.go`, the goclilogin v0.8.0 source (`deployment.go`), and the main branch's `go.mod`. I traced every implementer of `ListAllTasks` and every site that decides the mode or the api-with-sync conflict. I ran nothing against real config, keyring or services.",
"standards": "Cited: code-quality.md/\"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\". Cited: go.md/\"Ask the library to export what you would otherwise copy out of it\". Cited: data.md/\"Filtering is server-side\". Cited: cli-design.md/\"One fact gets one answer on every command that reaches it\". Searches run: \"one owner for a fact config keys copied from library\", \"adapter re-implements rules the server owns filtering sorting\", \"mode decided in several places predicate duplicated\".",
"findings": [
{
"number": "code-architecture.1",
"section": "design",
"sites": [
"apiclient/backend.go:34",
"apiclient/backend.go:105",
"apiclient/backend.go:264",
"apiclient/backend.go:276",
"service/service.go:170",
"service/service.go:465",
"service/service.go:1084",
"service/service.go:1103",
"cli/data_source.go:204"
],
"reach": "The next change to any board-view rule edits `service` and `apiclient.Backend` together. Such changes include the order of archived items, which items search hides, and the board's project order. This PR already shows the cost once: one new `backend.Backend` method, `ListAllTasks`, needed edits to the interface, `service.Service`, `sync.SyncBackend` and `apiclient.Backend`.",
"body": "### Two modules decide the board's list rules\n\nThe board's view rules each live in two places:\n- **Archived items left out of a project's list:** `service.ListItemsByProject` and `apiclient.Backend.ListItemsByProject`.\n- **Archived items left out of search:** `service.Search` and `apiclient.Backend.Search`.\n- **Archived items sorted newest-updated first:** `service.ListArchived` and `apiclient.Backend.ListArchived`.\n- **Board order of projects:** `service.ListProjectsByStatus` and `apiclient.Backend.ListProjectsByStatus`.\n\nIn each pair, the `apiclient.Backend` version repeats the same `slices.DeleteFunc` or `SortStableFunc` line on the route's answer. `boardBackend` (`cli/data_source.go:204`) gives the board `service` in database mode and `apiclient.Backend` in API mode. Each mode runs its own copy of every rule, and nothing compares the copies.\n\nThis cuts against the PR's own decision. The PR says commands are written once against `apiclient.Client` so both modes answer alike. The board did not follow that decision.\n\nRules that reach it:\n- code-quality.md § \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\"\n- data.md § \"Filtering is server-side\"\n\n**Fix:** give each rule one owner. The smallest version follows the `model.SortProjectsForBoard` pattern, which both sides already call. Lift the archived filter and the archived ordering into `model` functions, such as `model.WithoutArchived` and `model.SortArchivedForBoard`, and call them from both `service` and `apiclient.Backend`. A larger fix runs the board in unsynced database mode through `apiclient.NewBackend` over the in-process client. Then only the synced mode keeps a second path."
},
{
"number": "code-architecture.2",
"section": "design",
"sites": [
"config/config.go:34",
"config/config.go:35",
"config/config.go:36",
"config/config.go:37",
"config/config.go:136",
"config/config.go:191",
"cli/data_source.go:84",
"cli/sync.go:40",
"cli/api_login.go:64"
],
"reach": "Any goclilogin upgrade that adds a key to its `configFile` breaks every `projects` command until `config.Config` gains a matching field. `config.Load` fails with \"parsing config\" on that key because of `ErrorUnused`. This PR already did this once by hand: it copied in four fields, including `token_command`, which login never writes. The copy exists only to get past that check.",
"body": "### Two parsers own the same config keys and the same config path\n\ngoclilogin's `configFile`/`loginKeys` says it owns the keys `api`, `issuer`, `client_id` and `token_command`. `config.Config` (`config/config.go:34-37`) copies all four as fields. Its own comment says why: `ErrorUnused` would otherwise refuse them.\n\nThe mode is decided from viper's parse of the file:\n- `cfg.API` at `cli/data_source.go:84`\n- `cfg.API` at `cli/sync.go:40`\n\nThe deployment that commands then actually use comes from goclilogin's separate TOML parse.\n\nThe file's path is also decided twice:\n- `config.newViper`/`userConfigDir` reads from one path.\n- `goclilogin.ConfigPath` produces the path every message prints (`configFilePath`, `cli/api_login.go:64`).\n\nBefore this PR, `newViper` said it was shared \"so they cannot drift on where that file is\". The PR adds a third reader that does not share it.\n\nRule: go.md § \"Ask the library to export what you would otherwise copy out of it\".\n\n**Fix:** make one parser the owner of the login keys and the path.\n- Ask goclilogin to export its key set, and have `config.Load` skip those keys in its `ErrorUnused` check, or decode into a struct that embeds goclilogin's exported one.\n- Decide the mode from `goclilogin.LoadDeployment`'s `API` setting rather than `cfg.API`.\n- Have `config.newViper` take its path from `goclilogin.ConfigPath(\"projects\")`."
},
{
"number": "code-architecture.3",
"section": "design",
"sites": [
"cli/data_source.go:105",
"cli/auth.go:139",
"cli/sync.go:40",
"cli/data_source.go:84"
],
"reach": "The next command that depends on the mode adds a fifth site, with its own check and its own refusal sentence. Examples: a `config` verb that prints the resolved values, or another setup command. This PR adds three such sites in one change.",
"body": "### Four sites decide the api-versus-sync choice\n\nWhether this run uses the API or the database, and whether `api` and `[sync]` conflict, is decided at four sites. Each has its own predicate and its own sentence:\n- `openDataSource`: `apiOverride != \"\" || cfg.API != \"\"` (`cli/data_source.go:84`).\n- `openAPI`: refuses `api` beside `cfg.Sync.Enabled` (`cli/data_source.go:105`).\n- `refuseLoginBesideSync`: refuses `[sync]` before `api` is written (`cli/auth.go:139`).\n- The `sync` command: checks `cfg.API != \"\"` itself, before it calls `openDataSource` (`cli/sync.go:40`).\n\nNo single function answers \"which mode does this config select, or is it refused\". Each command decides the question again.\n\nRule: cli-design.md § \"One fact gets one answer on every command that reaches it\".\n\n**Fix:** add one function, either in `config` or beside `openDataSource`, that returns the mode or the refusal. Have `openDataSource`, `sync` and `auth login` all call it. Login would call it with the `api` it is about to write."
}
]
}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 a binary in my directory. I ran it with HOME and the XDG paths pointed at a scratch directory. I ran --help, bare projects, list --json, sync, list --api, create with close --reason, complete, list --status all --json, and three config files: api with a string token_command, api with a list under [sync] token_command, and no config. I searched every repo in the registry for callers of projects, icb projects, drop/close, items tree and token_command.
Standards: I cited cli-design.md § "No args shows help, always", tui.md § "A tool with a CLI opens its TUI as tui, one program with a tab per view", help.md § "One concept, one word — across help, output, errors, JSON, and the verbs themselves" and cli-design.md § "One closed verb vocabulary across every resource". cli-design.md § "Exit codes are 0 success, 2 usage error, other non-zero for failure" covers the 0/1/2/4 codes, and they match it.
Findings: 1 correctness · 2 design · 0 prose
Correctness
contracts.1 main.go:16 · cli/root.go:79 · dotfiles:shell/common/aliases.sh:97 · docs:docs/terminal/how-a-cli-opens-its-tui.md:17 · docs:docs/go/tui-real-binary-harness.md:275
Reach: The pj alias is loaded in every interactive shell from dotfiles shell/common/aliases.sh:97. On the head build, bare projects prints the root help and exits 0. It no longer opens the board.
The pj alias stops opening the board. Before this PR, bare projects ran the TUI in the root RunE. At the head, main.go calls cli.Execute(), and the root is a goclikit namespace with no RunE. I ran the built binary bare. It printed projects — Projects, the items that finish them, ... and the full command list, then exited 0.
Dotfiles defines alias pj='projects'. Typing pj now prints a help screen instead of opening the board. The exit is 0, so nothing flags the change. The PR body names the break in general ("A shell alias or launcher that ran bare projects ... now prints help"). It does not fix the one alias on this machine.
Two fleet docs now describe the old surface:
docs/terminal/how-a-cli-opens-its-tui.md:17listsprojectsas "a program that is only a TUI" that "keeps its own name".docs/go/tui-real-binary-harness.md:275drives the board with-- projects.
The new shape follows cli-design.md § "No args shows help, always" and tui.md § "A tool with a CLI opens its TUI as tui". The fix belongs in the callers. Change the alias to alias pj='projects tui' in the same release. Move projects off the TUI-only list in the TUI doc, and change the harness example to -- projects tui.
Design and architecture
contracts.2 config/config.go:37 · config/config.go:49 · config.example.toml:22 · config.example.toml:36
Reach: A user switching from [sync] to api copies the [sync] token_command string up to the new top-level key. The README tells this user to "set api ... and remove [sync]". The write refusal in cli/data_source.go:161 says the same. The next caller to add a token command has to work out which of the two shapes applies.
token_command now names two keys with different types and parsing rules. The new top-level token_command is a []string argv list run without a shell (config/config.go:37). The existing [sync] token_command is a string split on spaces (config/config.go:49). In config.example.toml both appear 14 lines apart. One is shown as ["printf", ...] and the other as "<cli> auth token".
I followed the migration the refusal sentence asks for and moved the sync form up beside api:
api = "http://127.0.0.1:1"
token_command = "printf {...}"
The command exited 1 with toml: line 2 (last key "token_command"): incompatible types: TOML value has type string; destination has type slice. The reverse fails too. A list under [sync] gives 'sync.token_command' expected type 'string', got unconvertible type '[]interface {}'.
The same pattern appears with api and [sync] api_url. Both hold the address of a projects API without the /api/v1 prefix, under two different names. help.md § "One concept, one word" says this both ways: two different things must not share a word, and one thing must not get two.
Fix: accept both shapes in both places. A string splits on spaces, and a list is argv. Alternatively, keep one shape and refuse the other with a sentence that names the accepted form. Choose one name for the API address, api or api_url, and accept the other as a deprecated alias that warns.
contracts.3 cli/projects.go:275 · cli/root.go:130 · ichrisbirch:cli/internal/cli/projects.go:261
Reach: An agent or person who knows icb projects drop <project> --reason (ichrisbirch cli/internal/cli/projects.go:261) finds no drop in projects. A reader of --json sees closed_at stamped on a completed project and takes close to cover completion as well.
close is a second name for dropping a project. projects close <project> --reason <why> sets the status to dropped. Its help says "Its status becomes dropped". list --status accepts active, someday, completed, dropped, all. The ichrisbirch CLI runs the same operation against the same API as icb projects drop <project> --reason <why>.
The data already uses "close" in a wider sense. I created two projects. I ran close on one and complete on the other, then ran projects list --status all --json. Both came back with closed_at set:
{"name":"Done one","status":"completed","closed_at":"2026-11-07T22:13:06.264000Z"}
{"name":"Paint shed","status":"dropped","closed_at":"2026-11-07T22:12:58.382000Z"}
In the JSON, "closed" means ended either way. The verb close means only dropped. help.md § "One concept, one word" and cli-design.md § "One closed verb vocabulary across every resource" both reach this. The PR's decision ("the verb says what the person does, and the status keeps its name") is what that rule warns against.
Fix: name the verb drop. That matches the dropped status, the --status dropped filter and the existing icb projects drop. If close must stay, make it a hidden alias of drop.
Findings as JSON
{
"used": "I built the PR head into a binary in my directory. I ran it with HOME and the XDG paths pointed at a scratch directory. I ran `--help`, bare `projects`, `list --json`, `sync`, `list --api`, `create` with `close --reason`, `complete`, `list --status all --json`, and three config files: `api` with a string `token_command`, `api` with a list under `[sync] token_command`, and no config. I searched every repo in the registry for callers of `projects`, `icb projects`, `drop`/`close`, `items tree` and `token_command`.",
"standards": "I cited cli-design.md § \"No args shows help, always\", tui.md § \"A tool with a CLI opens its TUI as <tool> tui, one program with a tab per view\", help.md § \"One concept, one word — across help, output, errors, JSON, and the verbs themselves\" and cli-design.md § \"One closed verb vocabulary across every resource\". cli-design.md § \"Exit codes are 0 success, 2 usage error, other non-zero for failure\" covers the 0/1/2/4 codes, and they match it.",
"findings": [
{
"number": "contracts.1",
"section": "correctness",
"sites": [
"main.go:16",
"cli/root.go:79",
"dotfiles:shell/common/aliases.sh:97",
"docs:docs/terminal/how-a-cli-opens-its-tui.md:17",
"docs:docs/go/tui-real-binary-harness.md:275"
],
"reach": "The `pj` alias is loaded in every interactive shell from dotfiles `shell/common/aliases.sh:97`. On the head build, bare `projects` prints the root help and exits 0. It no longer opens the board.",
"body": "**The `pj` alias stops opening the board.** Before this PR, bare `projects` ran the TUI in the root `RunE`. At the head, `main.go` calls `cli.Execute()`, and the root is a goclikit namespace with no `RunE`. I ran the built binary bare. It printed `projects — Projects, the items that finish them, ...` and the full command list, then exited 0.\n\nDotfiles defines `alias pj='projects'`. Typing `pj` now prints a help screen instead of opening the board. The exit is 0, so nothing flags the change. The PR body names the break in general (\"A shell alias or launcher that ran bare `projects` ... now prints help\"). It does not fix the one alias on this machine.\n\nTwo fleet docs now describe the old surface:\n- `docs/terminal/how-a-cli-opens-its-tui.md:17` lists `projects` as \"a program that is only a TUI\" that \"keeps its own name\".\n- `docs/go/tui-real-binary-harness.md:275` drives the board with `-- projects`.\n\nThe new shape follows cli-design.md § \"No args shows help, always\" and tui.md § \"A tool with a CLI opens its TUI as <tool> tui\". The fix belongs in the callers. Change the alias to `alias pj='projects tui'` in the same release. Move `projects` off the TUI-only list in the TUI doc, and change the harness example to `-- projects tui`."
},
{
"number": "contracts.2",
"section": "design",
"sites": [
"config/config.go:37",
"config/config.go:49",
"config.example.toml:22",
"config.example.toml:36"
],
"reach": "A user switching from `[sync]` to `api` copies the `[sync] token_command` string up to the new top-level key. The README tells this user to \"set api ... and remove [sync]\". The write refusal in `cli/data_source.go:161` says the same. The next caller to add a token command has to work out which of the two shapes applies.",
"body": "**`token_command` now names two keys with different types and parsing rules.** The new top-level `token_command` is a `[]string` argv list run without a shell (`config/config.go:37`). The existing `[sync] token_command` is a `string` split on spaces (`config/config.go:49`). In `config.example.toml` both appear 14 lines apart. One is shown as `[\"printf\", ...]` and the other as `\"<cli> auth token\"`.\n\nI followed the migration the refusal sentence asks for and moved the sync form up beside `api`:\n```\napi = \"http://127.0.0.1:1\"\ntoken_command = \"printf {...}\"\n```\nThe command exited 1 with `toml: line 2 (last key \"token_command\"): incompatible types: TOML value has type string; destination has type slice`. The reverse fails too. A list under `[sync]` gives `'sync.token_command' expected type 'string', got unconvertible type '[]interface {}'`.\n\nThe same pattern appears with `api` and `[sync] api_url`. Both hold the address of a projects API without the `/api/v1` prefix, under two different names. help.md § \"One concept, one word\" says this both ways: two different things must not share a word, and one thing must not get two.\n\nFix: accept both shapes in both places. A string splits on spaces, and a list is argv. Alternatively, keep one shape and refuse the other with a sentence that names the accepted form. Choose one name for the API address, `api` or `api_url`, and accept the other as a deprecated alias that warns."
},
{
"number": "contracts.3",
"section": "design",
"sites": [
"cli/projects.go:275",
"cli/root.go:130",
"ichrisbirch:cli/internal/cli/projects.go:261"
],
"reach": "An agent or person who knows `icb projects drop <project> --reason` (ichrisbirch `cli/internal/cli/projects.go:261`) finds no `drop` in `projects`. A reader of `--json` sees `closed_at` stamped on a `completed` project and takes `close` to cover completion as well.",
"body": "**`close` is a second name for dropping a project.** `projects close <project> --reason <why>` sets the status to `dropped`. Its help says \"Its status becomes dropped\". `list --status` accepts `active, someday, completed, dropped, all`. The ichrisbirch CLI runs the same operation against the same API as `icb projects drop <project> --reason <why>`.\n\nThe data already uses \"close\" in a wider sense. I created two projects. I ran `close` on one and `complete` on the other, then ran `projects list --status all --json`. Both came back with `closed_at` set:\n```\n{\"name\":\"Done one\",\"status\":\"completed\",\"closed_at\":\"2026-11-07T22:13:06.264000Z\"}\n{\"name\":\"Paint shed\",\"status\":\"dropped\",\"closed_at\":\"2026-11-07T22:12:58.382000Z\"}\n```\nIn the JSON, \"closed\" means ended either way. The verb `close` means only dropped. help.md § \"One concept, one word\" and cli-design.md § \"One closed verb vocabulary across every resource\" both reach this. The PR's decision (\"the verb says what the person does, and the status keeps its name\") is what that rule warns against.\n\nFix: name the verb `drop`. That matches the `dropped` status, the `--status dropped` filter and the existing `icb projects drop`. If `close` must stay, make it a hidden alias of `drop`."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — fleet-patterns
Used: I cloned PR 19 at head 4305425 and read its diff and body. I compared apiclient/transports.go, cli/api_errors.go and cli/root.go with the matching code in five places: slate's cli/internal/cli (request_retries.go, client.go, root.go), fleet's daemon/retry.go, the client.go files of the ichrisbirch, ypl, meso, nomad and learning CLIs, and goclilogin. I diffed slate's retry functions against the PR's.
Standards: The retry and exit-4 contract comes from cli-design.md § "A client retries a bounded few times, names the server and what chose it, and exits 4 when no answer came". The copying findings cite go.md § "Ask the library to export what you would otherwise copy out of it" and code-quality.md § "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site". Exit codes follow cli-design.md § "One fact gets one answer on every command that reaches it: the same flag meaning, error, exit code and validation".
Findings: 0 correctness · 3 design · 0 prose
Design and architecture
fleet-patterns.1 apiclient/transports.go:35 · apiclient/transports.go:43 · apiclient/transports.go:98 · apiclient/transports.go:129 · slate:cli/internal/cli/request_retries.go:25 · slate:cli/internal/cli/request_retries.go:77 · slate:cli/internal/cli/request_retries.go:108 · fleet:daemon/retry.go:25
Reach: projects' Retrying wraps every API-mode request that cli/data_source.go builds, so all of these code paths run in production. Slate's copy landed in e72a1e3 on 2026-11-04. The next change to the retry rule will be made in one copy and missed in the other, for example retrying a write that carries an Idempotency-Key, as fleet's daemon/retry.go already does. After that, projects and slate treat the same outage differently. Each new API CLI that follows the standard pastes a third or fourth copy.
The retrying transport is slate's file copied line for line
projects' apiclient/transports.go reproduces slate's request_retries.go. It has the same three attempts and the same 20 s cap. It retries the same set of statuses: 429, 502, 503 and 504. Its readWhole, answerCutShort, neverSent, unreachable and randomPause match slate's. I diffed the code from worthRetrying to the end of each file after renaming resp→response and req→request. Only one doc-comment clause differs: slate's randomPause comment ends "as the AWS CLI's standard mode does". Slate keeps the type unexported inside internal/cli, so projects could not import it and pasted it instead.
Three repos now carry this standard's retry logic: fleet:daemon/retry.go, slate and projects. go.md § "Ask the library to export what you would otherwise copy out of it" covers this case. A copy agrees with its original only on the day it is taken. The PR body cites the AWS CLI. It does not say why the code is copied rather than shared.
Fix: move Retrying, Attempts and the classifier into the Go library every one of these CLIs already imports. That library is goclilogin, or goclikit if it should not depend on OAuth. projects and slate then wrap their transport with that one function. If that move is out of scope here, open the library issue and cite it in a comment that names both sites. A "keep in sync" note does not replace the library.
fleet-patterns.2 cli/api_errors.go:56 · slate:cli/internal/cli/client.go:96 · ichrisbirch:cli/internal/cli/client.go:82 · ypl:cli/internal/cli/client.go:98 · meso:cli/internal/cli/client.go:64
Reach: (*dataSource).explain runs on every API-mode failure in projects, so this copy is the error text users see. goclilogin owns HandsTokens, TokenCommandError, IsSessionRejected and the Deployment fields these messages read. Suppose goclilogin adds a credential case or renames a config key. Then every CLI's copy has to be found and edited by hand, and the CLIs that get missed keep printing the old remedy. ichrisbirch's wording has already drifted from projects' and slate's.
The credential-failure wording is the seventh copy of one switch over goclilogin's types
credentialFailure sorts a failed request into three cases: a handed token (d.HandsTokens()), a service client (isService()) and a person. In each case it words a token-command failure, a provider rejection (goclilogin.IsSessionRejected), a 401 and a 403. slate's handleAPIError has the same three cases and almost the same sentences. Examples are "%s refused service client %s — check client_id in %s, and %s", "%s rejected the token %q handed" and "session expired — run X auth login".
I found the same switch in seven CLIs: ichrisbirch (client.go:82–103), ypl (client.go:98), slate, projects, and in reduced form meso, nomad and learning (client.go:55–64). The copies already disagree on wording. ichrisbirch says "the API rejected the token that %q printed; it expects an unexpired access token…". projects says "%s rejected the token %q handed". Only projects adds the RFC 6749 § 5.1 hint for a token command. Every input to this switch is a goclilogin type, so code-quality.md § "Ask whatever owns a fact…" places the wording in goclilogin.
Fix: goclilogin exports one function, for example goclilogin.ExplainCredential(err, d, cliName, isService) error, that returns the worded error or nil. projects calls it from explain. Slate and the other CLIs move to it as they are next changed.
fleet-patterns.3 cli/root.go:158 · cli/api_errors.go:56 · slate:cli/internal/cli/root.go:83 · slate:cli/internal/cli/root.go:233
Reach: An agent or script that composes projects and slate branches on their exit codes. Slate gives an expired login or a refused scope exit 5, which means a person must fix it. projects gives the same credential failure exit 1, the code for a refusal. A wrapper that sends 5 to a login prompt reads projects' 1 as a refused request. Only slate holds the opposite pattern, so this is weak evidence. It is reported because the two copies already disagree.
projects and slate give a refused credential different exit codes
slate's e72a1e3 ("the exit status says who acts…") added statusCredentialRefused = 5. projects copied the retry half of that commit, the transport and exit 4, but not the credential half. Its exitCodeFor returns 1 for every credential failure that credentialFailure words. The PR body asks reviewers to "check … a refused credential on 1". It gives no reason for differing from slate.
cli-design.md § "A client retries a bounded few times…" says only that "a server that answered and refused exits 1". It names no 5. projects therefore matches the written standard, and slate goes further than it. Slate's split carries more information: a caller can tell "fix your login" from "the API said no". But only the standard is binding across the fleet. cli-design.md § "One fact gets one answer on every command that reaches it" asks for one answer.
Fix: choose one contract and record it in the standard. If 5 is right, projects returns 5 when credentialFailure matches, and the standard adds the 5 with slate cited. If it is not, slate drops 5. Either way the exit-code mapping belongs in the shared library proposed in the first finding, so the next CLI does not have to choose again.
Findings as JSON
{
"used": "I cloned PR 19 at head 4305425 and read its diff and body. I compared `apiclient/transports.go`, `cli/api_errors.go` and `cli/root.go` with the matching code in five places: slate's `cli/internal/cli` (`request_retries.go`, `client.go`, `root.go`), fleet's `daemon/retry.go`, the `client.go` files of the ichrisbirch, ypl, meso, nomad and learning CLIs, and goclilogin. I diffed slate's retry functions against the PR's.",
"standards": "The retry and exit-4 contract comes from cli-design.md § \"A client retries a bounded few times, names the server and what chose it, and exits 4 when no answer came\". The copying findings cite go.md § \"Ask the library to export what you would otherwise copy out of it\" and code-quality.md § \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\". Exit codes follow cli-design.md § \"One fact gets one answer on every command that reaches it: the same flag meaning, error, exit code and validation\".",
"findings": [
{
"number": "fleet-patterns.1",
"section": "design",
"sites": [
"apiclient/transports.go:35",
"apiclient/transports.go:43",
"apiclient/transports.go:98",
"apiclient/transports.go:129",
"slate:cli/internal/cli/request_retries.go:25",
"slate:cli/internal/cli/request_retries.go:77",
"slate:cli/internal/cli/request_retries.go:108",
"fleet:daemon/retry.go:25"
],
"reach": "projects' `Retrying` wraps every API-mode request that `cli/data_source.go` builds, so all of these code paths run in production. Slate's copy landed in e72a1e3 on 2026-11-04. The next change to the retry rule will be made in one copy and missed in the other, for example retrying a write that carries an `Idempotency-Key`, as fleet's `daemon/retry.go` already does. After that, `projects` and `slate` treat the same outage differently. Each new API CLI that follows the standard pastes a third or fourth copy.",
"body": "### The retrying transport is slate's file copied line for line\n\nprojects' `apiclient/transports.go` reproduces slate's `request_retries.go`. It has the same three attempts and the same 20 s cap. It retries the same set of statuses: 429, 502, 503 and 504. Its `readWhole`, `answerCutShort`, `neverSent`, `unreachable` and `randomPause` match slate's. I diffed the code from `worthRetrying` to the end of each file after renaming `resp`→`response` and `req`→`request`. Only one doc-comment clause differs: slate's `randomPause` comment ends \"as the AWS CLI's standard mode does\". Slate keeps the type unexported inside `internal/cli`, so projects could not import it and pasted it instead.\n\nThree repos now carry this standard's retry logic: `fleet:daemon/retry.go`, slate and projects. go.md § \"Ask the library to export what you would otherwise copy out of it\" covers this case. A copy agrees with its original only on the day it is taken. The PR body cites the AWS CLI. It does not say why the code is copied rather than shared.\n\n**Fix:** move `Retrying`, `Attempts` and the classifier into the Go library every one of these CLIs already imports. That library is `goclilogin`, or `goclikit` if it should not depend on OAuth. projects and slate then wrap their transport with that one function. If that move is out of scope here, open the library issue and cite it in a comment that names both sites. A \"keep in sync\" note does not replace the library."
},
{
"number": "fleet-patterns.2",
"section": "design",
"sites": [
"cli/api_errors.go:56",
"slate:cli/internal/cli/client.go:96",
"ichrisbirch:cli/internal/cli/client.go:82",
"ypl:cli/internal/cli/client.go:98",
"meso:cli/internal/cli/client.go:64"
],
"reach": "`(*dataSource).explain` runs on every API-mode failure in projects, so this copy is the error text users see. goclilogin owns `HandsTokens`, `TokenCommandError`, `IsSessionRejected` and the `Deployment` fields these messages read. Suppose goclilogin adds a credential case or renames a config key. Then every CLI's copy has to be found and edited by hand, and the CLIs that get missed keep printing the old remedy. ichrisbirch's wording has already drifted from projects' and slate's.",
"body": "### The credential-failure wording is the seventh copy of one switch over goclilogin's types\n\n`credentialFailure` sorts a failed request into three cases: a handed token (`d.HandsTokens()`), a service client (`isService()`) and a person. In each case it words a token-command failure, a provider rejection (`goclilogin.IsSessionRejected`), a 401 and a 403. slate's `handleAPIError` has the same three cases and almost the same sentences. Examples are \"%s refused service client %s — check client_id in %s, and %s\", \"%s rejected the token %q handed\" and \"session expired — run `X auth login`\".\n\nI found the same switch in seven CLIs: ichrisbirch (`client.go:82–103`), ypl (`client.go:98`), slate, projects, and in reduced form meso, nomad and learning (`client.go:55–64`). The copies already disagree on wording. ichrisbirch says \"the API rejected the token that %q printed; it expects an unexpired access token…\". projects says \"%s rejected the token %q handed\". Only projects adds the RFC 6749 § 5.1 hint for a token command. Every input to this switch is a goclilogin type, so code-quality.md § \"Ask whatever owns a fact…\" places the wording in goclilogin.\n\n**Fix:** goclilogin exports one function, for example `goclilogin.ExplainCredential(err, d, cliName, isService) error`, that returns the worded error or nil. projects calls it from `explain`. Slate and the other CLIs move to it as they are next changed."
},
{
"number": "fleet-patterns.3",
"section": "design",
"sites": [
"cli/root.go:158",
"cli/api_errors.go:56",
"slate:cli/internal/cli/root.go:83",
"slate:cli/internal/cli/root.go:233"
],
"reach": "An agent or script that composes `projects` and `slate` branches on their exit codes. Slate gives an expired login or a refused scope exit 5, which means a person must fix it. projects gives the same credential failure exit 1, the code for a refusal. A wrapper that sends 5 to a login prompt reads projects' 1 as a refused request. Only slate holds the opposite pattern, so this is weak evidence. It is reported because the two copies already disagree.",
"body": "### projects and slate give a refused credential different exit codes\n\nslate's e72a1e3 (\"the exit status says who acts…\") added `statusCredentialRefused = 5`. projects copied the retry half of that commit, the transport and exit 4, but not the credential half. Its `exitCodeFor` returns 1 for every credential failure that `credentialFailure` words. The PR body asks reviewers to \"check … a refused credential on 1\". It gives no reason for differing from slate.\n\ncli-design.md § \"A client retries a bounded few times…\" says only that \"a server that answered and refused exits 1\". It names no 5. projects therefore matches the written standard, and slate goes further than it. Slate's split carries more information: a caller can tell \"fix your login\" from \"the API said no\". But only the standard is binding across the fleet. cli-design.md § \"One fact gets one answer on every command that reaches it\" asks for one answer.\n\n**Fix:** choose one contract and record it in the standard. If 5 is right, projects returns 5 when `credentialFailure` matches, and the standard adds the 5 with slate cited. If it is not, slate drops 5. Either way the exit-code mapping belongs in the shared library proposed in the first finding, so the next CLI does not have to choose again."
}
]
}This review does not gate merge.
datapointchris
left a comment
There was a problem hiding this comment.
PR review — G1
Used: I built projects and a scratch server running routes.New over a copy of a seeded SQLite database. I ran the database-mode and API-mode commands (list, next, show, items create, complete, blocked, dependencies, tui --once, tui --json, auth status) and the sync refusals. I ran a Python proxy that forwards a write and then drops the connection. I re-ran TestTheCommandsAnswerTheSameInBothModes, which passed. All config, data and the registry were kept under the review directory.
Standards: I cited cli-design.md § "A client retries a bounded few times, names the server and what chose it, and exits 4 when no answer came". I also read api-design.md § "A value the server owns reaches the client from the server" and testing.md § "Tests ship with the code, not after the bug". The first is cited in G1.1 and G1.3. Neither of the other two governed a finding.
Findings: 3 correctness · 0 design · 0 prose
Correctness
G1.1 cli/api_errors.go:41 · cli/api_errors.go:43 · cli/api_errors.go:49 · cli/api_errors.go:106 · CLAUDE.md:188 · README.md:110
Reach: Any API-mode write that reaches the server and then loses its answer gets this message. That includes a reset, a 60 s requestTimeout and a proxy 502/504 after the upstream committed. A script or agent that retries on exit 4 then repeats the write. Exit 4 is documented as the code a later run can pass, so retrying is the documented response.
A write the API carried out exits 4 as "no answer, 3 attempts", and running it again repeats the write.
I put a proxy in front of the scratch API. It forwards a POST, gets the 201, then shuts the socket. Then I ran projects items create --project "Kitchen remodel" --title "Measure the wall":
error: no answer from the API at http://127.0.0.1:18720 (api in ~/config/projects/config.toml): EOF, 3 attempts
exit 4
The proxy log shows one POST, answered 201. projects show "Kitchen remodel" then lists "Measure the wall" as item 4. The command reported that nothing happened, but the item was created.
The same function produces two separate errors:
- The attempt count is a constant.
unansweredprintsapiclient.Attemptswhether the request was sent once or three times. A write is sent once after the connection opens, and a GET answered 500 is never retried. Both still read "3 attempts". - Exit 4 means "run it again".
root.gocalls it "the one failure where the same command run later can succeed", and the README table agrees. That holds for a read and for a write that never got a connection. It fails for a POST, PATCH or DELETE that was sent. For a create, a second run adds a duplicate item.cli-design.md§ "A client retries a bounded few times…" says a write without a deduplicated request id is not retried. The CLI follows that internally, then tells its caller to do the retry it refused to do itself.
The PR body asks reviewers to "check that no write can be sent twice". Inside the process none is. Through the exit code, one can be.
Fix: have Retrying record how many attempts it made and whether the last one was sent. Print that count in unanswered. When a non-GET was sent and got no answer, say the outcome is unknown and exit 1 rather than 4, for example: the API at … did not answer a create that was sent; check with projects show before running it again. Change the CLAUDE.md line "A 429, a 5xx or a transport error after apiclient.Attempts is no answer" to match.
G1.2 cli/data_source.go:106 · cli/data_source.go:161
Reach: This repo's own .envrc exports PROJECTS_SYNC=true. CLAUDE.md tells every hand run to load it with direnv exec .. A write in that setup, or a config file that sets api alongside it, shows this message.
Both sync refusals blame [sync] in the config file when PROJECTS_SYNC turned sync on.
The config file held only api and token_command, with PROJECTS_SYNC=true in the environment. projects list printed:
error: ~/config/projects/config.toml sets api and [sync] enabled = true, which are two authorities for the same rows; remove [sync] to work through the API, …
The file has no [sync] table, so the advice to remove it cannot be followed. A write with only the variable set says [sync] enabled = true keeps this database a copy of … and remove [sync]. The user goes looking in a file that never set it. cli-design.md § "A client retries a bounded few times…" asks an error to name "the flag, variable or file that chose it". The same PR applies that rule in unanswered, which separates --api from api in <file>.
Fix: check where Sync.Enabled came from. If PROJECTS_SYNC is set, name the variable (PROJECTS_SYNC=true (from the environment)) and tell the user to unset it. Name [sync] in the file only when the file set it.
G1.3 cli/flags.go:115 · cli/flags.go:119 · cli/items.go:326 · cli/item_create.go:125 · cli/item_create.go:156
Reach: Two machines that share one API, where one machine's repos.json is missing a tag the other machine wrote. A reader with no registry file is not affected. A machine that has a registry is refused every tag the file lacks. Sharing one API across machines is what this PR enables.
In API mode, --repo is checked against this machine's registry file, so a tag the API holds can't be filtered.
The PR body says "The ban on a project named for a repo applies in database mode only. The API holds no registry." validateRepoFlag and the items create prompt still load repos.DefaultPath() in both modes. Over the scratch API, I tagged item 3 from a config with no registry: projects items edit 3 --repo dotfiles exited 0. Then I set PROJECTS_REPOS_REGISTRY to a file listing only projects:
$ projects items list --repo dotfiles
error: unknown repo "dotfiles" — …/reg.json lists the registered names
exit 2
The item carries dotfiles on the API and appears in items list. The filter that should select it is refused as a usage error, so a script reading exit 2 treats its own input as wrong.
Fix: in API mode, either skip the registry check or check against the tags the API returns. The items list --status all answer already carries every item's repo. Keep the local registry as the check in database mode, where it describes this machine's data.
Findings as JSON
{
"used": "I built `projects` and a scratch server running `routes.New` over a copy of a seeded SQLite database. I ran the database-mode and API-mode commands (list, next, show, items create, complete, blocked, dependencies, `tui --once`, `tui --json`, `auth status`) and the sync refusals. I ran a Python proxy that forwards a write and then drops the connection. I re-ran `TestTheCommandsAnswerTheSameInBothModes`, which passed. All config, data and the registry were kept under the review directory.",
"standards": "I cited `cli-design.md` § \"A client retries a bounded few times, names the server and what chose it, and exits 4 when no answer came\". I also read `api-design.md` § \"A value the server owns reaches the client from the server\" and `testing.md` § \"Tests ship with the code, not after the bug\". The first is cited in G1.1 and G1.3. Neither of the other two governed a finding.",
"findings": [
{
"number": "G1.1",
"section": "correctness",
"sites": [
"cli/api_errors.go:41",
"cli/api_errors.go:43",
"cli/api_errors.go:49",
"cli/api_errors.go:106",
"CLAUDE.md:188",
"README.md:110"
],
"reach": "Any API-mode write that reaches the server and then loses its answer gets this message. That includes a reset, a 60 s `requestTimeout` and a proxy 502/504 after the upstream committed. A script or agent that retries on exit 4 then repeats the write. Exit 4 is documented as the code a later run can pass, so retrying is the documented response.",
"body": "**A write the API carried out exits 4 as \"no answer, 3 attempts\", and running it again repeats the write.**\n\nI put a proxy in front of the scratch API. It forwards a POST, gets the 201, then shuts the socket. Then I ran `projects items create --project \"Kitchen remodel\" --title \"Measure the wall\"`:\n\n```text\nerror: no answer from the API at http://127.0.0.1:18720 (api in ~/config/projects/config.toml): EOF, 3 attempts\nexit 4\n```\n\nThe proxy log shows one POST, answered 201. `projects show \"Kitchen remodel\"` then lists \"Measure the wall\" as item 4. The command reported that nothing happened, but the item was created.\n\nThe same function produces two separate errors:\n- **The attempt count is a constant.** `unanswered` prints `apiclient.Attempts` whether the request was sent once or three times. A write is sent once after the connection opens, and a GET answered 500 is never retried. Both still read \"3 attempts\".\n- **Exit 4 means \"run it again\".** `root.go` calls it \"the one failure where the same command run later can succeed\", and the README table agrees. That holds for a read and for a write that never got a connection. It fails for a POST, PATCH or DELETE that was sent. For a create, a second run adds a duplicate item. `cli-design.md` § \"A client retries a bounded few times…\" says a write without a deduplicated request id is not retried. The CLI follows that internally, then tells its caller to do the retry it refused to do itself.\n\nThe PR body asks reviewers to \"check that no write can be sent twice\". Inside the process none is. Through the exit code, one can be.\n\nFix: have `Retrying` record how many attempts it made and whether the last one was sent. Print that count in `unanswered`. When a non-GET was sent and got no answer, say the outcome is unknown and exit 1 rather than 4, for example: `the API at … did not answer a create that was sent; check with projects show before running it again`. Change the `CLAUDE.md` line \"A 429, a 5xx or a transport error after `apiclient.Attempts` is no answer\" to match."
},
{
"number": "G1.2",
"section": "correctness",
"sites": [
"cli/data_source.go:106",
"cli/data_source.go:161"
],
"reach": "This repo's own `.envrc` exports `PROJECTS_SYNC=true`. CLAUDE.md tells every hand run to load it with `direnv exec .`. A write in that setup, or a config file that sets `api` alongside it, shows this message.",
"body": "**Both sync refusals blame `[sync]` in the config file when `PROJECTS_SYNC` turned sync on.**\n\nThe config file held only `api` and `token_command`, with `PROJECTS_SYNC=true` in the environment. `projects list` printed:\n\n```text\nerror: ~/config/projects/config.toml sets api and [sync] enabled = true, which are two authorities for the same rows; remove [sync] to work through the API, …\n```\n\nThe file has no `[sync]` table, so the advice to remove it cannot be followed. A write with only the variable set says `[sync] enabled = true keeps this database a copy of …` and `remove [sync]`. The user goes looking in a file that never set it. `cli-design.md` § \"A client retries a bounded few times…\" asks an error to name \"the flag, variable or file that chose it\". The same PR applies that rule in `unanswered`, which separates `--api` from `api in <file>`.\n\nFix: check where `Sync.Enabled` came from. If `PROJECTS_SYNC` is set, name the variable (`PROJECTS_SYNC=true (from the environment)`) and tell the user to unset it. Name `[sync]` in the file only when the file set it."
},
{
"number": "G1.3",
"section": "correctness",
"sites": [
"cli/flags.go:115",
"cli/flags.go:119",
"cli/items.go:326",
"cli/item_create.go:125",
"cli/item_create.go:156"
],
"reach": "Two machines that share one API, where one machine's `repos.json` is missing a tag the other machine wrote. A reader with no registry file is not affected. A machine that has a registry is refused every tag the file lacks. Sharing one API across machines is what this PR enables.",
"body": "**In API mode, `--repo` is checked against this machine's registry file, so a tag the API holds can't be filtered.**\n\nThe PR body says \"The ban on a project named for a repo applies in database mode only. The API holds no registry.\" `validateRepoFlag` and the `items create` prompt still load `repos.DefaultPath()` in both modes. Over the scratch API, I tagged item 3 from a config with no registry: `projects items edit 3 --repo dotfiles` exited 0. Then I set `PROJECTS_REPOS_REGISTRY` to a file listing only `projects`:\n\n```text\n$ projects items list --repo dotfiles\nerror: unknown repo \"dotfiles\" — …/reg.json lists the registered names\nexit 2\n```\n\nThe item carries `dotfiles` on the API and appears in `items list`. The filter that should select it is refused as a usage error, so a script reading exit 2 treats its own input as wrong.\n\nFix: in API mode, either skip the registry check or check against the tags the API returns. The `items list --status all` answer already carries every item's repo. Keep the local registry as the check in database mode, where it describes this machine's data."
}
]
}This review does not gate merge.
datapointchris
left a comment
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 · 5 design · 0 prose
Design and architecture
synthesis.1 explains G1.1 · failure-paths.1
A failed write exits 4 as "no answer" although the request was sent. The explain/noAnswerError path in cli/api_errors.go does not separate a sent write from an unsent one, so exit 4 invites a retry that repeats the write.
synthesis.2 explains G1.2 · code-architecture.3
The mode choice and the api-versus-sync conflict are decided at several sites, each with its own predicate and its own refusal text. The refusal text names the wrong source when PROJECTS_SYNC set sync, and no one function owns the answer.
synthesis.3 explains fleet-patterns.1 · fleet-patterns.2 · fleet-patterns.3
Logic that should have one owner in a shared library (goclilogin or goclikit) is copied across CLIs: the retry transport, the credential-failure wording and the exit-code mapping. The copies drift.
synthesis.4 explains system-design.1 · contracts.1
Bare projects no longer opens the board, because it moved to projects tui. Callers outside this repo, the pj alias and fleet docs, were not updated.
synthesis.5 explains stale-prose.1 · stale-prose.2 · stale-prose.3 · stale-prose.4
Prose and comments in the repo carry values or history that the code owns or that came from another repo. They go stale or are false: counts, lists, narrated changes and measurements.
One defect, raised more than once
G1.1 · failure-paths.1: A sent write that gets a 5xx or a lost answer exits 4 with a constant "3 attempts" message in cli/api_errors.go, so a retry on exit 4 repeats the write.
system-design.1 · contracts.1: The pj alias, alias pj='projects' in dotfiles, prints help instead of opening the board after bare projects stopped opening the TUI.
Shapes as JSON
{
"shapes": [
{
"number": "synthesis.1",
"section": "design",
"explains": [
"G1.1",
"failure-paths.1"
],
"body": "A failed write exits 4 as \"no answer\" although the request was sent. The explain/noAnswerError path in `cli/api_errors.go` does not separate a sent write from an unsent one, so exit 4 invites a retry that repeats the write."
},
{
"number": "synthesis.2",
"section": "design",
"explains": [
"G1.2",
"code-architecture.3"
],
"body": "The mode choice and the api-versus-sync conflict are decided at several sites, each with its own predicate and its own refusal text. The refusal text names the wrong source when `PROJECTS_SYNC` set sync, and no one function owns the answer."
},
{
"number": "synthesis.3",
"section": "design",
"explains": [
"fleet-patterns.1",
"fleet-patterns.2",
"fleet-patterns.3"
],
"body": "Logic that should have one owner in a shared library (goclilogin or goclikit) is copied across CLIs: the retry transport, the credential-failure wording and the exit-code mapping. The copies drift."
},
{
"number": "synthesis.4",
"section": "design",
"explains": [
"system-design.1",
"contracts.1"
],
"body": "Bare `projects` no longer opens the board, because it moved to `projects tui`. Callers outside this repo, the `pj` alias and fleet docs, were not updated."
},
{
"number": "synthesis.5",
"section": "design",
"explains": [
"stale-prose.1",
"stale-prose.2",
"stale-prose.3",
"stale-prose.4"
],
"body": "Prose and comments in the repo carry values or history that the code owns or that came from another repo. They go stale or are false: counts, lists, narrated changes and measurements."
}
],
"duplicates": [
{
"defect": "A sent write that gets a 5xx or a lost answer exits 4 with a constant \"3 attempts\" message in `cli/api_errors.go`, so a retry on exit 4 repeats the write.",
"findings": [
"G1.1",
"failure-paths.1"
]
},
{
"defect": "The `pj` alias, `alias pj='projects'` in dotfiles, prints help instead of opening the board after bare `projects` stopped opening the TUI.",
"findings": [
"system-design.1",
"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": 19,
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"experiment": "none",
"dispatch": "20261107T221009Z-pr-review",
"roster": "67aaba2ee4a38bd9a173eff3b1b7fe9b066b2cd4",
"harness": "9fb899ec802ea87518b69760bc1354bcff4e0c7c",
"machine": "archlinux",
"started": "2026-11-07T22:11:23Z",
"finished": "2026-11-07T22:16:28Z",
"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": "a0f85e3b82b4",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 271,
"tokens": {
"input": 90,
"cache_write": 82200,
"cache_read": 2742586,
"output": 19712
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 90,
"cache_write": 82200,
"cache_read": 2742586,
"output": 19712
}
},
"session": "01a1b810-da6a-7ad7-a284-590dcc8415ff",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815250778",
"review_id": 5815250778,
"findings": {
"correctness": 3,
"design": 0,
"prose": 0
},
"numbers": [
"G1.1",
"G1.2",
"G1.3"
],
"rules": {
"proposed": 1,
"keys": [
"rule/cli-design/a-write-that-was-sent-and-got-no-answer-reports-its-outcome-as-unknown-never-as-retry-safe"
],
"filed": true,
"why": "",
"path": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records/G1.rules.json"
},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:23Z",
"finished": "2026-11-07T22:15:57Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "3700d72236e3",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 90,
"tokens": {
"input": 26,
"cache_write": 39578,
"cache_read": 392434,
"output": 5753
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 26,
"cache_write": 39578,
"cache_read": 392434,
"output": 5753
}
},
"session": "01a1b810-e7ea-73a9-8996-a9d9c3f79adb",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815239254",
"review_id": 5815239254,
"findings": {
"correctness": 0,
"design": 1,
"prose": 0
},
"numbers": [
"useless-test.1"
],
"rules": {
"proposed": 1,
"keys": [
"rule/testing/a-test-of-a-reread-changes-the-source-between-the-two-reads"
],
"filed": true,
"why": "",
"path": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records/useless-test.rules.json"
},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:30Z",
"finished": "2026-11-07T22:13:07Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "d9be86eb4324",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 98,
"tokens": {
"input": 30,
"cache_write": 57196,
"cache_read": 597358,
"output": 7872
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 30,
"cache_write": 57196,
"cache_read": 597358,
"output": 7872
}
},
"session": "01a1b810-e468-7cbe-9cc2-04c82cf42b8e",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815239523",
"review_id": 5815239523,
"findings": {
"correctness": 0,
"design": 3,
"prose": 0
},
"numbers": [
"code-architecture.1",
"code-architecture.2",
"code-architecture.3"
],
"rules": {},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:28Z",
"finished": "2026-11-07T22:13:11Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "eae343d1d8d5",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 183,
"tokens": {
"input": 34,
"cache_write": 37993,
"cache_read": 505282,
"output": 8310
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 34,
"cache_write": 37993,
"cache_read": 505282,
"output": 8310
}
},
"session": "01a1b810-e624-743d-8276-a37112f8b985",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815243716",
"review_id": 5815243716,
"findings": {
"correctness": 0,
"design": 3,
"prose": 0
},
"numbers": [
"fleet-patterns.1",
"fleet-patterns.2",
"fleet-patterns.3"
],
"rules": {
"proposed": 1,
"keys": [
"rule/go/client-behaviour-that-a-standard-fixes-for-every-api-cli-lives-in-one-shared-go-library-not-in-each-clis-internal-package"
],
"filed": true,
"why": "",
"path": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records/fleet-patterns.rules.json"
},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:29Z",
"finished": "2026-11-07T22:14:38Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "c284e39c894a",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 53,
"tokens": {
"input": 22,
"cache_write": 32920,
"cache_read": 323492,
"output": 4137
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 22,
"cache_write": 32920,
"cache_read": 323492,
"output": 4137
}
},
"session": "01a1b810-e997-7467-a54c-7160fc55934e",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815237369",
"review_id": 5815237369,
"findings": {
"correctness": 1,
"design": 0,
"prose": 0
},
"numbers": [
"system-design.1"
],
"rules": {},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:37Z",
"finished": "2026-11-07T22:12:35Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "50506a05f936",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 90,
"tokens": {
"input": 24,
"cache_write": 50580,
"cache_read": 417175,
"output": 7097
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 24,
"cache_write": 50580,
"cache_read": 417175,
"output": 7097
}
},
"session": "01a1b810-dce6-7f66-9cd0-7be8e84ac0c8",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815238798",
"review_id": 5815238798,
"findings": {
"correctness": 0,
"design": 0,
"prose": 4
},
"numbers": [
"stale-prose.1",
"stale-prose.2",
"stale-prose.3",
"stale-prose.4"
],
"rules": {},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:24Z",
"finished": "2026-11-07T22:12:56Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "56bdd640764b",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 44,
"tokens": {
"input": 12,
"cache_write": 17330,
"cache_read": 86699,
"output": 3238
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 12,
"cache_write": 17330,
"cache_read": 86699,
"output": 3238
}
},
"session": "01a1b810-de99-7a11-aa0a-76e52982d822",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815236236",
"review_id": 5815236236,
"findings": {
"correctness": 2,
"design": 0,
"prose": 0
},
"numbers": [
"failure-paths.1",
"failure-paths.2"
],
"rules": {},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:26Z",
"finished": "2026-11-07T22:12:13Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
},
{
"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": "88b65626bceb",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 126,
"tokens": {
"input": 32,
"cache_write": 50287,
"cache_read": 616479,
"output": 8268
},
"tokens_from": "result",
"tokens_by_model": {
"claude-opus-5-5": {
"input": 32,
"cache_write": 50287,
"cache_read": 616479,
"output": 8268
}
},
"session": "01a1b810-e20b-787b-a442-03b509fd38ee",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815240777",
"review_id": 5815240777,
"findings": {
"correctness": 1,
"design": 2,
"prose": 0
},
"numbers": [
"contracts.1",
"contracts.2",
"contracts.3"
],
"rules": {},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:11:26Z",
"finished": "2026-11-07T22:13:37Z",
"account": "fleetizen",
"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": "40dcb5a50df0",
"status": "ran",
"why": "",
"attempts": 1,
"seconds": 6,
"tokens": {
"input": 2,
"cache_write": 14691,
"cache_read": 1433,
"output": 744
},
"tokens_from": "result",
"tokens_by_model": {
"claude-sonnet-5-5": {
"input": 2,
"cache_write": 14691,
"cache_read": 1433,
"output": 744
}
},
"session": "01a1b815-66ae-76da-bab8-88f17e1a54e3",
"worklog": "2402.1",
"worklog_refused": "",
"claude_code": "2.1.319",
"review_url": "https://github.com/datapointchris/projects/pull/19#pullrequestreview-5815253919",
"review_id": 5815253919,
"findings": {
"correctness": 0,
"design": 5,
"prose": 0
},
"numbers": [
"synthesis.1",
"synthesis.2",
"synthesis.3",
"synthesis.4",
"synthesis.5"
],
"rules": {},
"head": "4305425bb4363604c1a879d85ed38eafd47896e7",
"kept": false,
"records": "/home/chris/.cache/run-pr-reviewers/datapointchris-projects-pr19-round1-20261107T221009Z/records",
"started": "2026-11-07T22:16:19Z",
"finished": "2026-11-07T22:16:28Z",
"account": "fleetizen",
"api_key_source": "none",
"machine": "archlinux"
}
],
"rules": {
"proposed": 3,
"keys": [
"rule/cli-design/a-write-that-was-sent-and-got-no-answer-reports-its-outcome-as-unknown-never-as-retry-safe",
"rule/go/client-behaviour-that-a-standard-fixes-for-every-api-cli-lives-in-one-shared-go-library-not-in-each-clis-internal-package",
"rule/testing/a-test-of-a-reread-changes-the-source-between-the-two-reads"
],
"unfiled": []
},
"kept": []
} |
…hived ones The service and the API client each filtered archived items out of a project's list and a search, and each sorted the archive. The two copies had to agree for the board to read the same in both modes. model.WithoutArchived and model.SortArchivedForBoard are now the one statement of each rule, and both backends call them. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1
The api-mode flow pressed R against an API nothing else wrote to, so its frame matched the one before it and could not fail. The model test writes an item behind the board's back, checks it is absent, presses R, and checks it arrived. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1
…tory The comments cited a test that does not exist, measurements taken while building it, and earlier drafts. They now state what the code does and why. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1
…es every command Exit 4 tells a caller that running the command again is safe. A write that was sent and got no answer, or got a 5xx, may have been carried out, so it now exits 1 and says to check before running it again. Retrying returns apiclient.Unanswered with the method, the attempts made and whether any try was sent. A read, a write that never left and a 429 still exit 4, and the attempt count printed is the real one. The token is fetched outside the retries. oauth2.Transport wraps Retrying, and a token the provider or token_command did not hand is worded with whichever it was. decideMode reads goclilogin's parse of api, so the mode and the login read one setting. chooseMode is the one sync check that every command, projects sync and auth login take, and its refusal names PROJECTS_SYNC when the environment turned sync on. --repo is checked against this machine's registry in database mode only, because an API holds tags written from every machine. config refuses a token_command written in the other key's shape and names the shape it takes. The README points at --help for the verb tree and states what exit 4 promises. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1
projectswas a board and nothing else. A script or an agent could not list, create or finish an itemwithout driving the TUI. The board itself could only read a SQLite database on the machine running it,
so working through the projects API meant a synced copy that a pull could empty.
This gives
projectsa command line, and lets the config file choose where every command and theboard read and write. With an
apikey, they go to the projects API and open no database. Withoutone, the commands run the API's own routes inside the process over the SQLite database. A refusal, an
exit code and a
--jsonanswer are then the same in both modes. Bareprojectsprints help, and theboard moves to
projects tui.Session 17043598-5f7e-48e2-b1f3-970a6fc9750e.
What to look at
cli/data_source.go—decideModereads goclilogin's parse ofapi, andchooseModeis the onecheck against sync that every command,
projects syncandauth logintake. Also the writerefusal with sync on, and the request and token deadlines. Check that no path opens a database in
API mode.
apiclient/transports.go—InProcesshands a request to a handler with no socket.Retryingmakes up to three attempts. A read retries any failure to reach the API, an answer cut short, and a
429, 502, 503 or 504. A write retries only a connection that never opened. On failure it returns
Unanswered, which records the method, the attempts made and whether any try was sent. Check thatno write can be sent twice.
cli/api_errors.goexplainandcli/root.goexitCodeFor— the mapping to exit 0, 1, 2 and 4.Check that a 422 lands on 2 and a refused credential on 1. A read with no answer, a 429 or a 5xx
lands on 4, and so does a write that never left. A write sent with no answer, or answered with a
5xx, lands on 1 and says it may have been carried out.
tui/app.go—Where, the API-mode reread on the sync tick and onR, andtaskSummary, whichreads every task once instead of once per listed item.
cli/schemas/— the six documents this tool composes. Each embeds the API's shapes and addsschema_version.both_modes_test.go,testdata/flows/api-mode.keysandTestRInAPIModeShowsAnItemAnotherMachineWrotein
tui/app_test.go. The stand-in API isroutes.Newover a seeded SQLite database in anhttptest.Server.How it was verified
The both-modes test runs the built binary in each mode over the same seeded data. It creates an item,
checks five
--jsonreads andauth status --jsonagainstcli/schemas, and compares the titles eachmode lists. Changing one expected exit code made it fail with the binary's real stderr.
The R test writes an item behind the board's back, checks it is absent, presses
R, and checks itarrived. It failed with
R's reread removed.The refusals and failures, run against scratch config directories. The socket on 18765 reads a request
and closes the connection without answering:
The client binary links no server dependency.
go list -deps ./api/routes ./api/wire | rg -c 'pgx|goose|goapiauth|goclilogin'prints nothing.go list -deps .lists one of them,goapiauth/clientid, which goclilogin imports and which itself imports nothing.A scratch test counted the requests one API-mode board read makes over the fixture: 8, none of them a
per-item task read.
What changes
projectsprints help. The board isprojects tui. A shell alias or launcher that ran bareprojectsto open the board now prints help.api,issuer,client_idandtoken_commandare new bare keys.projects auth login <url>writes the first three.
apiset sends every command and the board to that API.token_commandis a list of arguments printing a JSON token response.[sync] token_commandstays one string printing a bare token. Either written in the other's shape isrefused, naming the shape it takes.
apibeside sync stops every command. The refusal namesPROJECTS_SYNCwhen the environmentturned sync on, and
[sync]in the config file otherwise.writes and pushes.
projects syncrefuses whenapiis set.--repoagainst the repo registry, applyin database mode only. The API holds no registry, and holds tags written from every machine.
write that was sent and got no answer, or got a 5xx, exits 1, because it may have been carried out.
APIand the host. It rereads everysync.interval, andRrereads at once.PROJECTS_CLIENT_SECRETmakes every command authenticate as a service, with theclient-credentials grant and the
projects.items.readscope.Decisions, and what they rejected
apiclient.Client. Rejected: commands overservicein database mode and over HTTP in API mode,which is two implementations of every command and two sets of refusal sentences.
RunE.help,completionandversionthen opennothing, so no skip list is needed. Rejected: a
PersistentPreRunEthat opens for every command.left keep it. A write the API may have carried out exits 1 and says so, because a caller that
retries on 4 would otherwise repeat it.
separate code for a credential, which the written CLI design does not name.
oauth2.TransportwrapsRetrying, so a loginprovider that does not answer is reported as the provider, once, rather than retried as the API.
close --reasonsets a project todropped. The verb says what the person does, and the statuskeeps its name.
--jsonedges and roots name items by id. An item created while a syncedAPI was unreachable has no number yet.
auth status --jsonsendssession,backendandkeyring_noteasnullwhen absent, matchingthe API's rule that an empty value is
null.prompt/. The CLIs that already have one keep itinternal to their module.
ListAllTasksjoinsbackend.Backend, besideListMembershipsandListDependencies. OverHTTP at about 70 ms a request, one read per listed item cost seconds on every board refresh.
line. That fails the requirement that a fresh install works with no server.
The review
Round 1: #19 (comment)
Retryingreturnsapiclient.Unanswered, recording the method, the attempts madeand whether any try was sent. A write sent with no answer, or answered with a 5xx, exits 1 and says
it may have been carried out. A read, a write that never left and a 429 exit 4.
PROJECTS_SYNC=true in the environmentwhen the environmentturned sync on, and tell the reader to unset it.
--repois checked against the registry in database mode only. In API mode thedata source answers an empty registry, which refuses nothing.
oauth2.TransportwrapsRetrying, so a token is fetched once perrequest and never retried as an API failure.
noTokenwords a failed fetch with the issuer or thetoken_command, and a provider that did not answer exits 4.nextprints a capped list, the prompt comment citing atest that does not exist is gone, and
defaultNextItemLimit's comment says what it caps.projects --helpand three examples, thenextlinenames no count, and the retry paragraph and the diagram name no attempt count.
the code does rather than what an earlier version did.
comments.
Rstep and its two goldens are deleted.TestRInAPIModeShowsAnItemAnotherMachineWrotecoversRinstead, by writing an item behind theboard.
model.WithoutArchivedandmodel.SortArchivedForBoardeach stateone board rule, and the service and the API client call them.
api, so one parser decidesit.
configkeeps the login fields only so an unknown key still refuses. Both packages compute thefile path by the same rule,
XDG_CONFIG_HOMEthen~/.config, andconfigimporting gocliloginwould link it into the API's routes.
chooseModeis the one check, called by every command,projects syncandauth login.tuiverb until this releases. So the shellalias that ran bare
projectsand the two pages that describe opening the board change once therelease installs.
configrefuses atoken_commandwritten in the other key's shape and namesthe shape it takes. The README states both.
closeis in the verb list this work was asked to build.completeisits own verb beside it, and
closed_atmarks both because both close the project.are filed against it.
and is filed against it.
transport. This keeps 1, the code the written CLI design names.
projectsopening the board at aterminal, because a CLI that has commands opens its board with
tui.Risk and rollback
A release cuts a minor, because
feat!counts as a minor in.semrelrc. The API image builds only./api, which imports none of the new packages.A revert restores bare
projectsas the board. A config file thatauth loginalready wroteapi,issuerandclient_idinto is then refused by the older binary, which rejects keys it does notread. Removing those three lines recovers it.
What this does not do
apileaves its databasewhere it is.
[sync]still keeps a copy for the board.a time. With six active projects and four blocked items that is about fifteen requests a refresh.