diff --git a/CHECKLIST.md b/CHECKLIST.md index e8ac879..e48ef23 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -46,9 +46,17 @@ been measured. ## Milestone 3 — add a task, watch it work -- [ ] Screen 2 creates a task with a blocker and the command that decides it is done. -- [ ] Screen 3 shows the running log and the diff from the task's worktree. +- [x] Screen 2 creates a task with a blocker and the command that decides it is done. + → `AUD-163` and `AUD-164` made in the form; `AUD-164` blocked by `AUD-163`, and the + verify command landed in the task's own description. +- [x] Screen 3 shows the running log and the diff from the task's worktree. + → seen live: `running · assignment`, the worktree path and branch, `?? NOTES.md` + while the agent worked, then `754cdb5`, `NOTES.md | 3 +++` and the diff. - [ ] Pass: a two-task plan is created, ordered and finished from the terminal alone. + Created and ordered from the terminal, and both tasks are `done` — but **not by the + intended path**. Nothing merges a task's branch, so `AUD-164`'s worktree was cut from + `main`, which has no `NOTES.md`; the agent read the file across branches with + `git show AUD-163-…:NOTES.md` instead. That is what Milestone 5 exists for. ## Milestone 4 — the project layer diff --git a/bin/harness b/bin/harness index 06f9d5f..4734cf7 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/bin/layout b/bin/layout index 9ba76b6..94d1ad3 100755 --- a/bin/layout +++ b/bin/layout @@ -5,7 +5,7 @@ # cannot lose its first workspace. Each screen then gets a workspace named after # it, in this order. Running this again adopts what is already there. # -# bin/layout # the default project +# bin/layout # the default project and task # bin/layout spike-2026-11-10 AUD-162 set -euo pipefail @@ -24,6 +24,8 @@ fi "$ROOT/bin/screen" tree "$project" "$ROOT/bin/screen" reply "$task" +"$ROOT/bin/screen" new "$project" +"$ROOT/bin/screen" log "$task" tuios list-workspaces -s "$SESSION" --json | python3 -c ' import json,sys diff --git a/bin/screen b/bin/screen index 7bbe5e2..700869d 100755 --- a/bin/screen +++ b/bin/screen @@ -24,8 +24,10 @@ BIN="$ROOT/bin/harness" screen="${1:-}" case "$screen" in tree) args=(-screen tree -project "${2:-spike-2026-11-10}") ;; + new) args=(-screen new -project "${2:-spike-2026-11-10}") ;; + log) args=(-screen log -issue "${2:?log needs a task, for example AUD-160}") ;; reply) args=(-screen reply -issue "${2:?reply needs a task, for example AUD-162}") ;; - *) echo "usage: $(basename "$0") tree [project] | reply " >&2; exit 2 ;; + *) echo "usage: $(basename "$0") tree|new [project] | reply|log " >&2; exit 2 ;; esac if [ ! -x "$BIN" ] || [ -n "$(find "$ROOT/tui" -name '*.go' -newer "$BIN" -print -quit 2>/dev/null)" ]; then diff --git a/docs/PAPERCLIP-API-NOTES.md b/docs/PAPERCLIP-API-NOTES.md index 02f3177..e67bc28 100644 --- a/docs/PAPERCLIP-API-NOTES.md +++ b/docs/PAPERCLIP-API-NOTES.md @@ -299,3 +299,94 @@ returned `allBlockersDone: true, unresolvedBlockerCount: 0` while the task itsel `blocked`. So Paperclip does not walk a task forward on its own when its blocker clears; it makes the task ready and leaves the decision to a person or a program. That is the job the reply screen does, and the reason this milestone exists. + +--- + +# Milestone 3, from the terminal, 2026-11-10 + +Four screens now run as panes in the session `paperclip-harness`, on workspaces named +`shell`, `tree`, `reply`, `new` and `log`. + +## Screen 2 made a two-task plan + +Typed in the `new` pane, twice, and read back from the API: + +| Task | Created as | Blocker | Assignee | +|---|---|---|---| +| `AUD-163` | `todo` | none | none, until assigned from the tree | +| `AUD-164` | `todo` | `AUD-163` via a separate `PATCH` | none | + +`GET /api/issues/AUD-164/diagnostics/blockers` then said +`unresolvedBlockerCount: 1`, and the tree drew `[AUD-163 todo]` against it. The verify +command typed in the form is stored in the task's own description: + +``` +Create NOTES.md at the repository root. … + +Done when this command succeeds: + + test -s NOTES.md +``` + +Assigning is a separate key (`a` in the tree), never part of creation. That is what woke +`Builder` both times: `runs[0].invocationSource` is `assignment`. + +## Screen 3 showed the work happening + +Read from the `log` pane while the run was live, not afterwards: + +``` +runs + running assignment 2026-11-10T01:12:28.391Z +diff and worktree +worktree: …/worktrees/AUD-163-write-notes-md-with… +branch: AUD-163-write-notes-md-with-the-three-milestone-names base: main +no commits on this branch yet +uncommitted + ?? NOTES.md +``` + +and after it finished: + +``` +succeeded assignment 2026-11-10T01:12:28.391Z → 2026-11-10T01:13:01.683Z +commits + 754cdb5 AUD-163 add NOTES.md with the three milestone names +changed + NOTES.md | 3 +++ +diff + diff --git a/NOTES.md b/NOTES.md +``` + +## The gap this exposed: nothing merges + +`main` never received `NOTES.md`: + +``` +$ git -C /tmp/pc-sandbox-2026-11-10 cat-file -e main:NOTES.md +fatal: path 'NOTES.md' does not exist in 'main' +``` + +So `AUD-164`, whose branch was cut from `main`, had no file to read. The agent worked +around it by reading the other task's branch directly — +`git show AUD-163-write-notes-md-with-the-three-milestone-names:NOTES.md` — and reported +that in its comment. Two tasks both show `done`, and the handoff never passed through +`main`. + +A dependent task that finds its predecessor's work by reaching across branches is not a +merge and not a review. Milestone 5 (merge one at a time, re-checked) is what closes this, +and until it exists the second task in any chain is working from a guess. + +## Two behaviours worth knowing + +**A corrective wake is real.** `AUD-164`'s first run ended `succeeded` without setting a +status, and Paperclip immediately started a second run with `invocationSource: +automation` to ask the agent to finish what it started. The docs call this +`finish_successful_run_handoff`. It doubles the cost of an agent that forgets to set a +status, and our screens should show that second run rather than hide it. + +**A comment can be attributed to the person when an agent wrote it.** The first comment on +`AUD-164` is `authorType: user`, and the agent's own next comment says the text "landed +under the board identity by mistake". So `authorType: user` is not proof that the person +typed it. Screen 4 currently prints `user` as "you"; that label is wrong often enough to +matter, and it needs a better rule than the author type alone. diff --git a/tui/go.mod b/tui/go.mod index 5fe9aad..d4e9a9d 100644 --- a/tui/go.mod +++ b/tui/go.mod @@ -3,27 +3,32 @@ module paperclip-harness/tui go 1.26.5 require ( + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/lrstanley/bubblezone v1.0.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect 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/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.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/mattn/go-runewidth v0.0.19 // 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/sys v0.38.0 // indirect golang.org/x/text v0.24.0 // indirect ) diff --git a/tui/go.sum b/tui/go.sum index 86980ed..3b1a1e0 100644 --- a/tui/go.sum +++ b/tui/go.sum @@ -1,45 +1,54 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= 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/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 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/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.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/logdiff.go b/tui/logdiff.go new file mode 100644 index 0000000..70684d8 --- /dev/null +++ b/tui/logdiff.go @@ -0,0 +1,290 @@ +package main + +// Screen 3: what the task is doing, and what it changed. +// +// The log half comes from Paperclip: the task's runs and its activity. The diff +// half comes from the task's own copy of the code, which Paperclip made as a git +// worktree; the program only reads it with git, in a command, never while +// drawing. + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "paperclip-harness/tui/paperclip" +) + +type logMsg struct { + task paperclip.Issue + runs []paperclip.Run + activity []paperclip.Activity + diff string + diffErr error +} + +type tickMsg time.Time + +type logModel struct { + client *paperclip.Client + issue string + + task paperclip.Issue + runs []paperclip.Run + activity []paperclip.Activity + diff string + diffErr error + err error + scroll int + + width, height int +} + +func newLogModel(c *paperclip.Client, issueID string) logModel { + return logModel{client: c, issue: issueID} +} + +// The screen re-reads on a timer so it is live while an agent works. The read +// happens in a command, never in Update or View. +func (m logModel) Init() tea.Cmd { return tea.Batch(m.load(), m.tick()) } + +func (m logModel) load() tea.Cmd { + c, id := m.client, m.issue + return func() tea.Msg { + task, err := c.Issue(id) + if err != nil { + return errMsg{err} + } + runs, _ := c.Runs(id) + act, _ := c.Activity(id) + msg := logMsg{task: task, runs: runs, activity: act} + if task.Workspace == nil || task.Workspace.CWD == "" { + msg.diffErr = fmt.Errorf("this task has no worktree, so there is nothing to compare") + return msg + } + msg.diff, msg.diffErr = worktreeDiff(task.Workspace.CWD, task.Workspace.BaseRef) + return msg + } +} + +// worktreeDiff reads what the task's branch changed. It runs git, so it is only +// ever called from a command. +func worktreeDiff(dir, base string) (string, error) { + if base == "" { + base = "main" + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + run := func(args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("git %s: %v", strings.Join(args, " "), err) + } + return string(out), nil + } + + var b strings.Builder + head, err := run("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } + b.WriteString("worktree: " + dir + "\n") + b.WriteString("branch: " + strings.TrimSpace(head) + " base: " + base + "\n\n") + + commits, err := run("log", "--oneline", base+"..HEAD") + if err != nil { + commits = "" + } + if strings.TrimSpace(commits) == "" { + b.WriteString("no commits on this branch yet\n") + } else { + b.WriteString("commits\n") + b.WriteString(indent(commits, " ", 20)) + b.WriteString("\n") + } + + stat, err := run("diff", "--stat", base+"...HEAD") + if err == nil && strings.TrimSpace(stat) != "" { + b.WriteString("changed\n") + b.WriteString(indent(stat, " ", 40)) + b.WriteString("\n") + } + + d, err := run("diff", base+"...HEAD") + if err == nil && strings.TrimSpace(d) != "" { + b.WriteString("diff\n") + b.WriteString(indent(d, " ", 400)) + } + + // Work the agent has not committed is worth more than the commit log. + dirty, _ := run("status", "--porcelain") + if strings.TrimSpace(dirty) != "" { + b.WriteString("\nuncommitted\n") + b.WriteString(indent(dirty, " ", 40)) + ud, _ := run("diff") + if strings.TrimSpace(ud) != "" { + b.WriteString(indent(ud, " ", 200)) + } + } + return b.String(), nil +} + +// indent prefixes lines and stops after max, saying how many were left out. +func indent(s, prefix string, max int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + var b strings.Builder + for i, ln := range lines { + if i >= max { + fmt.Fprintf(&b, "%s… %d more lines\n", prefix, len(lines)-max) + break + } + b.WriteString(prefix + ln + "\n") + } + return b.String() +} + +func (m logModel) tick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m logModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + 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": + return m, m.load() + case "up": + m.scroll-- + return m, nil + case "down": + m.scroll++ + return m, nil + case "g": + m.scroll = 0 + return m, nil + case "G": + m.scroll = 1 << 30 + return m, nil + case "esc": + return m, nil + } + + case tickMsg: + return m, tea.Batch(m.load(), m.tick()) + + case logMsg: + m.task = msg.task + m.runs = msg.runs + m.activity = msg.activity + m.diff = msg.diff + m.diffErr = msg.diffErr + m.err = nil + return m, nil + + case errMsg: + m.err = msg.err + return m, m.tick() + } + return m, nil +} + +func (m logModel) running() bool { + for _, r := range m.runs { + if r.Status == "running" { + return true + } + } + return false +} + +func (m logModel) View() string { + if m.width == 0 || m.height == 0 { + return "loading…" + } + if m.width < minWidth || m.height < minHeight { + return fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height) + } + + var lines []string + head := fmt.Sprintf("%s %s · log and diff", m.task.Identifier, m.task.Status) + lines = append(lines, titleStyle.Render(truncate(head, m.width))) + + if m.err != nil { + lines = append(lines, blockedStyle.Render(truncate("error: "+m.err.Error(), m.width))) + } + + lines = append(lines, "") + lines = append(lines, groupStyle.Render("runs")) + if len(m.runs) == 0 { + lines = append(lines, mutedStyle.Render(" never run")) + } + for _, r := range m.runs { + when := r.StartedAt + if r.FinishedAt != "" { + when += " → " + r.FinishedAt + } + line := fmt.Sprintf(" %-10s %-9s %s", r.Status, r.Source, when) + if r.ErrorCode != "" { + line += " " + r.ErrorCode + } + lines = append(lines, truncate(line, m.width)) + } + + lines = append(lines, "") + lines = append(lines, groupStyle.Render("activity")) + if len(m.activity) == 0 { + lines = append(lines, mutedStyle.Render(" nothing recorded")) + } + for i, a := range m.activity { + if i >= 12 { + lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more", len(m.activity)-12))) + break + } + lines = append(lines, truncate(fmt.Sprintf(" %s %-16s %s", a.CreatedAt, a.ActorType, a.Action), m.width)) + } + + lines = append(lines, "") + lines = append(lines, groupStyle.Render("diff and worktree")) + + switch { + case m.diffErr != nil: + lines = append(lines, mutedStyle.Render(" "+truncate(m.diffErr.Error(), m.width-2))) + case m.diff == "": + lines = append(lines, mutedStyle.Render(" nothing yet")) + default: + for _, ln := range strings.Split(strings.TrimRight(m.diff, "\n"), "\n") { + lines = append(lines, truncate(ln, m.width)) + } + } + + // Scrolling, with the cursor pinned to the bottom until moved up. + visible := max(4, m.height-1) + maxScroll := max(0, len(lines)-visible) + if m.scroll > maxScroll { + m.scroll = maxScroll + } + if m.scroll < 0 { + m.scroll = 0 + } + from := m.scroll + to := min(len(lines), from+visible+1) + + var b strings.Builder + for _, ln := range lines[from:to] { + b.WriteString(ln) + b.WriteString("\n") + } + return b.String() +} diff --git a/tui/main.go b/tui/main.go index 588fd62..b8bf884 100644 --- a/tui/main.go +++ b/tui/main.go @@ -16,7 +16,8 @@ import ( ) func main() { - screen := flag.String("screen", "tree", "which screen to run: tree or reply") + screen := flag.String("screen", "tree", "which screen to run: tree, reply, new or log") + agent := flag.String("agent", "403de1f7-645c-4d35-bc7e-19b01d77bacc", "agent id to assign to, default Builder") project := flag.String("project", "", "project id or url key, for the tree screen") issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen") dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)") @@ -46,7 +47,23 @@ func main() { } return } - m = newModel(client, proj) + m = newModel(client, proj, *agent) + + case "new": + proj, err := client.ResolveProject(*project) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m = newTaskForm(client, proj, *agent) + + case "log": + task, err := resolveIssue(client, *issue) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m = newLogModel(client, task.ID) case "reply": task, err := resolveIssue(client, *issue) @@ -57,7 +74,7 @@ func main() { m = newReplyModel(client, task.ID) default: - fmt.Fprintf(os.Stderr, "unknown screen %q: use tree or reply\n", *screen) + fmt.Fprintf(os.Stderr, "unknown screen %q: use tree, reply, new or log\n", *screen) os.Exit(1) } diff --git a/tui/model.go b/tui/model.go index 3a1bc8c..b42be3d 100644 --- a/tui/model.go +++ b/tui/model.go @@ -119,6 +119,7 @@ func loadRows(c *paperclip.Client, proj paperclip.Project) ([]Row, error) { type model struct { client *paperclip.Client project paperclip.Project + agent string zones *zone.Manager rows []Row @@ -130,10 +131,11 @@ type model struct { height int } -func newModel(c *paperclip.Client, proj paperclip.Project) model { +func newModel(c *paperclip.Client, proj paperclip.Project, agent string) model { return model{ client: c, project: proj, + agent: agent, // 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. @@ -171,6 +173,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "r": m.loading = true return m, m.reload() + case "a": + // Assign the selected task to one agent. Paperclip wakes that + // agent as soon as the assignment lands, so this is also the + // "start work" key. + if m.cursor < len(m.rows) && m.rows[m.cursor].Issue.AssigneeAgent == "" { + c, id, agent := m.client, m.rows[m.cursor].Issue.ID, m.agent + return m, func() tea.Msg { + if _, err := c.Assign(id, agent); err != nil { + return errMsg{err} + } + rows, err := loadRows(c, m.project) + if err != nil { + return errMsg{err} + } + return rowsMsg(rows) + } + } + return m, nil case "up", "shift+tab": if m.cursor > 0 { m.cursor-- @@ -283,7 +303,7 @@ func (m model) View() string { } b.WriteString(mutedStyle.Render(truncate( - "↑/↓ or Tab/Shift-Tab move · r refresh · q quit", m.width))) + "↑/↓ or Tab/Shift-Tab move · a assign to Builder · r refresh · q quit", m.width))) // One scan for the whole view. return m.zones.Scan(b.String()) diff --git a/tui/newtask.go b/tui/newtask.go new file mode 100644 index 0000000..f4a8988 --- /dev/null +++ b/tui/newtask.go @@ -0,0 +1,226 @@ +package main + +// Screen 2: make a task. +// +// The order the fields ask for is the order Paperclip needs them in: a title, +// what it must do, what must finish first, and the command that proves it is +// finished. The task is created unassigned and given its blocker in a separate +// call, because a task assigned at creation is claimed by an agent's run within +// seconds, and that run then owns the task's fields — the blocker write would be +// refused. + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "paperclip-harness/tui/paperclip" +) + +type createdMsg struct { + issue paperclip.Issue + blocked string + err error +} + +type newTaskModel struct { + client *paperclip.Client + proj paperclip.Project + agent string + + inputs []textinput.Model + focus int + err error + busy bool + + created *paperclip.Issue + notice string + + width, height int +} + +const ( + fieldTitle = iota + fieldBody + fieldBlocker + fieldVerify + fieldAssign + fieldCount +) + +func newTaskForm(c *paperclip.Client, proj paperclip.Project, agentID string) newTaskModel { + placeholders := []string{ + "what needs doing, in one line", + "details the agent needs: files, behaviour, limits", + "identifier of the task that must finish first, or empty", + "the command that proves it is done, or empty", + "assign after the blocker is set: yes, or empty", + } + m := newTaskModel{client: c, proj: proj, agent: agentID} + for _, l := range placeholders { + ti := textinput.New() + ti.Placeholder = l + ti.CharLimit = 1000 + ti.Width = 60 + ti.Prompt = "" + m.inputs = append(m.inputs, ti) + } + m.inputs[fieldTitle].Focus() + return m +} + +var fieldNames = []string{"Title", "What it must do", "Must finish first", "Proves it is done", "Assign to"} + +func (m newTaskModel) Init() tea.Cmd { return textinput.Blink } + +func (m newTaskModel) submit() tea.Cmd { + title := strings.TrimSpace(m.inputs[fieldTitle].Value()) + body := strings.TrimSpace(m.inputs[fieldBody].Value()) + blocker := strings.TrimSpace(m.inputs[fieldBlocker].Value()) + verify := strings.TrimSpace(m.inputs[fieldVerify].Value()) + assign := strings.TrimSpace(m.inputs[fieldAssign].Value()) + + c, proj, agent := m.client, m.proj, m.agent + + desc := body + if verify != "" { + // The command lives with the task, in the task's own words, because + // Paperclip owns the task. The project's .harness/verify.sh is the + // convention it follows. + desc += "\n\nDone when this command succeeds:\n\n " + verify + } + if blocker != "" { + desc += "\n\nMust finish first: " + blocker + } + + return func() tea.Msg { + is, err := c.CreateTask(paperclip.NewTask{Title: title, Description: desc, ProjectID: proj.ID}) + if err != nil { + return createdMsg{err: err} + } + if blocker != "" { + b, err := resolveIssue(c, blocker) + if err != nil { + return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker %q was not found: %w", is.Identifier, blocker, err)} + } + if _, err := c.SetBlockers(is.ID, []string{b.ID}); err != nil { + return createdMsg{issue: is, err: fmt.Errorf("created %s, but the blocker was refused: %w", is.Identifier, err)} + } + } + if assign != "" { + if agent == "" { + return createdMsg{issue: is, err: fmt.Errorf("created %s, but no agent is configured to assign to", is.Identifier)} + } + // Assigned last, on purpose: this is the call that wakes the agent. + if _, err := c.Assign(is.ID, agent); err != nil { + return createdMsg{issue: is, err: fmt.Errorf("created %s, but the assignment was refused: %w", is.Identifier, err)} + } + } + return createdMsg{issue: is, blocked: blocker} + } +} + +func (m newTaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + w := max(20, min(72, m.width-8)) + for i := range m.inputs { + m.inputs[i].Width = w + } + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + return m, tea.Quit + case tea.KeyTab, tea.KeyDown: + m.inputs[m.focus].Blur() + m.focus = (m.focus + 1) % fieldCount + return m, m.inputs[m.focus].Focus() + case tea.KeyShiftTab, tea.KeyUp: + m.inputs[m.focus].Blur() + m.focus = (m.focus + fieldCount - 1) % fieldCount + return m, m.inputs[m.focus].Focus() + case tea.KeyEnter: + if m.focus < fieldCount-1 { + m.inputs[m.focus].Blur() + m.focus++ + return m, m.inputs[m.focus].Focus() + } + if strings.TrimSpace(m.inputs[fieldTitle].Value()) == "" { + m.err = fmt.Errorf("a task needs a title") + return m, nil + } + if m.busy { + return m, nil + } + m.busy = true + m.err = nil + return m, m.submit() + } + + case createdMsg: + m.busy = false + m.err = msg.err + if msg.err == nil { + m.created = &msg.issue + m.notice = "created " + msg.issue.Identifier + if msg.blocked != "" { + m.notice += " after " + msg.blocked + } + // Leave the form filled so a failed run can be corrected, but say + // plainly what happened in the pane. + return m, tea.Quit + } + return m, nil + } + + var cmd tea.Cmd + m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg) + return m, cmd +} + +func (m newTaskModel) View() string { + if m.width == 0 || m.height == 0 { + return "loading…" + } + if m.width < minWidth || m.height < minHeight { + return 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("new task · "+m.proj.Name, m.width))) + b.WriteString("\n\n") + + for i, in := range m.inputs { + label := fieldNames[i] + if i == fieldTitle { + label += " (required)" + } + muted := mutedStyle + if i == m.focus { + muted = groupStyle + } + b.WriteString(muted.Render(label)) + b.WriteString("\n") + b.WriteString(" " + in.View()) + b.WriteString("\n") + } + + b.WriteString("\n") + if m.err != nil { + b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width))) + } else if m.busy { + b.WriteString(mutedStyle.Render("writing to Paperclip…")) + } else { + b.WriteString(mutedStyle.Render(truncate("Enter next field, Enter on the last line creates · Esc cancels", m.width))) + } + b.WriteString("\n") + return lipgloss.NewStyle().MaxHeight(m.height).Render(b.String()) +} diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index 83cb88c..a6bc340 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -57,17 +57,104 @@ func New() (*Client, error) { 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"` + ID string `json:"id"` + Description string `json:"description"` + 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"` + UpdatedAt string `json:"updatedAt"` + + // Workspace is where Paperclip put this task's own copy of the code. It is + // null when the instance does not make worktrees. + Workspace *struct { + CWD string `json:"cwd"` + Branch string `json:"branchName"` + BaseRef string `json:"baseRef"` + Strategy string `json:"strategyType"` + } `json:"currentExecutionWorkspace"` +} + +// Run is one heartbeat run: when it started, how it ended, what it cost. +type Run struct { + ID string `json:"runId"` + Status string `json:"status"` + Source string `json:"invocationSource"` + AgentID string `json:"agentId"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` + ErrorCode string `json:"errorCode"` +} + +// Activity is one structural event on a task. +type Activity struct { + ID string `json:"id"` + ActorType string `json:"actorType"` + Action string `json:"action"` + CreatedAt string `json:"createdAt"` +} + +// Runs returns a task's run history, newest first. +func (c *Client) Runs(issueID string) ([]Run, error) { + var out []Run + err := c.do("GET", "/api/issues/"+issueID+"/runs", nil, &out) + return out, err +} + +// Activity returns a task's structural events, newest first. +func (c *Client) Activity(issueID string) ([]Activity, error) { + var out []Activity + err := c.do("GET", "/api/issues/"+issueID+"/activity", nil, &out) + return out, err +} + +// CreateTask creates a task, unassigned. Blockers are never accepted at +// creation: they must be a separate call, or the write is ignored. +type NewTask struct { + Title string + Description string + ProjectID string +} + +func (c *Client) CreateTask(t NewTask) (Issue, error) { + if !c.UsingUserKey() { + return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + var out Issue + body := map[string]any{ + "title": t.Title, + "description": t.Description, + "projectId": t.ProjectID, + "status": "todo", + } + err := c.do("POST", "/api/companies/"+c.Company+"/issues", body, &out) + return out, err +} + +// SetBlockers replaces a task's blocked-by list. +func (c *Client) SetBlockers(issueID string, blockerIDs []string) (Issue, error) { + if !c.UsingUserKey() { + return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + var out Issue + err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"blockedByIssueIds": blockerIDs}, &out) + return out, err +} + +// Assign gives a task to an agent. It is always a separate call from creation: +// an assigned task is claimed by that agent's run within seconds, and the run +// then owns the task's fields. +func (c *Client) Assign(issueID, agentID string) (Issue, error) { + if !c.UsingUserKey() { + return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + var out Issue + err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"assigneeAgentId": agentID}, &out) + return out, err } type Project struct {