diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/home.md b/.pi/memory-vault/packs/paperclip-harness/20-context/home.md index 528bb7b..78019bb 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/home.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/home.md @@ -1286,3 +1286,145 @@ I have changed nothing since the last commit except the code you already have (` - session: [[packs/paperclip-harness/80-sessions/paperclip-harness-multi-machine-setup-program-delivery-git-w|Paperclip harness multi-machine setup: program delivery, git worktrees vs local_path, env var inheritance …[truncated]…]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-cannot-2026-11-12-06-28-28|Rich discovery: cannot 2026-11-12 06:28:28]] - context: [[packs/paperclip-harness/20-context/home|cannot]] + + +--- + +## Update (2026-11-12 09:24:20) + +## Rich discovery update (2026-11-12 09:24:20) + +Research done, measured against the running instance and the installed build rather than remembered. Here is what Paperclip actually offers for git. + +## 1. The CLI has no git commands at all + +``` +paperclipai project list · get · create · update · delete +paperclipai project-workspace list · create · update · delete · runtime-service · runtime-command +paperclipai workspace list · get · close-readiness · operations · update · runtime-* +paperclipai cost · openapi · … +``` + +Nothing initialises a repository, nothing reports whether a folder is one. The git-shaped verbs are all about *execution workspaces* — the per-task worktrees. + +## 2. The web "git screen" is the execution workspace page + +Two things on that page, found in the served bundle: + +- a **"Git status"** panel showing `branchName`, and +- a **"Source control"** section with `Branch name` ("Useful for isolated worktrees") and `Base ref` editors. + +So the screen you are thinking of is per *task worktree*, not the project folder. + +## 3. The API behind it — and this is useful to us + +`GET /api/execution-workspaces/{id}/close-readiness` returns a `git` object. Live, for AUD-172 a moment ago: + +```json +"git": { + "repoRoot": "/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…", + "workspacePath":"/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…", + "branchName": "AUD-172-add-a-status-md-with-one-line", + "baseRef": "main", + "hasDirtyTrackedFiles": false, + "hasUntrackedFiles": false, + "dirtyEntryCount": 0, + "untrackedEntryCount": 0, + "aheadCount": 0, + "behindCount": 1, + "isMergedIntoBase": true, + "createdByRuntime": true +}, +"deliveryState": "merged_by_ancestry", +"warnings": ["This workspace is 1 commit behind main."] +``` + +That is Paperclip doing the git reading for you: branch, dirtiness, how far behind, whether it is already merged. Nothing we should reimplement. + +## 4. The project workspace side is declaration only + +`GET /api/projects/{id}/workspaces` returns configuration, no git state: + +``` +sourceType local_path | git_repo | remote_managed | non_git_path +cwd · repoUrl · repoRef · defaultRef +setupCommand · cleanupCommand +visibility · runtimeConfig · isPrimary +``` + +And the policy on the project has more hooks than we use: + +``` +workspaceStrategy.type project_primary | git_worktree | adapter_managed | cloud_sandbox + baseRef · branchTemplate · existingBranch · worktreeParentDir + provisionCommand · runtimeProvisionCommand · teardownCommand +defaultMode shared_workspace | isolated_workspace | operator_branch | adapter_default +sharedWorkspaceConcurrency auto | serialize | allow +``` + +## 5. The finding that matters most + +**Paperclip does not check that the folder is a repository.** The whole validation is: + +```js +function validateProjectWorkspace(value, ctx) { + const sourceType = value.sourceType ?? "local_path"; + const hasCwd = …; const hasRepo = …; + if (sourceType === "remote_managed") { …; return; } + if (!hasCwd && !hasRepo) ctx.addIssue({ message: "Workspace requires at least one of cwd or repoUrl." }); +} +``` + +`git_repo` with a folder that is not a repository is accepted and fails later, when a task first tries to make a worktree. Your instinct was right: this has to be caught on our side, at creation, or it is not caught at all. + +## 6. What cannot be done through Paperclip + +- **No endpoint to create a worktree on demand.** Execution workspaces appear when a task runs. So the first task is the only real test that the repository is usable — we cannot "try one" beforehand. +- **No hook for `git init`.** `provisionCommand` runs per worktree, which is far too late, and `setupCommand` is per project workspace but is not a validation step. +- `POST /api/execution-workspaces/{id}/reconcile-branch` exists for repairing a drifted branch record, and `existingBranch` lets a task be pointed at a branch you made yourself, instead of `{{issue.identifier}}-{{slug}}`. + +## 7. One correction to what I built + +Our create body sets the policy's `baseRef` but leaves the project workspace's `defaultRef` as `null` — visible on both existing projects. Those are two fields for the same idea and both should be set. Small, but it is the kind of gap that bites later. + +Also: `non_git_path` exists as a `sourceType`, but it appears exactly once in the whole build (in the schema) and nothing treats it differently from `local_path`. I would keep using `local_path` and not invent a meaning it does not have. + +## What this means for the plan you approved + +| Piece | Change from what we discussed | +|---|---| +| Gate at creation | stays ours, and is now clearly the **only** early check. Also set `defaultRef`, and refuse to call a non-repo `git_repo` | +| `git` tab | becomes **two layers**: the folder's own state (we read it), and Paperclip's `close-readiness` scan of each task worktree — branch, dirty, ahead/behind, merged. The second gives "1 commit behind main" and "merged into main" without us computing anything | +| `.gitignore` editing | unchanged: lazygit, opened with `t`, files panel `i`, then `c` to commit | +| Banner | unchanged, one line while the folder cannot hold worktrees | +| Two-step creation | unchanged: repo + `.gitignore` + stage, stop, you commit in lazygit, run again to get the worktree project | + +The research also suggests one extra thing worth having in the `git` tab, since the data is free: **the list of this project's worktrees with their git state**, including ones that are behind `main` or already merged. That is the same view Paperclip's web screen gives, in the place you are already working. + +I have not touched `~/SECURITY`, and I will not run `office` on it. + +Say go and I will build it in this order: the creation gate (with `defaultRef` and the honest `sourceType`), then the `git` tab with both layers, then the banner. All of it lives in the commands and the program, so nothing needs rebuilding on any machine. + +## Source paths observed + +- `./scripts/provision-worktree-runtime.sh` +- `./scripts/provision-worktree.sh` +- `./scripts/teardown-worktree.sh` +- `path/to/worktree` +- `github.com/acme/growth-playbook` +- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-add-a-status-md-with-one-line` +- `home/sam/paseo/projects/test-alpha` +- `home/sam/SECURITY` +- `p/worktrees/AUD-173-add-a-line-to-readme` +- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20|Rich discovery: git_repo 2026-11-12 09:24:20]] +- context: [[packs/paperclip-harness/20-context/home|git_repo]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|git_repo repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/paperclip-harness.md b/.pi/memory-vault/packs/paperclip-harness/20-context/paperclip-harness.md index d2408c4..9237c7c 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/paperclip-harness.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/paperclip-harness.md @@ -941,3 +941,113 @@ Operational note: home-manager-only changes are safe to `nixos-rebuild switch` ( - runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Rebuild .27 and .51 after adding ~/.local/bin to home-manager PATH]] - action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Added ~/.local/bin to PATH on .27/.51 and committed; .13 untouched]] - session: [[packs/paperclip-harness/80-sessions/nixos-fleet-path-fix-rollout-27-and-51-need-home-rebuild-13-|NixOS fleet PATH fix rollout: .27 and .51 need home rebuild, .13 already correct]] + + +--- + +## Update (2026-11-12 07:09:34) + +## Rich discovery update (2026-11-12 07:09:34) + +Your summary of the machinery is exactly right, and nothing you asked was silly. TUIOS on each machine, `office` copied to each, Paperclip and therefore the agents only on `.13`. Everything below follows from that one fact. + +## 1. Worktrees by default — agreed, and now it is the default + +You are right that worktrees are the normal case, not an optional extra. Without them two tasks edit the same files, and `Work>change`, `Work>review` and `Work>merge` have nothing to work from — they stop being a review gate and become decoration. + +I also found a real bug while doing it: the base branch was hardcoded to `main`. A repository on `master` would have been given a worktree policy pointing at a branch that does not exist, so every run would fail on first use. It is now read from the folder. + +``` +worktrees: yes — a repository on branch 'main', so each task gets its own +worktrees: no — this is not a repository +``` + +## 2. Asking, not doing it silently + +This is the part where I would push back on doing it automatically, and `~/SECURITY` is the perfect example of why. + +To give a folder worktrees you have to run `git init`, `git add -A` and a first commit — which commits **every file in that folder**, including anything you forgot was there. In `SECURITY` that means `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`. Committing is a decision about your own files, and it is not one a setup command should make on your behalf. A first commit is also nearly impossible to take back quietly. + +So it asks, and says what it will do before doing it: + +``` +/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree. + without them, two tasks can change the same files at once, and there + is no diff to review and nothing to merge. + +make it a repository now? This runs: git init -b main, git add -A, +and one commit of EVERY file in the folder, including anything you +have not noticed. Add a .gitignore first if that is not what you want. + type yes to do it: +``` + +Three ways through, in the order I would use them: + +| Situation | What happens | +|---|---| +| at a terminal | it asks; `yes` makes the repository, anything else leaves it alone | +| in a script | `--git-init` makes it without asking | +| no terminal at all | it changes nothing and prints the exact commands to run yourself | + +Say no, and the project is still created — as `local_path`, in place, with no branch and nothing to merge. That is honest for a folder of notes and wrong for code, and it now says which one you got. + +Verified on a throwaway folder: with `--git-init` it made one commit on `main` and reported ready; a second call found it already ready and did nothing; a repository with no commit was refused with the right one-line fix. + +## 3. Warning when it is not the Paperclip machine — and refusing for a new project + +I made it stronger than a warning for `--new`, because the failure is invisible until a task runs: + +``` +refused: this folder is on sam-4screen-desktop, but Paperclip and its agents run on nixos-desktop + the panes will open here and will read this folder correctly + the agents will not: they start on nixos-desktop, where /home/sam/SECURITY does not exist + a task you assign will fail, or work on a different folder + move the folder to nixos-desktop, mount it there, or pass --anywhere to go ahead anyway +``` + +My reasoning for refuse rather than warn: creating that project writes a lasting record in Paperclip that can never work, and nothing about the panes will look wrong. Adopting an existing project — `--project` instead of `--new` — only warns, because opening panes to read a folder that lives elsewhere is a legitimate thing to want. + +`--anywhere` proceeds anyway. + +## What this means for the `security` project + +It is currently `local_path` on `.27`, so it has the problem in two ways at once: no worktrees, and its folder is invisible to the agents. A project's policy is fixed when it is created, so it cannot be upgraded — it has to be made again. + +You have three choices, and I have not touched it while you decide: + +1. **Move the notes to `.13`** and re-create it there. Cleanest: panes, agents and worktrees all on the same machine. +2. **Leave it on `.27`** and use its panes only as a place to read and write the notes yourself, with no agent work. +3. **Mount `.27`'s home on `.13`** so agents can see the path. Then re-create it. Adds a mount that can fail silently. + +If you want option 1, the commands are: + +```sh +cd ~/SECURITY && git init -b main && git add -A && git commit -m "security notes as they stand" +rsync -a ~/SECURITY/ sam@192.168.20.13:/home/sam/SECURITY/ # from .27 +# then on .13: +office ~/SECURITY --new "security" +``` + +Add a `.gitignore` before the first commit if any of those files should stay out of the history. + +## Committed + +`paperclip-harness` → `f7b77f3`. `office --help` now documents the worktree rule, the question, and where things run, alongside the per-machine commands. + +Nothing needs rebuilding for any of this — it is all in the commands, which `--host` copies to each machine. The only outstanding rebuild is still `.51`, for `~/.local/bin` on `PATH`, and that is optional now that the commands read the secrets file themselves. + +## Source paths observed + +- `tmp/officetest/.harness/harness.toml` +- `home/sam/SECURITY` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34|Rich discovery: now 2026-11-12 07:09:34]] +- context: [[packs/paperclip-harness/20-context/paperclip-harness|now]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|now repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/with.md b/.pi/memory-vault/packs/paperclip-harness/20-context/with.md new file mode 100644 index 0000000..15aa332 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/with.md @@ -0,0 +1,160 @@ +--- +type: context +id: context.paperclip-harness.with +title: with +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-12 +tags: + - pack/paperclip-harness + - agent-memory/context + - autolearn + - rich-discovery + - repo/with +--- + +# with + +## Rich discovery update (2026-11-12 09:01:33) + +## Yes — lazygit handles `.gitignore`, so no rethink needed + +I checked the installed build rather than trusting memory. `lazygit 0.66.2`, and its binary contains exactly the code paths and menu items for this: + +``` +FilesController.ignoreOrExcludeFile / ignoreOrExcludeMenu +"Add to .gitignore" "Add to .git/info/exclude" "Ignore file" +"Cannot ignore .gitignore" +"Can't stage '{{.path}}' because it is a worktree. Remove it with …, or ignore it with …" +"Amend last commit" +``` + +So from the Files panel: + +| key | does | +|---|---| +| `i` | menu: **Add to .gitignore** or **Add to .git/info/exclude** for the selected file or folder | +| `space` | stage / unstage | +| `c` | commit | +| `A` | amend the last commit (safe here: nothing is pushed) | +| `e` | edit the file, `.gitignore` included | +| `?` | every key, in case you want the exact ones for this version | + +Three details worth knowing: + +- It adds the **path** of what you select, one entry per file. Adding the folder `evidence/` ignores the whole folder. For a pattern like `*secret*` you edit `.gitignore` with `e`. +- It refuses to ignore `.gitignore` itself, so that file always gets committed. +- It already knows how to complain about worktrees, which is the next point. + +Our `t` tool chooser already opens lazygit **with the project folder as its working directory**, so it lands in the right repository with no setup. + +## The one thing that must not be optional + +Paperclip puts each task's worktree **inside the project folder**, at `.paperclip/worktrees/…`. If git ever sees those, the repository becomes a mess of nested checkouts of itself. So `.paperclip/` has to be in `.gitignore` from the first moment, on every path — not as advice, as a rule. lazygit warns about staging a worktree, but the ignore entry should already be there before it can happen. + +`.harness/` is the opposite: it holds the project's own `check.sh` and notes, the agent may need it, so it should be committed. + +## Revised Option 1 — two choices, no file list + +You are right that listing files is useless: you cannot act on the list at that moment. + +``` +This folder is not a git repository, so each task cannot have its own branch and worktree. + + Y make the repository and commit EVERY file in it + Hundreds is fine. Anything matched by .gitignore is left out. + A .gitignore is written first, containing .paperclip/ — that one is not optional. + + N no repository: no worktrees, tasks edit this folder directly, + no diff to review and nothing to merge +``` + +Two answers, both honest, and the sentence about `.gitignore` does the teaching. + +## The ordering problem, and why your sequence is the right one + +If `office` creates the repository **and commits in the same breath**, you never get the chance to exclude anything first. Fixing it afterwards means `git rm --cached` and `--amend` — doable in lazygit, but rewriting the first commit is exactly the kind of thing that goes wrong quietly. + +So the flow you described is the correct one, and I would build it as two visible steps: + +``` +office ~/SECURITY --new "security" + made a repository on branch main + wrote .gitignore → .paperclip/ + staged everything; nothing is committed yet + 8 files staged, 1 folder (evidence/) + + Run t → lazygit to exclude what you do not want (`i` on a file or folder), + then `c` to commit. When a commit exists, run this command again and it will + create the project with worktrees. +``` + +and the second run, seeing a commit, creates the project with `isolated_workspace` and the base branch it finds. + +That way no tool ever writes history on your behalf, and the exclusion decision happens where you can actually see the files — in lazygit. For anyone who does not want two steps, `--commit-all` does it in one. + +## The `git` tab in `Project>project` + +It shows facts, read live from the folder and from Paperclip each time the pane draws: + +``` +repository yes, branch main, 1 commit +remote none (not needed for worktrees) +worktrees isolated per task (Paperclip policy: isolated_workspace, base main) +changed 8 staged · 0 unstaged · 3 untracked · 2 ignored +ignored .paperclip/ evidence/ +next t → lazygit: `i` to ignore a file, `c` to commit +``` + +Two things it deliberately does **not** do: + +- **it does not edit `.gitignore` itself.** lazygit already does it correctly, including `.git/info/exclude` and the guard on `.gitignore`. Two implementations of one job drift apart; one is enough. +- **it does not hold a copy of the state.** Same rule as everything else here: read it, do not remember it. + +And a line in `Project>overview` pointing at it, so the tab is found rather than discovered. + +## The banner + +One line, only while something is wrong, in every pane: + +``` +no worktrees — no commit in this folder · Project>project → git (t: lazygit) +``` + +It disappears the moment a commit exists, so it cannot become furniture. + +## What I would build + +| | | +|---|---| +| Gate in `office` — two choices, write `.gitignore`, stage, stop before committing | **yes** | +| `--commit-all` for the one-step case | **yes** | +| `git` tab in `Project>project`: state, policy, counts, one-line next step | **yes** | +| One-line banner in every pane while not worktree-ready | **yes** | +| `.gitignore` editing inside our interface | **no** — lazygit does it, `t` opens it | +| Interesting complication: showing `staged` counts means running `git status` | read on refresh, on a button, not on every keystroke | + +I have not touched `~/SECURITY`. It is still not a repository, and I will not run `office` on it — when the gate exists you can run it yourself and answer Y or N with everything in front of you. + +## Source paths observed + +- `run/current-system/sw/bin/lazygit` +- `b/rebase-merge/doneremote.origin.urlrebase.updateRefsopen` +- `.git/info/excludeRemove` +- `.git/info/exclude` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33|Rich discovery: with 2026-11-12 09:01:33]] +- context: [[packs/paperclip-harness/20-context/with|with]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|with repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md b/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md index b38a415..a788315 100644 --- a/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md +++ b/.pi/memory-vault/packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li.md @@ -549,3 +549,34 @@ Follow-up: consider making `office` warn/refuse when folder's machine != Papercl - session: [[packs/paperclip-harness/80-sessions/paperclip-harness-multi-machine-setup-program-delivery-git-w|Paperclip harness multi-machine setup: program delivery, git worktrees vs local_path, env var inheritance …[truncated]…]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-cannot-2026-11-12-06-28-28|Rich discovery: cannot 2026-11-12 06:28:28]] - context: [[packs/paperclip-harness/20-context/home|cannot]] + + +--- + +## Update (2026-11-12 07:23:27) + +Proposal put to the user (not yet approved/implemented) for alerting users that git must be set up at project creation and that files may need to be gitignored. + +Decision (recommended): +- BUILD a real three-answer gate at creation (A commit everything with file list shown; B write starter .gitignore then commit rest; C no repo / direct-edit mode). Rationale: the commit decision is made once, with the file list in front of the user, because commit contents define what the agent sees. +- BUILD a `git` tab in `Project>project` that reads the folder live (repo/branch/commits/remote/worktrees/ignored + project policy + copy-paste instructions; `t`→lazygit, `g`→starter .gitignore). Rationale: honest, always-current facts about the project; also surfaces policy mismatch like `no isolation (policy: local_path)`. +- BUILD a one-line banner in every pane while the project isn't a git repo: `no worktrees — this folder is not a git repository · Project>project → git`. Catches pre-existing projects like the current `security` one. +- Optionally write a starter `.gitignore` only on demand. + +REJECTED / NOT TO BUILD: +- A new ninth pane (8 already; this is a project fact, not a work page). +- Auto-committing anything (what you commit is what the agent sees; must never be a side effect). +- Requiring a remote (worktrees + merges are local; remote is backup only). + +Consequences/caveats: "everything" must never be shown without the concrete file list; the git tab must show Paperclip policy alongside folder state; implementation still pending user selection among the four options. Do not run `office` on `~/SECURITY` — user initializes it themselves. + +## Evidence + +- Confidence: 80% +- Reason: Captures a clear architectural/UX decision with rationale and rejected alternatives, though it is a pending proposal awaiting user confirmation. + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Paperclip repo/git mechanics: worktrees, commits, bin/merge, remote necessity]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Decision (proposed, pending user pick): gate git at project creation, add Project>project git tab, add no …[truncated]…]] +- session: [[packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re|Git-init gating for Paperclip projects: design options and repo-mechanics findings]] diff --git a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md index e6f2d45..736f966 100644 --- a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md +++ b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md @@ -6107,3 +6107,426 @@ Three ways to close the gap, and I have already put the least intrusive one in t - session: [[packs/paperclip-harness/80-sessions/paperclip-harness-multi-machine-setup-program-delivery-git-w|Paperclip harness multi-machine setup: program delivery, git worktrees vs local_path, env var inheritance …[truncated]…]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-cannot-2026-11-12-06-28-28|Rich discovery: cannot 2026-11-12 06:28:28]] - context: [[packs/paperclip-harness/20-context/home|cannot]] + + +--- + +## Update (2026-11-12 07:09:34) + +## Structural/configuration observations (2026-11-12 07:09:34) + +Your summary of the machinery is exactly right, and nothing you asked was silly. TUIOS on each machine, `office` copied to each, Paperclip and therefore the agents only on `.13`. Everything below follows from that one fact. + +## 1. Worktrees by default — agreed, and now it is the default + +You are right that worktrees are the normal case, not an optional extra. Without them two tasks edit the same files, and `Work>change`, `Work>review` and `Work>merge` have nothing to work from — they stop being a review gate and become decoration. + +I also found a real bug while doing it: the base branch was hardcoded to `main`. A repository on `master` would have been given a worktree policy pointing at a branch that does not exist, so every run would fail on first use. It is now read from the folder. + +``` +worktrees: yes — a repository on branch 'main', so each task gets its own +worktrees: no — this is not a repository +``` + +## 2. Asking, not doing it silently + +This is the part where I would push back on doing it automatically, and `~/SECURITY` is the perfect example of why. + +To give a folder worktrees you have to run `git init`, `git add -A` and a first commit — which commits **every file in that folder**, including anything you forgot was there. In `SECURITY` that means `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`. Committing is a decision about your own files, and it is not one a setup command should make on your behalf. A first commit is also nearly impossible to take back quietly. + +So it asks, and says what it will do before doing it: + +``` +/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree. + without them, two tasks can change the same files at once, and there + is no diff to review and nothing to merge. + +make it a repository now? This runs: git init -b main, git add -A, +and one commit of EVERY file in the folder, including anything you +have not noticed. Add a .gitignore first if that is not what you want. + type yes to do it: +``` + +Three ways through, in the order I would use them: + +| Situation | What happens | +|---|---| +| at a terminal | it asks; `yes` makes the repository, anything else leaves it alone | +| in a script | `--git-init` makes it without asking | +| no terminal at all | it changes nothing and prints the exact commands to run yourself | + +Say no, and the project is still created — as `local_path`, in place, with no branch and nothing to merge. That is honest for a folder of notes and wrong for code, and it now says which one you got. + +Verified on a throwaway folder: with `--git-init` it made one commit on `main` and reported ready; a second call found it already ready and did nothing; a repository with no commit was refused with the right one-line fix. + +## 3. Warning when it is not the Paperclip machine — and refusing for a new project + +I made it stronger than a warning for `--new`, because the failure is invisible until a task runs: + +``` +refused: this folder is on sam-4screen-desktop, but Paperclip and its agents run on nixos-desktop + the panes will open here and will read this folder correctly + the agents will not: they start on nixos-desktop, where /home/sam/SECURITY does not exist + a task you assign will fail, or work on a different folder + move the folder to nixos-desktop, mount it there, or pass --anywhere to go ahead anyway +``` + +My reasoning for refuse rather than warn: creating that project writes a lasting record in Paperclip that can never work, and nothing about the panes will look wrong. Adopting an existing project — `--project` instead of `--new` — only warns, because opening panes to read a folder that lives elsewhere is a legitimate thing to want. + +`--anywhere` proceeds anyway. + +## What this means for the `security` project + +It is currently `local_path` on `.27`, so it has the problem in two ways at once: no worktrees, and its folder is invisible to the agents. A project's policy is fixed when it is created, so it cannot be upgraded — it has to be made again. + +You have three choices, and I have not touched it while you decide: + +1. **Move the notes to `.13`** and re-create it there. Cleanest: panes, agents and worktrees all on the same machine. +2. **Leave it on `.27`** and use its panes only as a place to read and write the notes yourself, with no agent work. +3. **Mount `.27`'s home on `.13`** so agents can see the path. Then re-create it. Adds a mount that can fail silently. + +If you want option 1, the commands are: + +```sh +cd ~/SECURITY && git init -b main && git add -A && git commit -m "security notes as they stand" +rsync -a ~/SECURITY/ sam@192.168.20.13:/home/sam/SECURITY/ # from .27 +# then on .13: +office ~/SECURITY --new "security" +``` + +Add a `.gitignore` before the first commit if any of those files should stay out of the history. + +## Committed + +`paperclip-harness` → `f7b77f3`. `office --help` now documents the worktree rule, the question, and where things run, alongside the per-machine commands. + +Nothing needs rebuilding for any of this — it is all in the commands, which `--host` copies to each machine. The only outstanding rebuild is still `.51`, for `~/.local/bin` on `PATH`, and that is optional now that the commands read the secrets file themselves. + +## Source paths observed + +- `tmp/officetest/.harness/harness.toml` +- `home/sam/SECURITY` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34|Rich discovery: now 2026-11-12 07:09:34]] +- context: [[packs/paperclip-harness/20-context/paperclip-harness|now]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|now repository structure and configuration patterns]] + + +--- + +## Update (2026-11-12 07:23:27) + +Verified/established facts about how Paperclip uses git (relevant when advising on project setup): + +- `bin/merge` is LOCAL only — it rebases and merges; there is no `push` command in it (checked by search). So merging a task branch does not publish anywhere. +- `git worktree add` needs an existing commit to cut the branch from. A repo with no commit → Paperclip's worktree step fails. +- A worktree is a checkout of a commit: uncommitted/unignored-vs-ignored files do NOT appear in the worktree. Therefore an empty initial commit gives the agent an empty working view. Rule of thumb: what you commit = what the agent sees; ignored files are invisible to it. +- Paperclip records only a `cwd` for a project; it does not require a remote and does not itself push. A remote (e.g. Gitea) is optional, for backup/cross-machine copies; cloning/moving to another machine otherwise needs push or rsync. +- Project isolation is a project policy (e.g. `local_path` = no worktree isolation) surfaced in `Project>project`; mismatch between the folder's git state and the policy is currently invisible from the panes. +- lazygit is already available in a tool pane via hotkey `t`. + +Safe source paths referenced: `bin/merge`, `Project>project` tabs (`overview`, `facts`, `costs`, `settings`, `projects`). + +## Evidence + +- Confidence: 88% +- Reason: Factual repository/mechanics discoveries with safe paths that will guide future git-related work. + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Paperclip repo/git mechanics: worktrees, commits, bin/merge, remote necessity]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Decision (proposed, pending user pick): gate git at project creation, add Project>project git tab, add no …[truncated]…]] +- session: [[packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re|Git-init gating for Paperclip projects: design options and repo-mechanics findings]] + + +--- + +## Update (2026-11-12 09:01:33) + +## Structural/configuration observations (2026-11-12 09:01:33) + +## Yes — lazygit handles `.gitignore`, so no rethink needed + +I checked the installed build rather than trusting memory. `lazygit 0.66.2`, and its binary contains exactly the code paths and menu items for this: + +``` +FilesController.ignoreOrExcludeFile / ignoreOrExcludeMenu +"Add to .gitignore" "Add to .git/info/exclude" "Ignore file" +"Cannot ignore .gitignore" +"Can't stage '{{.path}}' because it is a worktree. Remove it with …, or ignore it with …" +"Amend last commit" +``` + +So from the Files panel: + +| key | does | +|---|---| +| `i` | menu: **Add to .gitignore** or **Add to .git/info/exclude** for the selected file or folder | +| `space` | stage / unstage | +| `c` | commit | +| `A` | amend the last commit (safe here: nothing is pushed) | +| `e` | edit the file, `.gitignore` included | +| `?` | every key, in case you want the exact ones for this version | + +Three details worth knowing: + +- It adds the **path** of what you select, one entry per file. Adding the folder `evidence/` ignores the whole folder. For a pattern like `*secret*` you edit `.gitignore` with `e`. +- It refuses to ignore `.gitignore` itself, so that file always gets committed. +- It already knows how to complain about worktrees, which is the next point. + +Our `t` tool chooser already opens lazygit **with the project folder as its working directory**, so it lands in the right repository with no setup. + +## The one thing that must not be optional + +Paperclip puts each task's worktree **inside the project folder**, at `.paperclip/worktrees/…`. If git ever sees those, the repository becomes a mess of nested checkouts of itself. So `.paperclip/` has to be in `.gitignore` from the first moment, on every path — not as advice, as a rule. lazygit warns about staging a worktree, but the ignore entry should already be there before it can happen. + +`.harness/` is the opposite: it holds the project's own `check.sh` and notes, the agent may need it, so it should be committed. + +## Revised Option 1 — two choices, no file list + +You are right that listing files is useless: you cannot act on the list at that moment. + +``` +This folder is not a git repository, so each task cannot have its own branch and worktree. + + Y make the repository and commit EVERY file in it + Hundreds is fine. Anything matched by .gitignore is left out. + A .gitignore is written first, containing .paperclip/ — that one is not optional. + + N no repository: no worktrees, tasks edit this folder directly, + no diff to review and nothing to merge +``` + +Two answers, both honest, and the sentence about `.gitignore` does the teaching. + +## The ordering problem, and why your sequence is the right one + +If `office` creates the repository **and commits in the same breath**, you never get the chance to exclude anything first. Fixing it afterwards means `git rm --cached` and `--amend` — doable in lazygit, but rewriting the first commit is exactly the kind of thing that goes wrong quietly. + +So the flow you described is the correct one, and I would build it as two visible steps: + +``` +office ~/SECURITY --new "security" + made a repository on branch main + wrote .gitignore → .paperclip/ + staged everything; nothing is committed yet + 8 files staged, 1 folder (evidence/) + + Run t → lazygit to exclude what you do not want (`i` on a file or folder), + then `c` to commit. When a commit exists, run this command again and it will + create the project with worktrees. +``` + +and the second run, seeing a commit, creates the project with `isolated_workspace` and the base branch it finds. + +That way no tool ever writes history on your behalf, and the exclusion decision happens where you can actually see the files — in lazygit. For anyone who does not want two steps, `--commit-all` does it in one. + +## The `git` tab in `Project>project` + +It shows facts, read live from the folder and from Paperclip each time the pane draws: + +``` +repository yes, branch main, 1 commit +remote none (not needed for worktrees) +worktrees isolated per task (Paperclip policy: isolated_workspace, base main) +changed 8 staged · 0 unstaged · 3 untracked · 2 ignored +ignored .paperclip/ evidence/ +next t → lazygit: `i` to ignore a file, `c` to commit +``` + +Two things it deliberately does **not** do: + +- **it does not edit `.gitignore` itself.** lazygit already does it correctly, including `.git/info/exclude` and the guard on `.gitignore`. Two implementations of one job drift apart; one is enough. +- **it does not hold a copy of the state.** Same rule as everything else here: read it, do not remember it. + +And a line in `Project>overview` pointing at it, so the tab is found rather than discovered. + +## The banner + +One line, only while something is wrong, in every pane: + +``` +no worktrees — no commit in this folder · Project>project → git (t: lazygit) +``` + +It disappears the moment a commit exists, so it cannot become furniture. + +## What I would build + +| | | +|---|---| +| Gate in `office` — two choices, write `.gitignore`, stage, stop before committing | **yes** | +| `--commit-all` for the one-step case | **yes** | +| `git` tab in `Project>project`: state, policy, counts, one-line next step | **yes** | +| One-line banner in every pane while not worktree-ready | **yes** | +| `.gitignore` editing inside our interface | **no** — lazygit does it, `t` opens it | +| Interesting complication: showing `staged` counts means running `git status` | read on refresh, on a button, not on every keystroke | + +I have not touched `~/SECURITY`. It is still not a repository, and I will not run `office` on it — when the gate exists you can run it yourself and answer Y or N with everything in front of you. + +## Source paths observed + +- `run/current-system/sw/bin/lazygit` +- `b/rebase-merge/doneremote.origin.urlrebase.updateRefsopen` +- `.git/info/excludeRemove` +- `.git/info/exclude` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33|Rich discovery: with 2026-11-12 09:01:33]] +- context: [[packs/paperclip-harness/20-context/with|with]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|with repository structure and configuration patterns]] + + +--- + +## Update (2026-11-12 09:24:20) + +## Structural/configuration observations (2026-11-12 09:24:20) + +Research done, measured against the running instance and the installed build rather than remembered. Here is what Paperclip actually offers for git. + +## 1. The CLI has no git commands at all + +``` +paperclipai project list · get · create · update · delete +paperclipai project-workspace list · create · update · delete · runtime-service · runtime-command +paperclipai workspace list · get · close-readiness · operations · update · runtime-* +paperclipai cost · openapi · … +``` + +Nothing initialises a repository, nothing reports whether a folder is one. The git-shaped verbs are all about *execution workspaces* — the per-task worktrees. + +## 2. The web "git screen" is the execution workspace page + +Two things on that page, found in the served bundle: + +- a **"Git status"** panel showing `branchName`, and +- a **"Source control"** section with `Branch name` ("Useful for isolated worktrees") and `Base ref` editors. + +So the screen you are thinking of is per *task worktree*, not the project folder. + +## 3. The API behind it — and this is useful to us + +`GET /api/execution-workspaces/{id}/close-readiness` returns a `git` object. Live, for AUD-172 a moment ago: + +```json +"git": { + "repoRoot": "/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…", + "workspacePath":"/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…", + "branchName": "AUD-172-add-a-status-md-with-one-line", + "baseRef": "main", + "hasDirtyTrackedFiles": false, + "hasUntrackedFiles": false, + "dirtyEntryCount": 0, + "untrackedEntryCount": 0, + "aheadCount": 0, + "behindCount": 1, + "isMergedIntoBase": true, + "createdByRuntime": true +}, +"deliveryState": "merged_by_ancestry", +"warnings": ["This workspace is 1 commit behind main."] +``` + +That is Paperclip doing the git reading for you: branch, dirtiness, how far behind, whether it is already merged. Nothing we should reimplement. + +## 4. The project workspace side is declaration only + +`GET /api/projects/{id}/workspaces` returns configuration, no git state: + +``` +sourceType local_path | git_repo | remote_managed | non_git_path +cwd · repoUrl · repoRef · defaultRef +setupCommand · cleanupCommand +visibility · runtimeConfig · isPrimary +``` + +And the policy on the project has more hooks than we use: + +``` +workspaceStrategy.type project_primary | git_worktree | adapter_managed | cloud_sandbox + baseRef · branchTemplate · existingBranch · worktreeParentDir + provisionCommand · runtimeProvisionCommand · teardownCommand +defaultMode shared_workspace | isolated_workspace | operator_branch | adapter_default +sharedWorkspaceConcurrency auto | serialize | allow +``` + +## 5. The finding that matters most + +**Paperclip does not check that the folder is a repository.** The whole validation is: + +```js +function validateProjectWorkspace(value, ctx) { + const sourceType = value.sourceType ?? "local_path"; + const hasCwd = …; const hasRepo = …; + if (sourceType === "remote_managed") { …; return; } + if (!hasCwd && !hasRepo) ctx.addIssue({ message: "Workspace requires at least one of cwd or repoUrl." }); +} +``` + +`git_repo` with a folder that is not a repository is accepted and fails later, when a task first tries to make a worktree. Your instinct was right: this has to be caught on our side, at creation, or it is not caught at all. + +## 6. What cannot be done through Paperclip + +- **No endpoint to create a worktree on demand.** Execution workspaces appear when a task runs. So the first task is the only real test that the repository is usable — we cannot "try one" beforehand. +- **No hook for `git init`.** `provisionCommand` runs per worktree, which is far too late, and `setupCommand` is per project workspace but is not a validation step. +- `POST /api/execution-workspaces/{id}/reconcile-branch` exists for repairing a drifted branch record, and `existingBranch` lets a task be pointed at a branch you made yourself, instead of `{{issue.identifier}}-{{slug}}`. + +## 7. One correction to what I built + +Our create body sets the policy's `baseRef` but leaves the project workspace's `defaultRef` as `null` — visible on both existing projects. Those are two fields for the same idea and both should be set. Small, but it is the kind of gap that bites later. + +Also: `non_git_path` exists as a `sourceType`, but it appears exactly once in the whole build (in the schema) and nothing treats it differently from `local_path`. I would keep using `local_path` and not invent a meaning it does not have. + +## What this means for the plan you approved + +| Piece | Change from what we discussed | +|---|---| +| Gate at creation | stays ours, and is now clearly the **only** early check. Also set `defaultRef`, and refuse to call a non-repo `git_repo` | +| `git` tab | becomes **two layers**: the folder's own state (we read it), and Paperclip's `close-readiness` scan of each task worktree — branch, dirty, ahead/behind, merged. The second gives "1 commit behind main" and "merged into main" without us computing anything | +| `.gitignore` editing | unchanged: lazygit, opened with `t`, files panel `i`, then `c` to commit | +| Banner | unchanged, one line while the folder cannot hold worktrees | +| Two-step creation | unchanged: repo + `.gitignore` + stage, stop, you commit in lazygit, run again to get the worktree project | + +The research also suggests one extra thing worth having in the `git` tab, since the data is free: **the list of this project's worktrees with their git state**, including ones that are behind `main` or already merged. That is the same view Paperclip's web screen gives, in the place you are already working. + +I have not touched `~/SECURITY`, and I will not run `office` on it. + +Say go and I will build it in this order: the creation gate (with `defaultRef` and the honest `sourceType`), then the `git` tab with both layers, then the banner. All of it lives in the commands and the program, so nothing needs rebuilding on any machine. + +## Source paths observed + +- `./scripts/provision-worktree-runtime.sh` +- `./scripts/provision-worktree.sh` +- `./scripts/teardown-worktree.sh` +- `path/to/worktree` +- `github.com/acme/growth-playbook` +- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-add-a-status-md-with-one-line` +- `home/sam/paseo/projects/test-alpha` +- `home/sam/SECURITY` +- `p/worktrees/AUD-173-add-a-line-to-readme` +- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20|Rich discovery: git_repo 2026-11-12 09:24:20]] +- context: [[packs/paperclip-harness/20-context/home|git_repo]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|git_repo repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re.md new file mode 100644 index 0000000..beb9c8c --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re.md @@ -0,0 +1,57 @@ +--- +type: session +id: session.paperclip-harness.git-init-gating-for-paperclip-projects-design-options-and-re +title: Git-init gating for Paperclip projects: design options and repo-mechanics findings +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-12 +tags: + - pack/paperclip-harness + - agent-memory/session + - paperclip-harness + - git + - worktree + - project-creation + - gitignore + - security-project + - design-proposal +--- + +# Git-init gating for Paperclip projects: design options and repo-mechanics findings + +Turn was a design/discovery discussion about how Paperclip should enforce that a project folder is a git repo at creation time. No code was written and no changes were made; the target folder `~/SECURITY` was explicitly protected by the user ("Do NOT START the SECURITY PROJECT — I want to initialize everything myself"). + +Key durable findings and mechanics established: + +1. `~/SECURITY` (reported to be on host `.13`) is NOT yet a git repository (confirmed: `fatal: not a git repository`). Its top-level contents (names only, contents not read): `01-caddy-exposure-inventory.csv`, `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`, `05-vulnerability-audit-php2shell-http2shell.md`, `06-independent-review.md`, `AGENTS.md`, and directories `evidence/`, `.harness/`, `.pi/` (10 entries total). + +2. Why the initial commit matters: (a) `git worktree add` requires a commit to branch from, so no commit means Paperclip's worktree step fails; (b) more importantly a worktree is a checkout of a commit, so anything not committed is invisible to the agent in the worktree — an `--allow-empty` first commit yields an empty agent view. Governing principle stated: **what you commit is what the agent works on; what you ignore is invisible to it.** + +3. Remote is optional: `git worktree` is local-only, Paperclip only records a `cwd` per project, and `bin/merge` does not push (verified — no `push` in it; merge rebases/merges locally). A remote (e.g. Gitea) is backup/multi-machine copy, not required for worktrees. + +4. Four proposed options for the creation-time alert, with recommendation to build Options 1–3 and skip a new pane / auto-commit / mandatory remote: + - Option 1 (BUILD): replace the yes/no git question at creation with three explicit answers (A commit everything incl. listed files; B write a starter `.gitignore` then commit the rest; C no repo — tasks edit the folder directly). Must show the actual file list, not the word "everything". + - Option 2 (BUILD): add a `git` tab to `Project>project` (alongside existing `overview`, `facts`, `costs`, `settings`, `projects`) that reads the folder live and shows repo state, branch, commits, remote, worktree/isolation status vs project policy (e.g. `no isolation (policy: local_path)`), ignored count, and copy-pasteable setup instructions; hotkeys `t` (lazygit, already wired as a tool pane) and `g` (write starter `.gitignore`). + - Option 3 (BUILD): one-line banner in every pane while the project isn't a git repo: `no worktrees — this folder is not a git repository · Project>project → git`. + - Option 4 (partial): starter `.gitignore` written only on demand, plus lazygit. + - REJECT: a ninth pane (8 is already many; this is a project fact not a work page); auto-committing anything; requiring a remote. + +5. Proposed starter `.gitignore` for the security folder (ignore) `.paperclip/`, `.pi/`, `.env`, `.env.*`, `*.pem`, `*.key`, `*secret*`, `*credential*`, `evidence/`, `.DS_Store`, `__pycache__/`, `*.pyc`; intentionally COMMIT `.harness/` (holds project's own `check.sh` and notes the agent may need). Rationale: `03-secret-rotation.md` and `evidence/` are the two things not to commit by reflex. + +Status: no implementation; awaiting user to choose which of the four options to build. Do not run `office` on `~/SECURITY`. + +## Evidence + +- Confidence: 90% +- Reason: Rich sanitized discovery + design proposal with concrete mechanics, paths, rejected alternatives, and explicit user guardrails; useful for a future agent continuing the work. + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|Paperclip repo/git mechanics: worktrees, commits, bin/merge, remote necessity]] +- decision: [[packs/paperclip-harness/50-decisions/paperclip-harness-ui-six-workspaces-spec-first-four-panel-li|Decision (proposed, pending user pick): gate git at project creation, add Project>project git tab, add no …[truncated]…]] +- session: [[packs/paperclip-harness/80-sessions/git-init-gating-for-paperclip-projects-design-options-and-re|Git-init gating for Paperclip projects: design options and repo-mechanics findings]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20.md new file mode 100644 index 0000000..7a84912 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20.md @@ -0,0 +1,161 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-git-repo-2026-11-12-09-24-20 +title: Rich discovery: git_repo 2026-11-12 09:24:20 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-12 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/git-repo +--- + +# Rich discovery: git_repo 2026-11-12 09:24:20 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `./scripts/provision-worktree-runtime.sh` +- `./scripts/provision-worktree.sh` +- `./scripts/teardown-worktree.sh` +- `path/to/worktree` +- `github.com/acme/growth-playbook` +- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-add-a-status-md-with-one-line` +- `home/sam/paseo/projects/test-alpha` +- `home/sam/SECURITY` +- `p/worktrees/AUD-173-add-a-line-to-readme` +- `home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-` + +## Final answer snapshot + +Research done, measured against the running instance and the installed build rather than remembered. Here is what Paperclip actually offers for git. + +## 1. The CLI has no git commands at all + +``` +paperclipai project list · get · create · update · delete +paperclipai project-workspace list · create · update · delete · runtime-service · runtime-command +paperclipai workspace list · get · close-readiness · operations · update · runtime-* +paperclipai cost · openapi · … +``` + +Nothing initialises a repository, nothing reports whether a folder is one. The git-shaped verbs are all about *execution workspaces* — the per-task worktrees. + +## 2. The web "git screen" is the execution workspace page + +Two things on that page, found in the served bundle: + +- a **"Git status"** panel showing `branchName`, and +- a **"Source control"** section with `Branch name` ("Useful for isolated worktrees") and `Base ref` editors. + +So the screen you are thinking of is per *task worktree*, not the project folder. + +## 3. The API behind it — and this is useful to us + +`GET /api/execution-workspaces/{id}/close-readiness` returns a `git` object. Live, for AUD-172 a moment ago: + +```json +"git": { + "repoRoot": "/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…", + "workspacePath":"/home/sam/paseo/projects/test-alpha/.paperclip/worktrees/AUD-172-…", + "branchName": "AUD-172-add-a-status-md-with-one-line", + "baseRef": "main", + "hasDirtyTrackedFiles": false, + "hasUntrackedFiles": false, + "dirtyEntryCount": 0, + "untrackedEntryCount": 0, + "aheadCount": 0, + "behindCount": 1, + "isMergedIntoBase": true, + "createdByRuntime": true +}, +"deliveryState": "merged_by_ancestry", +"warnings": ["This workspace is 1 commit behind main."] +``` + +That is Paperclip doing the git reading for you: branch, dirtiness, how far behind, whether it is already merged. Nothing we should reimplement. + +## 4. The project workspace side is declaration only + +`GET /api/projects/{id}/workspaces` returns configuration, no git state: + +``` +sourceType local_path | git_repo | remote_managed | non_git_path +cwd · repoUrl · repoRef · defaultRef +setupCommand · cleanupCommand +visibility · runtimeConfig · isPrimary +``` + +And the policy on the project has more hooks than we use: + +``` +workspaceStrategy.type project_primary | git_worktree | adapter_managed | cloud_sandbox + baseRef · branchTemplate · existingBranch · worktreeParentDir + provisionCommand · runtimeProvisionCommand · teardownCommand +defaultMode shared_workspace | isolated_workspace | operator_branch | adapter_default +sharedWorkspaceConcurrency auto | serialize | allow +``` + +## 5. The finding that matters most + +**Paperclip does not check that the folder is a repository.** The whole validation is: + +```js +function validateProjectWorkspace(value, ctx) { + const sourceType = value.sourceType ?? "local_path"; + const hasCwd = …; const hasRepo = …; + if (sourceType === "remote_managed") { …; return; } + if (!hasCwd && !hasRepo) ctx.addIssue({ message: "Workspace requires at least one of cwd or repoUrl." }); +} +``` + +`git_repo` with a folder that is not a repository is accepted and fails later, when a task first tries to make a worktree. Your instinct was right: this has to be caught on our side, at creation, or it is not caught at all. + +## 6. What cannot be done through Paperclip + +- **No endpoint to create a worktree on demand.** Execution workspaces appear when a task runs. So the first task is the only real test that the repository is usable — we cannot "try one" beforehand. +- **No hook for `git init`.** `provisionCommand` runs per worktree, which is far too late, and `setupCommand` is per project workspace but is not a validation step. +- `POST /api/execution-workspaces/{id}/reconcile-branch` exists for repairing a drifted branch record, and `existingBranch` lets a task be pointed at a branch you made yourself, instead of `{{issue.identifier}}-{{slug}}`. + +## 7. One correction to what I built + +Our create body sets the policy's `baseRef` but leaves the project workspace's `defaultRef` as `null` — visible on both existing projects. Those are two fields for the same idea and both should be set. Small, but it is the kind of gap that bites later. + +Also: `non_git_path` exists as a `sourceType`, but it appears exactly once in the whole build (in the schema) and nothing treats it differently from `local_path`. I would keep using `local_path` and not invent a meaning it does not have. + +## What this means for the plan you approved + +| Piece | Change from what we discussed | +|---|---| +| Gate at creation | stays ours, and is now clearly the **only** early check. Also set `defaultRef`, and refuse to call a non-repo `git_repo` | +| `git` tab | becomes **two layers**: the folder's own state (we read it), and Paperclip's `close-readiness` scan of each task worktree — branch, dirty, ahead/behind, merged. The second gives "1 commit behind main" and "merged into main" without us computing anything | +| `.gitignore` editing | unchanged: lazygit, opened with `t`, files panel `i`, then `c` to commit | +| Banner | unchanged, one line while the folder cannot hold worktrees | +| Two-step creation | unchanged: repo + `.gitignore` + stage, stop, you commit in lazygit, run again to get the worktree project | + +The research also suggests one extra thing worth having in the `git` tab, since the data is free: **the list of this project's worktrees with their git state**, including ones that are behind `main` or already merged. That is the same view Paperclip's web screen gives, in the place you are already working. + +I have not touched `~/SECURITY`, and I will not run `office` on it. + +Say go and I will build it in this order: the creation gate (with `defaultRef` and the honest `sourceType`), then the `git` tab with both layers, then the banner. All of it lives in the commands and the program, so nothing needs rebuilding on any machine. + +## Evidence + +- Confidence: 86% +- Reason: Preserve full sanitized discovery detail from a rich investigation turn + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-git-repo-2026-11-12-09-24-20|Rich discovery: git_repo 2026-11-12 09:24:20]] +- context: [[packs/paperclip-harness/20-context/home|git_repo]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|git_repo repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34.md new file mode 100644 index 0000000..f8d24f2 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34.md @@ -0,0 +1,129 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-now-2026-11-12-07-09-34 +title: Rich discovery: now 2026-11-12 07:09:34 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-12 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/now +--- + +# Rich discovery: now 2026-11-12 07:09:34 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `tmp/officetest/.harness/harness.toml` +- `home/sam/SECURITY` + +## Final answer snapshot + +Your summary of the machinery is exactly right, and nothing you asked was silly. TUIOS on each machine, `office` copied to each, Paperclip and therefore the agents only on `.13`. Everything below follows from that one fact. + +## 1. Worktrees by default — agreed, and now it is the default + +You are right that worktrees are the normal case, not an optional extra. Without them two tasks edit the same files, and `Work>change`, `Work>review` and `Work>merge` have nothing to work from — they stop being a review gate and become decoration. + +I also found a real bug while doing it: the base branch was hardcoded to `main`. A repository on `master` would have been given a worktree policy pointing at a branch that does not exist, so every run would fail on first use. It is now read from the folder. + +``` +worktrees: yes — a repository on branch 'main', so each task gets its own +worktrees: no — this is not a repository +``` + +## 2. Asking, not doing it silently + +This is the part where I would push back on doing it automatically, and `~/SECURITY` is the perfect example of why. + +To give a folder worktrees you have to run `git init`, `git add -A` and a first commit — which commits **every file in that folder**, including anything you forgot was there. In `SECURITY` that means `02-incident-2026-11-11.md`, `03-secret-rotation.md`, `04-login-audit.md`. Committing is a decision about your own files, and it is not one a setup command should make on your behalf. A first commit is also nearly impossible to take back quietly. + +So it asks, and says what it will do before doing it: + +``` +/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree. + without them, two tasks can change the same files at once, and there + is no diff to review and nothing to merge. + +make it a repository now? This runs: git init -b main, git add -A, +and one commit of EVERY file in the folder, including anything you +have not noticed. Add a .gitignore first if that is not what you want. + type yes to do it: +``` + +Three ways through, in the order I would use them: + +| Situation | What happens | +|---|---| +| at a terminal | it asks; `yes` makes the repository, anything else leaves it alone | +| in a script | `--git-init` makes it without asking | +| no terminal at all | it changes nothing and prints the exact commands to run yourself | + +Say no, and the project is still created — as `local_path`, in place, with no branch and nothing to merge. That is honest for a folder of notes and wrong for code, and it now says which one you got. + +Verified on a throwaway folder: with `--git-init` it made one commit on `main` and reported ready; a second call found it already ready and did nothing; a repository with no commit was refused with the right one-line fix. + +## 3. Warning when it is not the Paperclip machine — and refusing for a new project + +I made it stronger than a warning for `--new`, because the failure is invisible until a task runs: + +``` +refused: this folder is on sam-4screen-desktop, but Paperclip and its agents run on nixos-desktop + the panes will open here and will read this folder correctly + the agents will not: they start on nixos-desktop, where /home/sam/SECURITY does not exist + a task you assign will fail, or work on a different folder + move the folder to nixos-desktop, mount it there, or pass --anywhere to go ahead anyway +``` + +My reasoning for refuse rather than warn: creating that project writes a lasting record in Paperclip that can never work, and nothing about the panes will look wrong. Adopting an existing project — `--project` instead of `--new` — only warns, because opening panes to read a folder that lives elsewhere is a legitimate thing to want. + +`--anywhere` proceeds anyway. + +## What this means for the `security` project + +It is currently `local_path` on `.27`, so it has the problem in two ways at once: no worktrees, and its folder is invisible to the agents. A project's policy is fixed when it is created, so it cannot be upgraded — it has to be made again. + +You have three choices, and I have not touched it while you decide: + +1. **Move the notes to `.13`** and re-create it there. Cleanest: panes, agents and worktrees all on the same machine. +2. **Leave it on `.27`** and use its panes only as a place to read and write the notes yourself, with no agent work. +3. **Mount `.27`'s home on `.13`** so agents can see the path. Then re-create it. Adds a mount that can fail silently. + +If you want option 1, the commands are: + +```sh +cd ~/SECURITY && git init -b main && git add -A && git commit -m "security notes as they stand" +rsync -a ~/SECURITY/ sam@192.168.20.13:/home/sam/SECURITY/ # from .27 +# then on .13: +office ~/SECURITY --new "security" +``` + +Add a `.gitignore` before the first commit if any of those files should stay out of the history. + +## Committed + +`paperclip-harness` → `f7b77f3`. `office --help` now documents the worktree rule, the question, and where things run, alongside the per-machine commands. + +Nothing needs rebuilding for any of this — it is all in the commands, which `--host` copies to each machine. The only outstanding rebuild is still `.51`, for `~/.local/bin` on `PATH`, and that is optional now that the commands read the secrets file themselves. + +## Evidence + +- Confidence: 86% +- Reason: Preserve full sanitized discovery detail from a rich investigation turn + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-now-2026-11-12-07-09-34|Rich discovery: now 2026-11-12 07:09:34]] +- context: [[packs/paperclip-harness/20-context/paperclip-harness|now]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|now repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33.md new file mode 100644 index 0000000..89f348d --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33.md @@ -0,0 +1,163 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-with-2026-11-12-09-01-33 +title: Rich discovery: with 2026-11-12 09:01:33 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-12 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/with +--- + +# Rich discovery: with 2026-11-12 09:01:33 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `run/current-system/sw/bin/lazygit` +- `b/rebase-merge/doneremote.origin.urlrebase.updateRefsopen` +- `.git/info/excludeRemove` +- `.git/info/exclude` + +## Final answer snapshot + +## Yes — lazygit handles `.gitignore`, so no rethink needed + +I checked the installed build rather than trusting memory. `lazygit 0.66.2`, and its binary contains exactly the code paths and menu items for this: + +``` +FilesController.ignoreOrExcludeFile / ignoreOrExcludeMenu +"Add to .gitignore" "Add to .git/info/exclude" "Ignore file" +"Cannot ignore .gitignore" +"Can't stage '{{.path}}' because it is a worktree. Remove it with …, or ignore it with …" +"Amend last commit" +``` + +So from the Files panel: + +| key | does | +|---|---| +| `i` | menu: **Add to .gitignore** or **Add to .git/info/exclude** for the selected file or folder | +| `space` | stage / unstage | +| `c` | commit | +| `A` | amend the last commit (safe here: nothing is pushed) | +| `e` | edit the file, `.gitignore` included | +| `?` | every key, in case you want the exact ones for this version | + +Three details worth knowing: + +- It adds the **path** of what you select, one entry per file. Adding the folder `evidence/` ignores the whole folder. For a pattern like `*secret*` you edit `.gitignore` with `e`. +- It refuses to ignore `.gitignore` itself, so that file always gets committed. +- It already knows how to complain about worktrees, which is the next point. + +Our `t` tool chooser already opens lazygit **with the project folder as its working directory**, so it lands in the right repository with no setup. + +## The one thing that must not be optional + +Paperclip puts each task's worktree **inside the project folder**, at `.paperclip/worktrees/…`. If git ever sees those, the repository becomes a mess of nested checkouts of itself. So `.paperclip/` has to be in `.gitignore` from the first moment, on every path — not as advice, as a rule. lazygit warns about staging a worktree, but the ignore entry should already be there before it can happen. + +`.harness/` is the opposite: it holds the project's own `check.sh` and notes, the agent may need it, so it should be committed. + +## Revised Option 1 — two choices, no file list + +You are right that listing files is useless: you cannot act on the list at that moment. + +``` +This folder is not a git repository, so each task cannot have its own branch and worktree. + + Y make the repository and commit EVERY file in it + Hundreds is fine. Anything matched by .gitignore is left out. + A .gitignore is written first, containing .paperclip/ — that one is not optional. + + N no repository: no worktrees, tasks edit this folder directly, + no diff to review and nothing to merge +``` + +Two answers, both honest, and the sentence about `.gitignore` does the teaching. + +## The ordering problem, and why your sequence is the right one + +If `office` creates the repository **and commits in the same breath**, you never get the chance to exclude anything first. Fixing it afterwards means `git rm --cached` and `--amend` — doable in lazygit, but rewriting the first commit is exactly the kind of thing that goes wrong quietly. + +So the flow you described is the correct one, and I would build it as two visible steps: + +``` +office ~/SECURITY --new "security" + made a repository on branch main + wrote .gitignore → .paperclip/ + staged everything; nothing is committed yet + 8 files staged, 1 folder (evidence/) + + Run t → lazygit to exclude what you do not want (`i` on a file or folder), + then `c` to commit. When a commit exists, run this command again and it will + create the project with worktrees. +``` + +and the second run, seeing a commit, creates the project with `isolated_workspace` and the base branch it finds. + +That way no tool ever writes history on your behalf, and the exclusion decision happens where you can actually see the files — in lazygit. For anyone who does not want two steps, `--commit-all` does it in one. + +## The `git` tab in `Project>project` + +It shows facts, read live from the folder and from Paperclip each time the pane draws: + +``` +repository yes, branch main, 1 commit +remote none (not needed for worktrees) +worktrees isolated per task (Paperclip policy: isolated_workspace, base main) +changed 8 staged · 0 unstaged · 3 untracked · 2 ignored +ignored .paperclip/ evidence/ +next t → lazygit: `i` to ignore a file, `c` to commit +``` + +Two things it deliberately does **not** do: + +- **it does not edit `.gitignore` itself.** lazygit already does it correctly, including `.git/info/exclude` and the guard on `.gitignore`. Two implementations of one job drift apart; one is enough. +- **it does not hold a copy of the state.** Same rule as everything else here: read it, do not remember it. + +And a line in `Project>overview` pointing at it, so the tab is found rather than discovered. + +## The banner + +One line, only while something is wrong, in every pane: + +``` +no worktrees — no commit in this folder · Project>project → git (t: lazygit) +``` + +It disappears the moment a commit exists, so it cannot become furniture. + +## What I would build + +| | | +|---|---| +| Gate in `office` — two choices, write `.gitignore`, stage, stop before committing | **yes** | +| `--commit-all` for the one-step case | **yes** | +| `git` tab in `Project>project`: state, policy, counts, one-line next step | **yes** | +| One-line banner in every pane while not worktree-ready | **yes** | +| `.gitignore` editing inside our interface | **no** — lazygit does it, `t` opens it | +| Interesting complication: showing `staged` counts means running `git status` | read on refresh, on a button, not on every keystroke | + +I have not touched `~/SECURITY`. It is still not a repository, and I will not run `office` on it — when the gate exists you can run it yourself and answer Y or N with everything in front of you. + +## Evidence + +- Confidence: 86% +- Reason: Preserve full sanitized discovery detail from a rich investigation turn + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-with-2026-11-12-09-01-33|Rich discovery: with 2026-11-12 09:01:33]] +- context: [[packs/paperclip-harness/20-context/with|with]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|with repository structure and configuration patterns]] diff --git a/bin/harness b/bin/harness index 0145dcb..1a0f1d2 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/bin/lib/repo.py b/bin/lib/repo.py index 9ce2e4a..e5162f8 100644 --- a/bin/lib/repo.py +++ b/bin/lib/repo.py @@ -67,3 +67,56 @@ def make_repo(folder, quiet=False): return False, "git commit failed: %s" % detail[:200] say(" made a repository on branch %s, one commit" % branch(folder)) return True, "" + + +STARTER = """# Paperclip puts each task's worktree inside this folder, at .paperclip/. +# It is a checkout of this repository, so it must never be committed. +.paperclip/ + +# Add your own patterns below, one per line. For example: +# evidence/ +# .env +# *secret* +""" + + +def ensure_ignore(folder): + """Make sure .gitignore exists and that it excludes .paperclip/. + + Returns (changed, note). The .paperclip entry is not a preference: without + it the first commit takes the worktrees with it. + """ + path = os.path.join(folder, ".gitignore") + if not os.path.exists(path): + with open(path, "w") as fh: + fh.write(STARTER) + return True, "wrote .gitignore, ignoring .paperclip/" + with open(path) as fh: + text = fh.read() + for line in text.splitlines(): + line = line.strip().strip("/") + if line == ".paperclip" or line.startswith(".paperclip/"): + return False, ".gitignore already ignores .paperclip/" + add = "" if text.endswith("\n") or not text else "\n" + add += "\n# Paperclip's task worktrees; a commit must never take them.\n.paperclip/\n" + with open(path, "a") as fh: + fh.write(add) + return True, "added .paperclip/ to the existing .gitignore" + + +def stage_all(folder): + r = git(folder, "add", "-A") + return r.returncode == 0 + + +def commit_all(folder, message="Initial commit, before any Paperclip task"): + """Commit what is staged. Returns (ok, message).""" + r = git(folder, "-c", "user.name=Paperclip Harness", + "-c", "user.email=harness@localhost", + "commit", "-q", "-m", message) + if r.returncode != 0: + detail = (r.stderr or r.stdout).strip() + if "nothing to commit" in detail: + return False, "nothing to commit: the folder has no files git will take" + return False, "git commit failed: %s" % detail[:200] + return True, "" diff --git a/bin/office b/bin/office index 04534e8..e74a5aa 100755 --- a/bin/office +++ b/bin/office @@ -46,7 +46,8 @@ def parse(argv): """ opts = {"folder": ".", "folder_given": False, "project": "", "session": "", "dry": False, "host": "", "no_switch": False, "new": "", "remote": "", - "git_init": "--git-init" in argv, "anywhere": "--anywhere" in argv} + "git_init": "--git-init" in argv, "anywhere": "--anywhere" in argv, + "commit_all": "--commit-all" in argv, "git_init_commits": False} positional = [] i = 0 while i < len(argv): @@ -71,7 +72,7 @@ def parse(argv): return opts -def create_project(name, folder, dry): +def create_project(name, folder, dry, worktrees=True): """Create a Paperclip project for a folder, with the worktree policy set. Adopting a folder that Paperclip has never heard of needed two steps before: @@ -80,7 +81,7 @@ def create_project(name, folder, dry): # A folder that is a git repository gets a branch per task in its own # worktree. A folder that is not cannot: asking for a worktree would create # a project whose every run fails. Those keep the folder in place instead. - is_repo = rp.is_repo(folder) and rp.has_commit(folder) + is_repo = worktrees and rp.is_repo(folder) and rp.has_commit(folder) body = { "name": name, "description": "Adopted from %s by bin/office." % folder, @@ -90,6 +91,9 @@ def create_project(name, folder, dry): "sourceType": "git_repo" if is_repo else "local_path", "cwd": folder, "isPrimary": True, + # The project workspace's own base ref, for the case where a task is + # run without the policy. Read from the folder, never assumed. + "defaultRef": rp.branch(folder) if is_repo else None, }, } if is_repo: @@ -313,7 +317,8 @@ COMMANDS office --session name the session instead of the folder office --project "" --host the folder is on that machine office --new "" --dry say what would happen, write nothing - office --new "" --git-init make the folder a repository first + office --new "" (with no repository it asks Y or N) + office --new "" --commit-all make the repository and commit everything office --new "" --anywhere allow it although agents cannot see it board create any missing pane in this project's session @@ -352,11 +357,20 @@ WORKTREES, AND WHY GIT MATTERS A new project gets a worktree policy: Paperclip makes a branch and a separate directory per task, so two tasks never change the same files, and there is a diff to review and a merge to approve. - That needs a repository with at least one commit. When the folder is not one, - office says so and asks before touching anything: making a repository means - committing every file in the folder, which is a decision about your own files. - Without it the project is created in place (local_path): a task edits the - folder directly, there is no diff and nothing to merge. + That needs a repository WITH AT LEAST ONE COMMIT, because a worktree is a + checkout of a commit: anything not committed is invisible to the agent. + + When the folder cannot do that yet, office asks, and never writes history: + + Y make the repository, write .gitignore containing .paperclip/, stage + everything, and stop. Commit in lazygit, then run this again. + N create the project in place (local_path): a task edits the folder + directly, there is no diff and nothing to merge. + + Y leaves you in lazygit's hands on purpose: what you commit is what the agent + will see, and .gitignore is how you keep a file out of its view. + --commit-all does the whole thing in one go, for anyone who does not care. + The base branch is read from the folder, not assumed to be main. WHERE THINGS RUN @@ -381,6 +395,7 @@ AFTER A REBOOT KEYS INSIDE THE PANES ( ) change tab tab change panel enter open / choose r re-read Paperclip R rebuild all panes ? list the keys + t open lazygit in the project folder (stage, ignore, commit) h j k l z n and digits belong to TUIOS and never reach the program. MORE @@ -431,51 +446,98 @@ def guard_machine(folder, opts): def prepare_git(folder, opts): - """Make the folder able to hold a worktree per task, and say what it did. + """Get the folder as far as it can go toward a worktree per task. - A worktree needs a repository and at least one commit. Neither is created - silently: a first commit is a decision about a person's own files, and for a - folder of notes it may include things that should not be in a history at - all. Returns True when the folder is ready for worktrees. + A worktree needs a repository and at least one commit. Neither is made + silently: the first commit decides what every agent will be able to see, and + taking it is the person's decision. + + Returns one of: + "ready" a repository with a commit: the project is created with worktrees + "staged" the repository and .gitignore are made and everything is staged, + but not committed, so the project is not created yet + "no" leave it alone: the project is created in place, without worktrees """ if rp.is_repo(folder) and rp.has_commit(folder): - return True - if rp.is_repo(folder) and not rp.has_commit(folder): - what = "the repository at %s has no commit yet" % folder - else: - what = "%s is not a git repository" % folder + note_ignore(folder, opts) + return "ready" + if opts["dry"]: - return False - h.say("%s, so each task cannot have its own branch and worktree." % what) - h.say(" without them, two tasks can change the same files at once, and there") + return "ready" if rp.is_repo(folder) and rp.has_commit(folder) else "no" + + what = ("%s is not a git repository" % folder if not rp.is_repo(folder) + else "the repository at %s has no commit yet" % folder) + h.say("%s, so a task cannot have its own branch and worktree." % what) + h.say(" without one, two tasks can change the same files at once, and there") h.say(" is no diff to review and nothing to merge.") - if opts["git_init"]: - go = True - elif sys.stdin.isatty(): - h.say("") - h.say("make it a repository now? This runs: git init -b main, git add -A,") - h.say("and one commit of EVERY file in the folder, including anything you") - h.say("have not noticed. Add a .gitignore first if that is not what you want.") - answer = input(" type yes to do it: ").strip().lower() - go = answer in ("y", "yes") - else: - go = False - h.say("") - h.say("not a terminal, so nothing was changed. Either run this again and") - h.say("answer the question, or do it yourself:") - h.say(" cd %s" % folder) - if rp.is_repo(folder): + h.say("") + + if not (opts["git_init"] or opts["commit_all"]): + if not sys.stdin.isatty(): + h.say("not a terminal, so nothing was changed. Either run this again and") + h.say("answer the question, or do it yourself and pass --project later:") + h.say(" cd %s" % folder) + if not rp.is_repo(folder): + h.say(" git init -b main") + h.say(" printf '.paperclip/\\n' >> .gitignore") h.say(" git add -A && git commit -m 'Initial commit'") - else: - h.say(" git init -b main && git add -A && git commit -m 'Initial commit'") - h.say("or pass --git-init to let this command do it.") - if not go: - return False - ok, why = rp.make_repo(folder) - if not ok: - h.say(" could not: %s" % why) - return False - return True + h.say("or pass --commit-all to let this command do all of it.") + raise h.Refused(2, "no repository and no terminal to ask in") + h.say(" Y make the repository, write .gitignore with .paperclip/,") + h.say(" stage everything, and stop. You commit it in lazygit, then") + h.say(" run this command again to create the project with worktrees.") + h.say(" N create the project in place instead: no branches, no diff,") + h.say(" nothing to merge.") + h.say("") + answer = input(" Y or N: ").strip().lower() + if answer not in ("y", "yes"): + h.say(" left alone. Creating the project in place, without worktrees.") + return "no" + + # Y, --git-init, or --commit-all from here on. + if not rp.is_repo(folder): + r = rp.git(folder, "init", "-b", "main") + if r.returncode != 0: + raise h.Refused(2, "git init failed: %s" % (r.stderr or r.stdout).strip()[:200]) + h.say(" made a repository on branch %s" % rp.branch(folder)) + changed, note = rp.ensure_ignore(folder) + h.say(" %s" % note) + if not rp.stage_all(folder): + raise h.Refused(2, "git add failed in %s" % folder) + + if opts["commit_all"] or (opts["git_init"] and opts["git_init_commits"]): + ok, why = rp.commit_all(folder) + if not ok: + h.say(" could not commit: %s" % why) + return "staged" + h.say(" committed everything on branch %s" % rp.branch(folder)) + return "ready" + + h.say("") + h.say(" Everything is staged and nothing is committed. Check what git is about") + h.say(" to take, and commit it:") + h.say("") + h.say(" cd %s && lazygit" % folder) + h.say(" space stage or unstage a file i ignore a file or folder") + h.say(" c commit ? every key") + h.say("") + h.say(" Then run this command again. It will create the project with worktrees.") + return "staged" + + +def note_ignore(folder, opts): + """Say when worktrees are about to be committed. Never change anything.""" + state = rp.is_repo(folder) + if not state: + return + path = os.path.join(folder, ".gitignore") + text = open(path).read() if os.path.exists(path) else "" + for line in text.splitlines(): + trimmed = line.strip().strip("/") + if trimmed == ".paperclip" or trimmed.startswith(".paperclip/"): + return + h.say(" warning: .paperclip/ is not in .gitignore, so a commit here would") + h.say(" take Paperclip's task worktrees with it. Add it before you commit.") def main(): @@ -532,10 +594,11 @@ def main(): h.say("would create a Paperclip project %r for %s" % (opts["new"], folder)) else: h.say("would adopt the Paperclip project %r for %s" % (opts["project"] or "", folder)) - if ready: + if ready == "ready": h.say("worktrees: yes — a repository on branch %r, so each task gets its own" % rp.branch(folder)) else: - h.say("worktrees: no — %s" % ("this is not a repository" if not rp.is_repo(folder) else "the repository has no commit")) + h.say("worktrees: no — %s" % ("this is not a repository" if not rp.is_repo(folder) + else "the repository has no commit")) h.say("would write %s" % os.path.join(folder, ".harness", "harness.toml")) h.say("would create or reuse session %r and build eight panes there" % session) h.say("dry run: nothing was written") @@ -545,8 +608,15 @@ def main(): # Only a note, because the panes are built by the machine that owns them. h.say("host: %s (build the pages there)" % host) + if ready == "staged": + # Nothing is committed, so the project cannot have worktrees yet, and + # creating it without them is a decision that cannot be undone. + h.say("") + h.say("the project was not created: commit the staged files, then run this again.") + return 0 + if opts["new"]: - ref = create_project(opts["new"], folder, opts["dry"]) + ref = create_project(opts["new"], folder, opts["dry"], ready == "ready") if not ref: return 1 opts["project"] = ref diff --git a/docs/STARTUP.md b/docs/STARTUP.md index cb5a01b..b77d8e7 100644 --- a/docs/STARTUP.md +++ b/docs/STARTUP.md @@ -72,36 +72,100 @@ Inside a pane, `R` in `Repair>repair` rebuilds everything; `B` only adds what is A new project gets a worktree policy: Paperclip makes a branch and a separate directory per task, so two tasks never change the same files, and there is a diff to review and a merge to approve. -That needs a repository **with at least one commit**. `office` checks, and when the folder is not -one it explains what is lost and asks before touching anything: +That needs a repository **with at least one commit**, because a worktree is a checkout of a commit: +anything not committed is invisible to the agent. When the folder cannot do that yet, `office` +asks, and never writes history: ``` -/tmp/officetest is not a git repository, so each task cannot have its own branch and worktree. - without them, two tasks can change the same files at once, and there +/tmp/gate1 is not a git repository, so a task cannot have its own branch and worktree. + without one, two tasks can change the same files at once, and there is no diff to review and nothing to merge. -make it a repository now? This runs: git init -b main, git add -A, -and one commit of EVERY file in the folder, including anything you -have not noticed. Add a .gitignore first if that is not what you want. - type yes to do it: + Y make the repository, write .gitignore with .paperclip/, + stage everything, and stop. You commit it in lazygit, then + run this command again to create the project with worktrees. + N create the project in place instead: no branches, no diff, + nothing to merge. + + Y or N: ``` -Three answers, in order of preference: +Answer **Y** and it stops before committing, on purpose: -1. **Answer the question.** It only appears at a terminal, so nothing is created by a script by - accident. -2. **`--git-init`** does the same without asking, for a script. -3. **Do it yourself**, which is what a machine without a terminal is told to do, with the exact - commands. +``` + made a repository on branch main + wrote .gitignore, ignoring .paperclip/ -If you say no, or there is no terminal and no flag, the project is still created — as -`local_path`, in place. A task then edits the folder directly: no branch, no diff, nothing to -merge. That is honest for a folder of notes and wrong for code. + Everything is staged and nothing is committed. Check what git is about + to take, and commit it: -The base branch is read from the folder (`git symbolic-ref`), not assumed to be `main`, so a -repository on `master` works. + cd /tmp/gate1 && lazygit + space stage or unstage a file i ignore a file or folder + c commit ? every key -## A folder on a machine that does not serve Paperclip + Then run this command again. It will create the project with worktrees. + +the project was not created: commit the staged files, then run this again. +``` + +Two decisions are kept out of the tool's hands, because both belong to the person: + +- **what gets committed**, because what you commit is what the agent can see. `lazygit`'s files + panel stages and unstages with `space`, and **`i` adds a file or folder to `.gitignore`** — for a + pattern, `e` opens the file. The first commit is unpushed, so `A` amends it if you change your + mind. +- **whether there is a commit at all**. No commit means no worktrees, and `N` creates the project + in place (`local_path`) instead: a task edits the folder directly, with no diff and nothing to + merge. A project's policy is fixed when it is created, so that choice cannot be revisited + without making the project again. + +`--commit-all` does the whole thing in one step for anyone who does not want to look. With no +terminal and no flag, `office` changes nothing and prints the three commands instead. + +**`.paperclip/` is written into `.gitignore` on every path and is not a preference.** Paperclip +puts its worktrees inside the project folder; a commit that took them would put checkouts of the +repository inside the repository. + +The base branch is read from the folder (`git symbolic-ref`), not assumed to be `main`, and it is +set in both places Paperclip keeps it: the policy's `baseRef` and the workspace's `defaultRef`. + +## Seeing the state: the `git` tab, and the banner + +`Project>project` → `(` `)` → **git** reads the folder and Paperclip together: + +``` + THIS FOLDER + repository yes · branch main + commits 3 + remote none — not needed for worktrees + changed 0 staged · 0 unstaged · 2 untracked + .gitignore .paperclip/ + + worktrees isolated_workspace · git_worktree · base main + + WORKTREES, AS PAPERCLIP SEES THEM + AUD-172-add-a-status-md-with-one-line clean · 1 behind · merged into base + This workspace is 1 commit behind main. +``` + +Two halves, deliberately: the top is git run in this folder, which Paperclip never checks; the +bottom is Paperclip's own scan of each task's worktree +(`GET /api/execution-workspaces/{id}/close-readiness`), which already knows the branch, what is +dirty, how far behind the base it is, and whether it is merged. Nothing is recomputed. + +`t` opens `lazygit` in the project folder, wherever the git tab says something needs doing. + +While the folder cannot hold a worktree, **every pane** carries one line at the top: + +``` +commit will swallow the worktrees — .paperclip/ is not in .gitignore · Project>project → git +``` + +It takes the page title's line and leaves the panels and the key hints alone. It is read once when +the pane starts, so fixing the folder and pressing `R` (which rebuilds every pane) is what clears +it. + +## A folder on a machine that does not serve Paperclip## A folder on a machine that does not serve Paperclip Panes run where the folder is. **Agents run where Paperclip is**, because Paperclip starts them itself. When those are different machines the panes read the folder correctly and the agents diff --git a/tui/frame_test.go b/tui/frame_test.go index d78cee3..21a7b05 100644 --- a/tui/frame_test.go +++ b/tui/frame_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -56,3 +57,58 @@ func TestTruncateVisibleKeepsStylingAndWidth(t *testing.T) { t.Fatalf("truncation should say it truncated: %q", got) } } + +func TestBannerIsDrawnOnceAndKeepsTheHeight(t *testing.T) { + defer func(prev string) { bannerText = prev }(bannerText) + bannerText = "no worktrees — this folder is not a git repository" + const w, h = 80, 24 + + // A screen with three panels side by side, the way the board is drawn. + one := panel{title: "a", width: 26, height: h - 2}.render([]string{"x"}) + two := panel{title: "b", width: 26, height: h - 2}.render([]string{"y"}) + three := panel{title: "c", width: 26, height: h - 2}.render([]string{"z"}) + inner := strings.Join([]string{ + strings.Repeat(" ", w), + sideBySide(one, two, three), + "footer", + }, "\n") + + b := bannerModel{inner: stubModel{view: inner}, width: w, height: h} + got := lines(b.View()) + if len(got) != h { + t.Fatalf("got %d lines, want %d", len(got), h) + } + if n := strings.Count(strings.Join(got, "\n"), "no worktrees"); n != 1 { + t.Fatalf("the warning appears %d times, want once", n) + } + if !strings.Contains(strings.Join(got, "\n"), "footer") { + t.Fatal("the key hints were dropped; only the title line should give way") + } + if lipgloss.Width(got[0]) != w { + t.Fatalf("the warning line is %d wide, want %d", lipgloss.Width(got[0]), w) + } +} + +type stubModel struct{ view string } + +func (s stubModel) Init() tea.Cmd { return nil } +func (s stubModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return s, nil } +func (s stubModel) View() string { return s.view } + +func sideBySide(parts ...string) string { + rows := make([][]string, len(parts)) + for i, p := range parts { + rows[i] = lines(p) + } + var out []string + for r := range rows[0] { + var b strings.Builder + for _, cols := range rows { + if r < len(cols) { + b.WriteString(cols[r]) + } + } + out = append(out, b.String()) + } + return strings.Join(out, "\n") +} diff --git a/tui/gitinfo.go b/tui/gitinfo.go new file mode 100644 index 0000000..6ee0f67 --- /dev/null +++ b/tui/gitinfo.go @@ -0,0 +1,263 @@ +package main + +// What git says about the project folder, and what Paperclip says about the +// worktree each task got. +// +// Two layers, deliberately: +// +// readGitState runs git in the folder. Paperclip does not check that the +// folder is a repository at all, so this is the only early +// warning a person can get. +// readWorktrees asks Paperclip to scan each execution workspace. That scan +// already knows the branch, what is dirty, how far behind the +// base it is and whether it is merged — so nothing here is +// recomputed. +// +// Every git command in this file runs inside a tea.Cmd, never in Update or View. + +import ( + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "paperclip-harness/tui/paperclip" +) + +// gitState is the folder as git sees it today. +type gitState struct { + IsRepo bool + HasCommit bool + Branch string + Commits int + Remotes []string + Staged int + Unstaged int + Untracked int + HasIgnore bool + Ignore []string + IgnoreErr string + BehindBase int + AheadBase int +} + +func gitOut(folder string, args ...string) (string, bool) { + cmd := exec.Command("git", append([]string{"-C", folder}, args...)...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C") + out, err := cmd.Output() + if err != nil { + return "", false + } + return strings.TrimRight(string(out), "\n"), true +} + +// readGitState reads the folder. Safe to call from a goroutine. +func readGitState(folder string) gitState { + var g gitState + if _, ok := gitOut(folder, "rev-parse", "--is-inside-work-tree"); !ok { + return g + } + g.IsRepo = true + if out, ok := gitOut(folder, "rev-parse", "--verify", "HEAD"); ok && out != "" { + g.HasCommit = true + } + if out, ok := gitOut(folder, "symbolic-ref", "--short", "HEAD"); ok { + g.Branch = out + } + if out, ok := gitOut(folder, "rev-list", "--count", "HEAD"); ok { + g.Commits, _ = strconv.Atoi(strings.TrimSpace(out)) + } + if out, ok := gitOut(folder, "remote"); ok && out != "" { + g.Remotes = strings.Split(out, "\n") + } + if out, ok := gitOut(folder, "status", "--porcelain=v1"); ok { + for _, line := range strings.Split(out, "\n") { + if len(line) < 3 { + continue + } + switch { + case strings.HasPrefix(line, "??"): + g.Untracked++ + case line[0] != ' ': + g.Staged++ + default: + g.Unstaged++ + } + } + } + ignorePath := filepath.Join(folder, ".gitignore") + if b, err := os.ReadFile(ignorePath); err == nil { + g.HasIgnore = true + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + g.Ignore = append(g.Ignore, line) + } + } + return g +} + +// worktreeRow is one task's directory, as Paperclip describes it. +type worktreeRow struct { + Issue string + Branch string + Path string + Dirty int + Ahead int + Behind int + Merged bool + State string + Warning string +} + +// readWorktrees asks Paperclip for this project's task workspaces and their git +// scan. Newest first, and only the ones still in use, because each row costs a +// request and an old number is not worth one. +func readWorktrees(c *paperclip.Client, projectID string, limit int) ([]worktreeRow, error) { + all, err := c.ExecutionWorkspaces() + if err != nil { + return nil, err + } + var mine []paperclip.ExecutionWorkspace + for _, w := range all { + if w.ProjectID != projectID { + continue + } + if strings.EqualFold(w.Status, "closed") || strings.EqualFold(w.Status, "archived") { + continue + } + mine = append(mine, w) + } + sort.SliceStable(mine, func(i, j int) bool { return mine[i].LastUsedAt > mine[j].LastUsedAt }) + if limit > 0 && len(mine) > limit { + mine = mine[:limit] + } + out := make([]worktreeRow, 0, len(mine)) + for _, w := range mine { + row := worktreeRow{Branch: w.BranchName, Path: w.ProviderRef} + if row.Path == "" { + row.Path = w.Cwd + } + if rr, err := c.CloseReadinessFor(w.ID); err == nil { + row.Branch = rr.Git.BranchName + if rr.Git.WorkspacePath != "" { + row.Path = rr.Git.WorkspacePath + } + row.Dirty = rr.Git.DirtyEntryCount + rr.Git.UntrackedEntryCount + row.Ahead = rr.Git.AheadCount + row.Behind = rr.Git.BehindCount + row.Merged = rr.Git.IsMergedIntoBase + row.State = rr.State + if len(rr.Warnings) > 0 { + row.Warning = rr.Warnings[0] + } + } + out = append(out, row) + } + return out, nil +} + +// ready reports whether this folder can give each task its own worktree. +func (g gitState) ready() bool { return g.IsRepo && g.HasCommit } + +// ignoresPaperclipWorktrees finds the one entry that must never be missing: the +// folder Paperclip puts worktrees in. Without it a first commit swallows them. +func (g gitState) ignoresPaperclipWorktrees() bool { + for _, p := range g.Ignore { + trimmed := strings.Trim(p, "/") + if trimmed == ".paperclip" || strings.HasPrefix(trimmed, ".paperclip/") { + return true + } + } + return false +} + +// banner is the one line shown in every pane while this folder cannot hold a +// task worktree. Empty when it can. +func bannerFor(g gitState) string { + if g.ready() && g.ignoresPaperclipWorktrees() { + return "" + } + switch { + case !g.IsRepo: + return "no worktrees — this folder is not a git repository · Project>project → git" + case !g.HasCommit: + return "no worktrees — this repository has no commit · Project>project → git" + default: + return "commit will swallow the worktrees — .paperclip/ is not in .gitignore · Project>project → git" + } +} + +// bannerText is set once at startup, before the terminal is taken over, so no +// git command ever runs inside Update or View. Fixing the folder and pressing R +// (which restarts every pane) is what clears it. +var bannerText string + +func setGitBanner(folder string) { bannerText = bannerFor(readGitState(folder)) } + +// today is here so the file has one small helper used by the tab, and so the +// tab and the banner cannot drift apart in how they describe the same thing. +func today() string { return time.Now().Format("15:04") } + +// bannerLine is one full-width line, cut rather than wrapped. +func bannerLine(text string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(text) > width { + text = truncateVisible(text, width) + } + return lipgloss.NewStyle(). + Width(width). + Foreground(lipgloss.Color(active.Warning)). + Background(lipgloss.Color(active.Surface)). + Render(text) +} + +// withBanner wraps a screen so that the setup warning appears once, at the top, +// whatever the screen is made of. The board draws three panels side by side, so +// drawing the banner inside a panel put it there three times. +// +// It takes the line the screen would have drawn first — the page title and the +// read time — and gives that line to the warning. The panels and the key hints +// are left alone, because those are what a person needs while fixing the +// problem. +type bannerModel struct { + inner tea.Model + width, height int +} + +func (b bannerModel) Init() tea.Cmd { return b.inner.Init() } + +func (b bannerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if w, ok := msg.(tea.WindowSizeMsg); ok { + b.width, b.height = w.Width, w.Height + } + next, cmd := b.inner.Update(msg) + b.inner = next + return b, cmd +} + +func (b bannerModel) View() string { + view := b.inner.View() + if bannerText == "" || b.width <= 0 { + return view + } + lines := strings.Split(view, "\n") + if len(lines) > 1 { + lines = lines[1:] // the warning takes the title line + } + out := append([]string{bannerLine(bannerText, b.width)}, lines...) + if b.height > 0 && len(out) > b.height { + out = out[:b.height] + } + return strings.Join(out, "\n") +} diff --git a/tui/main.go b/tui/main.go index c35a64d..69b0349 100644 --- a/tui/main.go +++ b/tui/main.go @@ -40,6 +40,14 @@ func main() { fmt.Fprintln(os.Stderr, "warning: PAPERCLIP_BOARD_KEY is not set, so replies and status changes will be refused") } + // One git read, here, before the terminal is taken over: the banner in every + // pane depends on it and no git command may run inside Update or View. + screenFolder := *folder + if screenFolder == "" { + screenFolder, _ = os.Getwd() + } + setGitBanner(screenFolder) + var m tea.Model switch *screen { case "board": @@ -138,7 +146,8 @@ func main() { // Alternate screen: TUIOS keeps its own scrollback, and a full-screen // program that redraws in place must not fight it. - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + // The setup warning is drawn once for the whole screen, not once per panel. + p := tea.NewProgram(bannerModel{inner: m}, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index ecccb0f..00e77b2 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -221,6 +221,23 @@ type Project struct { Name string `json:"name"` URLKey string `json:"urlKey"` Status string `json:"status"` + + // The worktree policy is fixed when the project is created, so the screen + // reports it rather than offering to change it. + ExecutionWorkspacePolicy *struct { + Enabled bool `json:"enabled"` + DefaultMode string `json:"defaultMode"` + Strategy struct { + Type string `json:"type"` + Base string `json:"baseRef"` + } `json:"workspaceStrategy"` + } `json:"executionWorkspacePolicy"` + + Workspaces []struct { + SourceType string `json:"sourceType"` + Cwd string `json:"cwd"` + DefaultRef string `json:"defaultRef"` + } `json:"workspaces"` } // Comment is one entry on a task's thread. @@ -615,3 +632,76 @@ func (c *Client) ImportSkill(source string) (string, error) { } return "", fmt.Errorf("import returned no skill") } + +// ExecutionWorkspace is one task's own directory: a git worktree, or a shared +// folder when the project does not use worktrees. Paperclip makes these, not us. +type ExecutionWorkspace struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"projectId"` + SourceIssueID string `json:"sourceIssueId"` + BranchName string `json:"branchName"` + BaseRef string `json:"baseRef"` + ProviderType string `json:"providerType"` + ProviderRef string `json:"providerRef"` + Mode string `json:"mode"` + Status string `json:"status"` + DeliveryState string `json:"deliveryState"` + Cwd string `json:"cwd"` + LastUsedAt string `json:"lastUsedAt"` +} + +// CloseReadiness is Paperclip's own git scan of one execution workspace, plus +// what it would do if the workspace were closed. The git half is the only place +// in Paperclip that reports branch, dirtiness and how far behind a worktree is. +type CloseReadiness struct { + State string `json:"state"` + DeliveryState string `json:"deliveryState"` + BlockingReasons []string `json:"blockingReasons"` + Warnings []string `json:"warnings"` + Git struct { + RepoRoot string `json:"repoRoot"` + WorkspacePath string `json:"workspacePath"` + BranchName string `json:"branchName"` + BaseRef string `json:"baseRef"` + HasDirtyTrackedFiles bool `json:"hasDirtyTrackedFiles"` + HasUntrackedFiles bool `json:"hasUntrackedFiles"` + DirtyEntryCount int `json:"dirtyEntryCount"` + UntrackedEntryCount int `json:"untrackedEntryCount"` + AheadCount int `json:"aheadCount"` + BehindCount int `json:"behindCount"` + IsMergedIntoBase bool `json:"isMergedIntoBase"` + CreatedByRuntime bool `json:"createdByRuntime"` + } `json:"git"` +} + +// ExecutionWorkspaces lists every task workspace in the company. They are +// filtered per project here rather than by the API, which has no such filter. +func (c *Client) ExecutionWorkspaces() ([]ExecutionWorkspace, error) { + var raw json.RawMessage + if err := c.do("GET", "/api/companies/"+c.Company+"/execution-workspaces", nil, &raw); err != nil { + return nil, err + } + var list []ExecutionWorkspace + if err := json.Unmarshal(raw, &list); err == nil { + return list, nil + } + var wrapper struct { + Workspaces []ExecutionWorkspace `json:"workspaces"` + Data []ExecutionWorkspace `json:"data"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil { + return nil, err + } + if len(wrapper.Workspaces) > 0 { + return wrapper.Workspaces, nil + } + return wrapper.Data, nil +} + +// CloseReadinessFor asks Paperclip to scan one execution workspace's git state. +func (c *Client) CloseReadinessFor(id string) (CloseReadiness, error) { + var out CloseReadiness + err := c.do("GET", "/api/execution-workspaces/"+id+"/close-readiness", nil, &out) + return out, err +} diff --git a/tui/project.go b/tui/project.go index 2a3c315..cc9e618 100644 --- a/tui/project.go +++ b/tui/project.go @@ -21,6 +21,8 @@ import ( ) type projectMsg struct { + git gitState + wts []worktreeRow dash paperclip.Dashboard project paperclip.Project cfg map[string]string @@ -50,6 +52,9 @@ type projectModel struct { err error readAt time.Time + git gitState + wts []worktreeRow + tab int scroll int regAt int @@ -60,7 +65,18 @@ type projectModel struct { width, height int } -var projectTabs = []string{"overview", "facts", "costs", "settings", "projects"} +// Tab order is also the reading order, and the numbers are named because they +// are used in the key handling: a bare 4 broke once already. +const ( + tabOverview = iota + tabGit + tabFacts + tabCosts + tabSettings + tabProjects +) + +var projectTabs = []string{"overview", "git", "facts", "costs", "settings", "projects"} func newProjectModel(c *paperclip.Client, folder string) projectModel { return projectModel{client: c, folder: folder} @@ -104,7 +120,10 @@ func (m projectModel) load() tea.Cmd { costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount}) } dash, _ := c.DashboardSummary() - return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash} + git := readGitState(folder) + wts, _ := readWorktrees(c, proj.ID, 8) + return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, + dash: dash, git: git, wts: wts} } } @@ -184,6 +203,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "r": return m, m.load() + case "t": + // lazygit, in the project folder: it is the tool that can stage, + // exclude and commit. Nothing here reimplements it. + if err := openTool(m.folder, "lazygit"); err != nil { + m.err = err + } + return m, nil case "R": if err := startRebuild(m.folder); err != nil { m.err = err @@ -191,7 +217,7 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "enter": - if m.tab == 4 { + if m.tab == tabProjects { m.regs = readRegistry() if len(m.regs) > 0 && m.regAt < len(m.regs) { m.confirmSwitch = true @@ -211,13 +237,13 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "up", "k": - if m.tab == 4 && m.regAt > 0 { + if m.tab == tabProjects && m.regAt > 0 { m.regAt-- } m.scroll-- return m, nil case "down", "j": - if m.tab == 4 && m.regAt < len(m.regs)-1 { + if m.tab == tabProjects && m.regAt < len(m.regs)-1 { m.regAt++ } m.scroll++ @@ -234,6 +260,8 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cfg = msg.cfg m.agents = msg.agents m.costs = msg.costs + m.git = msg.git + m.wts = msg.wts m.err = msg.err m.readAt = time.Now() return m, nil @@ -263,7 +291,9 @@ func (m projectModel) View() string { } if m.showHelp { return helpOverlay("the project", [][2]string{ - {"[ ] h l", "facts, costs, settings"}, + {"( )", "overview, git, facts, costs, settings, projects"}, + {"t", "open lazygit in this folder"}, + {"[ ] h l", "previous and next tab"}, {"↑ ↓ j k", "scroll"}, {"r", "read again"}, {"R", "rebuild every pane"}, @@ -283,13 +313,15 @@ func (m projectModel) View() string { var body []string switch m.tab { - case 0: + case tabOverview: body = m.overviewLines(m.width - 4) - case 1: + case tabGit: + body = m.gitLines(m.width - 4) + case tabFacts: body = m.factLines() - case 2: + case tabCosts: body = m.costLines() - case 4: + case tabProjects: body = m.projectListLines(m.width - 4) default: body = m.settingLines() @@ -312,6 +344,7 @@ func (m projectModel) View() string { footer := hintLine([]string{ keyHint("( )", "tab"), + keyHint("t", "lazygit"), keyHint("↑↓", "scroll"), keyHint("r", "refresh"), keyHint("R", "rebuild"), @@ -574,3 +607,130 @@ func (m projectModel) projectListLines(width int) []string { out = append(out, dimStyle().Render(" ~/.config/tuios hosts table; otherwise attach to it by hand.")) return out } + +// gitLines is the git tab: the folder read locally, then what Paperclip knows +// about each task's worktree. The two halves answer different questions and are +// not merged into one verdict. +func (m projectModel) gitLines(width int) []string { + var out []string + g := m.git + + report := func(label, value string, bad bool) { + line := " " + mutedStyle2().Render(fmt.Sprintf("%-14s", label)) + style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)) + if bad { + style = style.Foreground(lipgloss.Color(active.Warning)) + } + out = append(out, line+style.Render(value)) + } + + // --- the folder --- + out = append(out, mutedStyle2().Render(" THIS FOLDER")) + if !g.IsRepo { + report("repository", "no — this folder is not a git repository", true) + } else { + branch := g.Branch + if branch == "" { + branch = "(no branch yet)" + } + report("repository", "yes · branch "+branch, false) + report("commits", fmt.Sprintf("%d", g.Commits), !g.HasCommit) + if g.Commits == 0 { + out = append(out, " no commit, so nothing to cut a worktree from") + } + remotes := "none — not needed for worktrees" + if len(g.Remotes) > 0 { + remotes = strings.Join(g.Remotes, ", ") + } + report("remote", remotes, false) + report("changed", fmt.Sprintf("%d staged · %d unstaged · %d untracked", + g.Staged, g.Unstaged, g.Untracked), false) + if g.HasIgnore { + shown := strings.Join(g.Ignore, " ") + if len(shown) > width-24 { + shown = shown[:width-25] + "…" + } + if shown == "" { + shown = "(no entries)" + } + report(".gitignore", shown, !g.ignoresPaperclipWorktrees()) + } else { + report(".gitignore", "missing — .paperclip/ must be in it", true) + } + } + out = append(out, "") + + // --- what Paperclip will do with each task --- + policy := "not set" + if p := m.project.ExecutionWorkspacePolicy; p != nil && p.Enabled { + policy = p.DefaultMode + if p.Strategy.Type != "" { + policy += " · " + p.Strategy.Type + } + if p.Strategy.Base != "" { + policy += " · base " + p.Strategy.Base + } + } + report("worktrees", policy, m.project.ExecutionWorkspacePolicy == nil || + (m.project.ExecutionWorkspacePolicy != nil && m.project.ExecutionWorkspacePolicy.Strategy.Type != "git_worktree")) + out = append(out, "") + + // --- what to do, when something is wrong --- + if !g.ready() || !g.ignoresPaperclipWorktrees() { + out = append(out, mutedStyle2().Render(" WHAT TO DO")) + folder := m.folder + if !g.ignoresPaperclipWorktrees() { + out = append(out, " .paperclip/ is not ignored: a commit would swallow every worktree.") + out = append(out, " "+dimStyle().Render("t lazygit · select .gitignore · e to edit · add .paperclip/")) + out = append(out, "") + } + if !g.IsRepo { + out = append(out, " This folder is not a repository, so no task can have its own branch.") + out = append(out, " "+dimStyle().Render("cd "+folder+" && git init -b main")) + out = append(out, " "+dimStyle().Render("then commit something — in lazygit: t, then c")) + } else if !g.HasCommit { + out = append(out, " This repository has no commit, so there is nothing to cut a worktree from.") + out = append(out, " "+dimStyle().Render("t lazygit · space to stage · c to commit")) + } + out = append(out, "") + out = append(out, " Then run "+keyHint("R", "rebuild")+" so every pane is built against the new state.") + out = append(out, "") + } + + // --- Paperclip's own scan of the task worktrees --- + out = append(out, mutedStyle2().Render(" WORKTREES, AS PAPERCLIP SEES THEM")) + if len(m.wts) == 0 { + out = append(out, dimStyle().Render(" none yet — one is made when a task runs")) + } + for _, w := range m.wts { + state := "clean" + if w.Dirty > 0 { + state = fmt.Sprintf("%d changed", w.Dirty) + } + extra := "" + if w.Ahead > 0 { + extra += fmt.Sprintf(" · %d ahead", w.Ahead) + } + if w.Behind > 0 { + extra += fmt.Sprintf(" · %d behind", w.Behind) + } + if w.Merged { + extra += " · merged into base" + } + branch := w.Branch + if branch == "" { + branch = "(no branch)" + } + if len(branch) > width-34 { + branch = branch[:width-35] + "…" + } + out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(branch)+ + dimStyle().Render(" "+state+extra)) + if w.Warning != "" { + out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(w.Warning)) + } + } + out = append(out, "") + out = append(out, dimStyle().Render(" read from git in this folder, and from Paperclip's scan of each worktree")) + return out +}