Go module with the task tree screen; worktrees on and measured; Go installed
This commit is contained in:
14
CHECKLIST.md
14
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
|
||||
|
||||
17
README.md
17
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
3
tui/.gitignore
vendored
Normal file
3
tui/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# built binaries and local caches
|
||||
/harness
|
||||
/tui
|
||||
29
tui/go.mod
Normal file
29
tui/go.mod
Normal file
@@ -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
|
||||
)
|
||||
45
tui/go.sum
Normal file
45
tui/go.sum
Normal file
@@ -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=
|
||||
86
tui/main.go
Normal file
86
tui/main.go
Normal file
@@ -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
|
||||
}
|
||||
334
tui/model.go
Normal file
334
tui/model.go
Normal file
@@ -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]) + "…"
|
||||
}
|
||||
258
tui/paperclip/client.go
Normal file
258
tui/paperclip/client.go
Normal file
@@ -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 <id or urlKey>")
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user