Screens 2 and 3: new-task form, live log and diff; measured the merge gap they expose

This commit is contained in:
Sam
2026-11-10 12:15:45 +11:00
parent 34f60872f6
commit 50fde87450
12 changed files with 801 additions and 44 deletions

View File

@@ -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

Binary file not shown.

View File

@@ -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

View File

@@ -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 <task>" >&2; exit 2 ;;
*) echo "usage: $(basename "$0") tree|new [project] | reply|log <task>" >&2; exit 2 ;;
esac
if [ ! -x "$BIN" ] || [ -n "$(find "$ROOT/tui" -name '*.go' -newer "$BIN" -print -quit 2>/dev/null)" ]; then

View File

@@ -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.

View File

@@ -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
)

View File

@@ -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=

290
tui/logdiff.go Normal file
View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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())

226
tui/newtask.go Normal file
View File

@@ -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())
}

View File

@@ -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 {