diff --git a/CHECKLIST.md b/CHECKLIST.md index ce81ff6..b6020b5 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -5,7 +5,8 @@ Tick each line. Nothing after the first unchecked line should be started early. ## Before you start - [x] Paperclip answers: `curl -s -H "Authorization: Bearer $PAPERCLIP_BOARD_KEY" http://127.0.0.1:3100/api/health` → 200. -- [ ] Go is installed: `go version` → **still not installed** (checked 2026-11-10). Needed before Milestone 2. +- [x] Go is installed: `go version` → `go1.26.5` from `~/.nix-profile` (2026-11-10). The + NixOS config change is staged but not rebuilt; see `docs/ENVIRONMENT-FACTS.md`. - [x] You are using the **user** key (`PAPERCLIP_BOARD_KEY`), not the agent key. ## Milestone 1 — one task, really run (no interface) — PASSED 2026-11-10 @@ -30,9 +31,14 @@ been measured. ## Milestone 2 — task tree and reply box -- [ ] Create the Go module in `tui/`. -- [ ] Screen 1 reads tasks for one project; blockers read **per task** (`/api/issues/{id}/diagnostics/blockers`), never from the list. -- [ ] Screen 4 shows the thread and posts a reply with the user key. +- [x] Create the Go module in `tui/`. → `tui/`, module `paperclip-harness/tui`, Bubble Tea + + Lipgloss + BubbleZone. `go build ./...` and `go vet ./...` are clean. +- [x] Screen 1 reads tasks for one project; blockers read **per task** (`/api/issues/{id}/diagnostics/blockers`), never from the list. + → seen in a real terminal: `TODO (1) / BLOCKED (1) / DONE (2)`, with + `[AUD-161 todo]` shown against the blocked task. Resize shows "too small" below + 56×12 instead of a broken tree. +- [ ] Screen 4 shows the thread and posts a reply with the user key. → client call `PostComment` + exists; the screen does not. - [ ] Pass: move a blocked task forward from the terminal, no browser, no shell. ## Milestone 3 — add a task, watch it work diff --git a/README.md b/README.md index aa29c3e..205bee4 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,23 @@ Nothing else. - Paperclip server running locally on `http://127.0.0.1:3100` (it is; 685 endpoints). - `PAPERCLIP_BOARD_KEY` in the environment — the **user** key. The terminal program uses this one. An agent key cannot write outside a heartbeat run. -- Go toolchain — **not installed yet**; add `go` to `/etc/nixos/configuration.nix` before - Milestone 2. See `docs/ENVIRONMENT-FACTS.md`. +- Go toolchain — **now installed** (`go1.26.5`, from `nix profile`). The NixOS config change + that makes it permanent is staged in `/etc/nixos/configuration.nix` and needs one + `sudo nixos-rebuild switch`. See `docs/ENVIRONMENT-FACTS.md`. - Pi, which is already the worker adapter on your Paperclip agent. +## Running the terminal program + +```bash +cd tui +go run . -project spike-2026-11-10 # full-screen task tree +go run . -project spike-2026-11-10 -dump # same data as plain text, no interface +``` + +The project can be an id or its url key. Keys inside the program: arrows or Tab and +Shift-Tab move, `r` re-reads from Paperclip, `q` quits. A bare Esc is left free, because in +TUIOS it has to close a box. + ## Rules for this project - No interface work before a real task has run end to end. diff --git a/docs/ENVIRONMENT-FACTS.md b/docs/ENVIRONMENT-FACTS.md index 2cec9f8..fdfd92c 100644 --- a/docs/ENVIRONMENT-FACTS.md +++ b/docs/ENVIRONMENT-FACTS.md @@ -59,32 +59,49 @@ A wake can still be refused without any error reaching us. Read and a plain-English diagnosis. Refusal reasons seen in the documentation: the agent is paused, its budget is exhausted, its cooldown has not elapsed, or its run slot is full. -## Isolated workspaces are switched off on this instance +## Isolated workspaces: switched on 2026-11-10, and measured working -The project was created asking for a git worktree per task (`executionWorkspacePolicy` -with `workspaceStrategy.type: "git_worktree"`). **No worktree was made.** The run worked -directly in `/tmp/pc-sandbox-2026-11-10` and committed onto `main`. The task's -`executionWorkspaceId` and `currentExecutionWorkspace` both read `null`. +The instance first had them off. With the flags off, the project's request for a git +worktree per task was silently ignored: `AUD-159` worked in `/tmp/pc-sandbox-2026-11-10` +on `main`, and the task's `executionWorkspaceId` read `null`. -`GET /api/instance/settings/experimental` returns: +Then, with the person's approval, two experimental settings were switched on: ``` -"enableIsolatedWorkspaces": false, -"enableIsolatedWorkspacesByDefault": false, -"enableWorktreeRunExecution": false, +PATCH /api/instance/settings/experimental +{"enableIsolatedWorkspaces": true, "enableWorktreeRunExecution": true} +→ 200 ``` -With `enableIsolatedWorkspaces` false, Paperclip discards a task's workspace settings -before the run is dispatched, so the mode falls back to the shared project folder. The -worktree code is present in the server (`git worktree add` in -dist/services/workspace-runtime.js). Switching it on is an instance-wide experimental -setting and a decision for the person, not a setting for one project. +`enableIsolatedWorkspacesByDefault` was deliberately **left off**. That flag would give +every project without a policy of its own a worktree, which would change the other nine +projects on this instance. Only projects that ask for one now get one. + +The next task (`AUD-160`) made a real worktree, and Paperclip posted a "Workspace Ready" +comment on the task naming it: + +``` +strategy git_worktree +branch AUD-160-milestone-1-t2-read-hello-txt +cwd /tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-160-milestone-1-t2-read-hello-txt +baseRef main +deliveryState merged_by_ancestry (no commit was needed; the branch equals main) +``` + +`git worktree list` in the sandbox repository shows that path on that branch. So the +worktree is Paperclip's job after all, once the instance allows it, and the terminal +program never needs to run `git worktree` itself. ## Toolchain -- **Go is not installed on this machine.** `go version` → not found. The terminal program - needs it. Under Nix, add `go` to `/etc/nixos/configuration.nix` and rebuild; do not - install it by hand. +- **Go is installed** (2026-11-10), by `nix profile install nixpkgs#go` → `go1.26.5` at + `/home/sam/.nix-profile/bin/go`. That is the quick path: it works now, and it is not part + of the system closure. +- The durable path is also prepared: `go` and `gopls` were added to + `environment.systemPackages` in `/etc/nixos/configuration.nix`. `nix flake check` passes + and the dry-run build evaluates. **The rebuild has not been run**, because `sudo` needs a + password here. One command finishes it: `sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop`. + Until then the profile copy is what runs. ## The new repository diff --git a/docs/PAPERCLIP-API-NOTES.md b/docs/PAPERCLIP-API-NOTES.md index f680dd0..40f23a7 100644 --- a/docs/PAPERCLIP-API-NOTES.md +++ b/docs/PAPERCLIP-API-NOTES.md @@ -220,3 +220,57 @@ step. That is a decision to put to the person, not a bug to work around. `AUD-160` stayed `todo`, unassigned, blocked by a task that is now `done`. Nothing woke, because no agent is assigned to it. Whether the `issue_blockers_resolved` wake fires for an assigned dependent task is still unmeasured. + +--- + +# Worktrees switched on, measured, 2026-11-10 + +The first worktree attempt proved the flags, not the strategy: with +`enableIsolatedWorkspaces: false` the project's `git_worktree` policy was discarded before +dispatch. That is recorded above and is worth keeping, because it is what an operator will +see on any fresh instance. + +After switching on `enableIsolatedWorkspaces` and `enableWorktreeRunExecution` (and leaving +`enableIsolatedWorkspacesByDefault` off, so the other projects are untouched), the same +project produced a real worktree on the next task: + +| Fact | Value | +|---|---| +| Task | `AUD-160`, run `11a94551-dd12-4b13-92fe-835ea63d7753` | +| Workspace id | `7900dbf1-692c-4217-aebb-5d539c30aa98` | +| Mode / strategy | `isolated_workspace` / `git_worktree` | +| Branch | `AUD-160-milestone-1-t2-read-hello-txt` | +| Path | `/tmp/pc-sandbox-2026-11-10/.paperclip/worktrees/AUD-160-milestone-1-t2-read-hello-txt` | +| Base | `main` at `b093301` | +| Delivery state | `merged_by_ancestry` (the task made no commit) | + +Paperclip wrote its own comment on the task, titled **Workspace Ready**, naming the +strategy, the branch and the working directory. `git worktree list` shows the new path, and +`git branch` shows the new branch. The file `hello.txt` from the first task was present in +the worktree, so the branch really was cut from the finished work. + +Two consequences worth remembering: + +- **Name the branch with a template.** The default is `{{issue.identifier}}-{{slug}}`; + usable placeholders include `{{issue.identifier}}`, `{{slug}}`, and the project and agent. + Without a template every worktree of the same task would land on the same name. +- **`merged_by_ancestry` is not a merge.** A task that changes nothing leaves its branch at + the base commit, and Paperclip marks it delivered because the branch adds nothing. A task + that does commit will stay `unmerged` until something merges it. That is the state the + review gate will read. + +## Budgets as they stand + +Read from `GET /api/companies/{companyId}/agents`: + +| Agent | Monthly budget (cents) | Spent (cents) | +|---|---|---| +| Builder | 5000 | 2 | +| Chief of Staff | 5000 | 0 | +| System | 3000 | 0 | +| researcher | 2000 | 0 | +| Documenter | 2000 | 0 | +| Hub | 0 | 1 | + +The company's `budgetMonthlyCents` is 0. There is no currency field anywhere in the payload: +Paperclip counts cents and does not name a currency, so "5000" is a number, not "$50 AUD". diff --git a/tui/.gitignore b/tui/.gitignore new file mode 100644 index 0000000..41c16d1 --- /dev/null +++ b/tui/.gitignore @@ -0,0 +1,3 @@ +# built binaries and local caches +/harness +/tui diff --git a/tui/go.mod b/tui/go.mod new file mode 100644 index 0000000..5fe9aad --- /dev/null +++ b/tui/go.mod @@ -0,0 +1,29 @@ +module paperclip-harness/tui + +go 1.26.5 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/lrstanley/bubblezone v1.0.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/tui/go.sum b/tui/go.sum new file mode 100644 index 0000000..86980ed --- /dev/null +++ b/tui/go.sum @@ -0,0 +1,45 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/tui/main.go b/tui/main.go new file mode 100644 index 0000000..90f7e9f --- /dev/null +++ b/tui/main.go @@ -0,0 +1,86 @@ +// Command harness is the terminal front end for the Paperclip coding factory. +// +// This file only wires flags and starts the program. The screens live in +// model.go. Every fact the program shows comes from the Paperclip API; nothing +// is stored locally. +package main + +import ( + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "paperclip-harness/tui/paperclip" +) + +func main() { + project := flag.String("project", "", "project id or url key (for example spike-2026-11-10)") + dump := flag.Bool("dump", false, "print the task tree as plain text and exit (no full-screen interface)") + flag.Parse() + + client, err := paperclip.New() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if !client.UsingUserKey() { + fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused") + } + + proj, err := client.ResolveProject(*project) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if *dump { + if err := dumpTree(client, proj); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + // Alternate screen: TUIOS keeps its own scrollback, and a full-screen + // program that redraws in place must not fight it. + p := tea.NewProgram(newModel(client, proj), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// dumpTree prints the tree without starting the interface. It is how the data +// path is checked from a script or over ssh. +func dumpTree(c *paperclip.Client, proj paperclip.Project) error { + rows, err := loadRows(c, proj) + if err != nil { + return err + } + fmt.Printf("%s (%s)\n\n", proj.Name, proj.Status) + for _, g := range groupOrder { + any := false + for _, r := range rows { + if r.Group != g { + continue + } + if !any { + fmt.Printf("%s\n", g) + any = true + } + fmt.Printf(" %-8s %-12s %-14s %s\n", r.Issue.Identifier, r.Issue.Status, r.Who, r.Issue.Title) + if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 { + fmt.Printf(" blocked by %v\n", r.Blocker.BlockingIdentities) + } + } + if any { + fmt.Println() + } + } + return nil +} diff --git a/tui/model.go b/tui/model.go new file mode 100644 index 0000000..3a1bc8c --- /dev/null +++ b/tui/model.go @@ -0,0 +1,334 @@ +package main + +import ( + "fmt" + "strings" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" + + "paperclip-harness/tui/paperclip" +) + +// groupOrder is the order of the four status groups on screen 1. +var groupOrder = []string{"Todo", "In progress", "Blocked", "In review", "Done"} + +// Row is one task as the screen needs it: the task itself, who has it, and +// what Paperclip says blocks it. +type Row struct { + Issue paperclip.Issue + Group string + Who string + Blocker *paperclip.BlockerInfo +} + +type rowsMsg []Row +type errMsg struct{ err error } + +func groupOf(status string) string { + switch status { + case "todo", "backlog": + return "Todo" + case "in_progress": + return "In progress" + case "blocked": + return "Blocked" + case "in_review": + return "In review" + case "done", "cancelled": + return "Done" + } + return "Todo" +} + +// loadRows reads the project's tasks from Paperclip, then asks about each +// task's blockers, because the list endpoint never carries them. +func loadRows(c *paperclip.Client, proj paperclip.Project) ([]Row, error) { + issues, err := c.Issues() + if err != nil { + return nil, err + } + mine := make([]paperclip.Issue, 0, len(issues)) + for _, is := range issues { + if is.ProjectID == proj.ID { + mine = append(mine, is) + } + } + + names := map[string]string{} + if agents, err := c.Agents(); err == nil { + for _, a := range agents { + names[a.ID] = a.Name + } + } + + rows := make([]Row, len(mine)) + for i, is := range mine { + who := "—" + switch { + case names[is.AssigneeAgent] != "": + who = names[is.AssigneeAgent] + case is.AssigneeAgent != "": + who = "agent" + case is.AssigneeUser != "": + who = "person" + } + rows[i] = Row{Issue: is, Group: groupOf(is.Status), Who: who} + } + + // One request per task. Bounded so a large project does not open a + // hundred sockets at once. + var wg sync.WaitGroup + sem := make(chan struct{}, 6) + for i := range rows { + wg.Add(1) + go func(i int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + info, err := c.Blockers(rows[i].Issue.ID) + if err == nil { + rows[i].Blocker = &info + } + }(i) + } + wg.Wait() + + order := map[string]int{} + for i, g := range groupOrder { + order[g] = i + } + // Stable sort by group, then by identifier so the tree does not jump + // around between refreshes. + for i := 1; i < len(rows); i++ { + for j := i; j > 0; j-- { + a, b := rows[j-1], rows[j] + if order[a.Group] > order[b.Group] || + (order[a.Group] == order[b.Group] && a.Issue.Identifier > b.Issue.Identifier) { + rows[j-1], rows[j] = rows[j], rows[j-1] + continue + } + break + } + } + return rows, nil +} + +type model struct { + client *paperclip.Client + project paperclip.Project + zones *zone.Manager + + rows []Row + cursor int + err error + loading bool + + width int + height int +} + +func newModel(c *paperclip.Client, proj paperclip.Project) model { + return model{ + client: c, + project: proj, + // The click-zone manager is created once, here, and the whole view is + // wrapped and scanned through it. Wrapping parts separately drifts + // when a pane is resized. + zones: zone.New(), + loading: true, + } +} + +func (m model) Init() tea.Cmd { + return m.reload() +} + +func (m model) reload() tea.Cmd { + c, proj := m.client, m.project + return func() tea.Msg { + rows, err := loadRows(c, proj) + if err != nil { + return errMsg{err} + } + return rowsMsg(rows) + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + // Sizes are always taken from the message. Nothing is hardcoded. + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "r": + m.loading = true + return m, m.reload() + case "up", "shift+tab": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "down", "tab": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + return m, nil + case "home": + m.cursor = 0 + return m, nil + case "end": + if len(m.rows) > 0 { + m.cursor = len(m.rows) - 1 + } + return m, nil + case "esc": + // A bare Esc stays free for closing a box. Nothing to close yet. + return m, nil + } + + case rowsMsg: + m.rows = msg + m.loading = false + m.err = nil + if m.cursor >= len(m.rows) { + m.cursor = max(0, len(m.rows)-1) + } + return m, nil + + case errMsg: + m.err = msg.err + m.loading = false + return m, nil + } + return m, nil +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true) + groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12")) + mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) +) + +// minWidth and minHeight are the smallest box that still shows something +// honest. Below this the program says so instead of drawing a broken tree. +const ( + minWidth = 56 + minHeight = 12 +) + +func (m model) View() string { + if m.width == 0 || m.height == 0 { + return "loading…" + } + if m.width < minWidth || m.height < minHeight { + return m.zones.Scan(lipgloss.NewStyle(). + Width(m.width).Height(m.height). + Render(fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height))) + } + + var b strings.Builder + b.WriteString(titleStyle.Render(truncate(m.project.Name+" · task tree", m.width))) + b.WriteString("\n") + + if m.err != nil { + b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width))) + b.WriteString("\n") + } + + if m.loading && len(m.rows) == 0 { + b.WriteString(mutedStyle.Render("reading tasks from Paperclip…")) + b.WriteString("\n") + } + + lines := 0 + budget := m.height - 3 + for _, g := range groupOrder { + var group []Row + for _, r := range m.rows { + if r.Group == g { + group = append(group, r) + } + } + if len(group) == 0 { + continue + } + if lines >= budget { + break + } + b.WriteString(groupStyle.Render(fmt.Sprintf("%s (%d)", strings.ToUpper(g), len(group)))) + b.WriteString("\n") + lines++ + for _, r := range group { + if lines >= budget { + break + } + b.WriteString(m.renderRow(r)) + b.WriteString("\n") + lines++ + } + } + if len(m.rows) == 0 && !m.loading && m.err == nil { + b.WriteString(mutedStyle.Render("no tasks in this project")) + b.WriteString("\n") + } + + b.WriteString(mutedStyle.Render(truncate( + "↑/↓ or Tab/Shift-Tab move · r refresh · q quit", m.width))) + + // One scan for the whole view. + return m.zones.Scan(b.String()) +} + +func (m model) renderRow(r Row) string { + i := m.indexOf(r.Issue.ID) + marker := " " + if i == m.cursor { + marker = "▸ " + } + who := r.Who + if who == "" { + who = "—" + } + line := fmt.Sprintf("%s%-8s %-11s %-10s %s", marker, r.Issue.Identifier, r.Issue.Status, who, r.Issue.Title) + if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 { + line += " [" + strings.Join(r.Blocker.BlockingIdentities, ", ") + "]" + } + line = truncate(line, m.width) + if i == m.cursor { + return m.zones.Mark("row-"+r.Issue.ID, selectedStyle.Render(line)) + } + return m.zones.Mark("row-"+r.Issue.ID, line) +} + +func (m model) indexOf(id string) int { + for i, r := range m.rows { + if r.Issue.ID == id { + return i + } + } + return -1 +} + +func truncate(s string, w int) string { + if w <= 0 { + return "" + } + r := []rune(s) + if len(r) <= w { + return s + } + if w == 1 { + return "…" + } + return string(r[:w-1]) + "…" +} diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go new file mode 100644 index 0000000..3780943 --- /dev/null +++ b/tui/paperclip/client.go @@ -0,0 +1,258 @@ +// Package paperclip is a thin read/write client for the Paperclip control plane. +// +// It holds no state. Every value it returns came from Paperclip on this call. +// Nothing here caches, and nothing here writes to disk. +package paperclip + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Client talks to one Paperclip instance. +type Client struct { + BaseURL string + Company string + token string + http *http.Client +} + +// New builds a client from the environment. +// +// The token is read from PAPERCLIP_BOARD_KEY (the person's key) and only falls +// back to PAPERCLIP_API_KEY for read-only use. The token is never logged. +func New() (*Client, error) { + base := os.Getenv("PAPERCLIP_API_URL") + if base == "" { + base = "http://127.0.0.1:3100" + } + company := os.Getenv("PAPERCLIP_COMPANY_ID") + if company == "" { + company = "d4921013-e5da-4d88-8016-57d5746e2d03" + } + token := os.Getenv("PAPERCLIP_BOARD_KEY") + if token == "" { + token = os.Getenv("PAPERCLIP_API_KEY") + } + if token == "" { + return nil, errors.New("no key in the environment: set PAPERCLIP_BOARD_KEY") + } + return &Client{ + BaseURL: strings.TrimRight(base, "/"), + Company: company, + token: token, + http: &http.Client{Timeout: 20 * time.Second}, + }, nil +} + +// UsingUserKey reports whether the person's key is in use. Writes need it. +func (c *Client) UsingUserKey() bool { return os.Getenv("PAPERCLIP_BOARD_KEY") != "" } + +type Issue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Status string `json:"status"` + Priority string `json:"priority"` + ProjectID string `json:"projectId"` + ParentID string `json:"parentId"` + AssigneeAgent string `json:"assigneeAgentId"` + AssigneeUser string `json:"assigneeUserId"` + ExecutionState string `json:"-"` + UpdatedAt string `json:"updatedAt"` +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + URLKey string `json:"urlKey"` + Status string `json:"status"` +} + +// Comment is one entry on a task's thread. +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` + AuthorType string `json:"authorType"` + AuthorID string `json:"authorAgentId"` + AuthorUser string `json:"authorUserId"` + CreatedAt string `json:"createdAt"` +} + +// BlockerInfo is what Paperclip says about one task's blockers. +// +// The list endpoint does not carry blockers, so the tree asks per task. +type BlockerInfo struct { + IssueID string + UnresolvedCount int + AllBlockersDone bool + DependencyReady bool + Diagnosis string + BlockingIdentities []string +} + +type blockerResponse struct { + Diagnosis string `json:"diagnosis"` + Readiness struct { + AllBlockersDone bool `json:"allBlockersDone"` + DependencyReady bool `json:"isDependencyReady"` + UnresolvedBlockerCount int `json:"unresolvedBlockerCount"` + } `json:"readiness"` + Blockers []struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + Status string `json:"status"` + } `json:"blockers"` +} + +func (c *Client) do(method, path string, body any, out any) error { + var rd io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rd = bytes.NewReader(b) + } + req, err := http.NewRequest(method, c.BaseURL+path, rd) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + var ue *url.Error + if errors.As(err, &ue) { + return fmt.Errorf("cannot reach Paperclip at %s: %w", c.BaseURL, ue.Err) + } + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, snippet(raw)) + } + if out == nil || len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("%s %s returned unreadable JSON: %w", method, path, err) + } + return nil +} + +func snippet(b []byte) string { + s := strings.TrimSpace(string(b)) + if len(s) > 300 { + s = s[:300] + "…" + } + return s +} + +// Projects lists the company's projects. +func (c *Client) Projects() ([]Project, error) { + var out []Project + err := c.do("GET", "/api/companies/"+c.Company+"/projects", nil, &out) + return out, err +} + +// ResolveProject accepts a project id or url key and returns the project. +func (c *Client) ResolveProject(ref string) (Project, error) { + if ref == "" { + return Project{}, errors.New("no project given: use -project ") + } + ps, err := c.Projects() + if err != nil { + return Project{}, err + } + for _, p := range ps { + if p.ID == ref || p.URLKey == ref { + return p, nil + } + } + return Project{}, fmt.Errorf("no project matches %q", ref) +} + +// Issues returns every task in the company. The caller filters by project, +// because the list endpoint takes no project filter. +func (c *Client) Issues() ([]Issue, error) { + var out []Issue + err := c.do("GET", "/api/companies/"+c.Company+"/issues", nil, &out) + return out, err +} + +// Blockers asks Paperclip what blocks one task. +func (c *Client) Blockers(issueID string) (BlockerInfo, error) { + var raw blockerResponse + err := c.do("GET", "/api/issues/"+issueID+"/diagnostics/blockers", nil, &raw) + info := BlockerInfo{ + IssueID: issueID, + UnresolvedCount: raw.Readiness.UnresolvedBlockerCount, + AllBlockersDone: raw.Readiness.AllBlockersDone, + DependencyReady: raw.Readiness.DependencyReady, + Diagnosis: raw.Diagnosis, + } + for _, b := range raw.Blockers { + info.BlockingIdentities = append(info.BlockingIdentities, b.Identifier+" "+b.Status) + } + return info, err +} + +// Comments returns a task's thread, oldest first. +func (c *Client) Comments(issueID string) ([]Comment, error) { + var out []Comment + err := c.do("GET", "/api/issues/"+issueID+"/comments?order=asc", nil, &out) + return out, err +} + +// PostComment writes a comment as the person, which requires the user key. +func (c *Client) PostComment(issueID, body string) (Comment, error) { + if !c.UsingUserKey() { + return Comment{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + var out Comment + err := c.do("POST", "/api/issues/"+issueID+"/comments", map[string]string{"body": body}, &out) + return out, err +} + +// SetStatus changes one task's status as the person. +func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) { + if !c.UsingUserKey() { + return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + payload := map[string]any{"status": status} + if comment != "" { + payload["comment"] = comment + } + var out Issue + err := c.do("PATCH", "/api/issues/"+issueID, payload, &out) + return out, err +} + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +// Agents lists the company's agents, used only to put a name on a task. +func (c *Client) Agents() ([]Agent, error) { + var out []Agent + err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out) + return out, err +}