|
| 1 | +--- |
| 2 | +tags: [plan, tier-2, oss-release, bugfix, harness] |
| 3 | +description: Pre-launch bug sweep — Tier 1 fresh-install blockers + Tier 2 harness polish, bundled for OSS release readiness. |
| 4 | +--- |
| 5 | + |
| 6 | +# Plan 19 — Pre-launch Bug Sweep |
| 7 | + |
| 8 | +**Tier:** 2 |
| 9 | +**Status:** Draft |
| 10 | +**Agent:** general-purpose (worktree-isolated) |
| 11 | + |
| 12 | +## Goal |
| 13 | + |
| 14 | +Ship eight targeted fixes as one PR so the next `go install github.com/.../bonsai` → `bonsai init` flow is clean for first-time OSS users: four fresh-install blockers + four harness polish items. After merge, manual `init → add → remove → update` on a fresh temp dir must produce no visible artifacts of the known bugs (CRLF, duplicate tech-lead message, tree root mislabel, silent generator failures). |
| 15 | + |
| 16 | +## Context |
| 17 | + |
| 18 | +**Why this batch, why now.** OSS launch is imminent. The Backlog's **Group F** (fresh-install blockers) + half of **Group B** (harness polish) is small, mechanical, and well-scoped — exactly the kind of cleanup that's risky to do *after* launch (first-user-experience regressions are the most expensive bugs to ship with). Items chosen: |
| 19 | + |
| 20 | +| # | Backlog Group | Severity | File footprint | |
| 21 | +|---|---|---|---| |
| 22 | +| 1 | F | P1 | `.gitattributes` (new), `internal/generate/generate.go`, `internal/generate/generate_test.go` | |
| 23 | +| 2 | F | — | `cmd/root.go` (`showWriteResults`) | |
| 24 | +| 3 | F | — | `cmd/update.go` (`applyCustomFileSelection`) | |
| 25 | +| 4 | B | P1 | 5 files in `cmd/` — ~29 sites | |
| 26 | +| 5 | B | — | `internal/tui/harness/steps.go` (`SpinnerStep.Init`) | |
| 27 | +| 6 | B | — | `internal/tui/harness/steps.go` (`NewConditional`) | |
| 28 | +| 7 | B | — | `internal/tui/harness/steps.go` + `harness.go` (Esc-back re-eval) | |
| 29 | +| 8 | B | — | `cmd/add.go` (drop NoteStep, keep ErrorDetail) | |
| 30 | + |
| 31 | +**Deferred** (not in this plan — tracked separately in Backlog): pre-release docs audit, demo GIF, `generate.go` split, catalog/cmd test coverage, trigger test infra, PTY smoke test, GO-2026-4602 stdlib monitor, Group D catalog expansion, Group E workspace QoL. |
| 32 | + |
| 33 | +**Key prior art** |
| 34 | +- Plan 15 (BubbleTea harness, merged `2ce63f6`) introduced `recoverBuilder` covering Build/Splice panics but left SpinnerStep action goroutine without `recover`. |
| 35 | +- Plan 15 also introduced `SpinnerStep`'s `func() error` return path and `spinnerDoneMsg` propagation. Callers still `_ =` the inner generator errors, making the mechanism useless until call sites migrate. |
| 36 | +- `internal/tui/harness/harness.go` Esc-back loop (L284-295) iterates steps + calls `Reset()` but does **not** call `SetPrior()` first, so `ConditionalStep.Reset()` can't re-evaluate its predicate against the user's new upstream picks. |
| 37 | + |
| 38 | +## Dependencies |
| 39 | + |
| 40 | +- Clean working tree on `main` at `5e9255f` (confirmed via pre-flight commit `84479a9`). |
| 41 | +- No blocking merges queued — `git log origin/main..HEAD` empty. |
| 42 | +- Go 1.24 toolchain available (verified via `go.mod`). |
| 43 | +- `make build && go test ./...` green on main (verified during Plan 15 merge). |
| 44 | + |
| 45 | +## Steps |
| 46 | + |
| 47 | +Execution order matters: Tier 1 bug fixes first (each independently verifiable), harness changes second (they depend on spinner refactor landing first), then add.go cleanup. |
| 48 | + |
| 49 | +### Step 1 — CRLF-proof installed shell scripts |
| 50 | + |
| 51 | +**Reproduction (defensive — current machine is LF):** fresh clone on Windows with `core.autocrlf=true` → clone rewrites `.sh.tmpl` sources to CRLF → `bonsai init` emits installed sensor scripts with CRLF → bash refuses `#!/bin/bash\r` → SessionStart hooks silently fail on the user's first session. |
| 52 | + |
| 53 | +**1a. Create `.gitattributes`** at repo root: |
| 54 | + |
| 55 | +```gitattributes |
| 56 | +# Force LF on anything that ends up in a generated shell hook or gets shelled out. |
| 57 | +*.sh text eol=lf |
| 58 | +*.sh.tmpl text eol=lf |
| 59 | +
|
| 60 | +# Keep lockfiles + generated-preview goldens predictable across checkouts. |
| 61 | +go.sum text eol=lf |
| 62 | +*.yaml text eol=lf |
| 63 | +``` |
| 64 | + |
| 65 | +**1b. Defensive LF normalization in the generator.** Edit `internal/generate/generate.go` `writeFile` (L262) and `writeFileChmod` (L301). For any target path where `strings.HasSuffix(rel, ".sh")` is true (so the post-`.tmpl`-strip output path), normalize bytes: replace every `\r\n` with `\n` and strip any standalone `\r` before writing. Rationale: belt-and-braces — even if `.gitattributes` gets dropped or a user clones through an old git that ignores it, the binary still writes LF. |
| 66 | + |
| 67 | +Prefer a single helper `normalizeShellLF(data []byte, rel string) []byte` placed immediately above `writeFile`. Call it in both `writeFile` and `writeFileChmod` before the `os.WriteFile` call. No-op for non-`.sh` paths. |
| 68 | + |
| 69 | +**1c. Generation-time test.** Add `TestShellScriptLF` to `internal/generate/generate_test.go`: |
| 70 | +- Build a minimal `catalog.Catalog` with one sensor (the test can construct it directly or re-use a helper — match `TestAgentWorkspaceNewFiles` style). |
| 71 | +- Run `AgentWorkspace` into a tmp dir. |
| 72 | +- For every file in `agent/Sensors/` ending in `.sh`, read bytes, assert zero `\r` characters. |
| 73 | +- Test should fail if a future change inlines a template with `\r\n` literal or removes the normalizer. |
| 74 | + |
| 75 | +**Acceptance:** after merge, `find agent/Sensors -name '*.sh' | xargs file` on a fresh `bonsai init` reports ASCII text with LF terminators; new test passes. |
| 76 | + |
| 77 | +### Step 2 — Fix `showWriteResults` cross-workspace tree root |
| 78 | + |
| 79 | +**Reproduction:** `bonsai init` with workspace `station/` → install a code agent `backend` with workspace `src/` → during `bonsai add backend`, the post-harness tree at `cmd/root.go:166-198` is called with `rootLabel = "src"`. But `wr.Files` includes files under `station/` too (because `PathScopedRules` and `WorkflowSkills` touch both workspaces). The prefix-strip path `strings.TrimPrefix(f.RelPath, "src/")` no-ops for `station/...` paths → they render *under* the `src` tree root → user sees nonsense like `src/station/agent/...`. |
| 80 | + |
| 81 | +**2a. Drop the single-root assumption.** Change `showWriteResults(wr *generate.WriteResult, rootLabel string)` signature behaviour: |
| 82 | + |
| 83 | +- Group files by their **top-level segment** (first component before the first `/`). |
| 84 | +- For each group, render a separate `FileTree` with that segment as the root label. |
| 85 | +- Keep `ActionCreated` / `ActionUpdated`/`ActionForced` / `ActionConflict` bucketing as today, but bucket *within* each top-level group. |
| 86 | + |
| 87 | +**2b. Keep call sites simple.** Drop the `rootLabel` parameter entirely — the function derives roots from paths. Update: |
| 88 | +- `cmd/init.go` L274: `showWriteResults(&wr)` |
| 89 | +- `cmd/update.go` L197: `showWriteResults(&wr)` |
| 90 | +- `cmd/add.go` — check for existing call (likely `showWriteResults(wr, …)`) and drop the label. |
| 91 | +- `cmd/remove.go` — same. |
| 92 | + |
| 93 | +**2c. Preserve stable panel ordering.** Sort top-level groups alphabetically so `bonsai init`'s sole workspace produces deterministic output and tests on top of this remain stable. Within each group, `Created` → `Updated` → `Skipped (user modified)`. |
| 94 | + |
| 95 | +**Acceptance:** running `bonsai add backend` in a `station/`-rooted project renders two trees rooted at `src` and `station` (or whichever labels apply) with no cross-root leaf leakage. |
| 96 | + |
| 97 | +### Step 3 — Dedup in `runUpdate.applyCustomFileSelection` |
| 98 | + |
| 99 | +**Reproduction:** user runs `bonsai update`, picks the same custom file twice across agents, or re-runs `update` after a prior successful run — `applyCustomFileSelection` appends to `installed.Skills` without checking for membership. Over time, `.bonsai.yaml` accumulates duplicate entries. |
| 100 | + |
| 101 | +**Fix:** `cmd/update.go:245-285` (`applyCustomFileSelection`). In the switch statement, for each type (`skill`, `workflow`, `protocol`, `sensor`, `routine`), build a `seen := make(map[string]struct{})` from the existing slice before the loop, then `if _, dup := seen[d.Name]; dup { continue }` before the append. Mark `seen[d.Name] = struct{}{}` after append so subsequent iterations in the same call de-dupe too. |
| 102 | + |
| 103 | +Refactor into a small helper to avoid five copies: |
| 104 | + |
| 105 | +```go |
| 106 | +func appendUnique(slice []string, name string) []string { |
| 107 | + for _, existing := range slice { |
| 108 | + if existing == name { |
| 109 | + return slice |
| 110 | + } |
| 111 | + } |
| 112 | + return append(slice, name) |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +Call `installed.Skills = appendUnique(installed.Skills, d.Name)` etc. |
| 117 | + |
| 118 | +**Acceptance:** `bonsai update` twice in a row, picking the same files each time, produces a `.bonsai.yaml` where each list has no duplicates. Add unit test `TestApplyCustomFileSelectionDedupes` — construct two calls with overlapping selections, assert post-state slice has `len(unique) == len(result)`. |
| 119 | + |
| 120 | +### Step 4 — Spinner error propagation (errors.Join at 29 sites) |
| 121 | + |
| 122 | +**Goal:** wrap every `_ = generate.X(...)` inside a spinner action closure with `errs = append(errs, generate.X(...))` and return `errors.Join(errs...)`. Harness already propagates via `spinnerDoneMsg.err` → `SpinnerStep.Result()`; callers need to actually check it. |
| 123 | + |
| 124 | +**4a. Migrate spinner actions (5 sites).** |
| 125 | + |
| 126 | +- `cmd/init.go` L167-206 — gather `errs` at top of closure, wrap L200-204 generators, return `errors.Join(cfg.Save err if non-nil, errs...)`. Preserve existing early-return on `cfg.Save` err — that's critical (no partial `.bonsai.yaml`). Actually: switch to `errs = append(errs, cfg.Save(configPath))` and keep early return pattern where needed (Scaffolding depends on cfg existing — keep `if err := cfg.Save(...); err != nil { return err }` first). |
| 127 | +- `cmd/add.go` L166-169 — `runAddSpinner` body. Find all `_ =` sites there (L334-337, L375-378 based on grep), collect errors, return `errors.Join`. |
| 128 | +- `cmd/remove.go` L95-106 (agent removal) — wrap L103 (`cfg.Save`) + L104 (`SettingsJSON`). |
| 129 | +- `cmd/remove.go` L315-317 — `runRemoveItemAction` body lives at L451+ (os.Remove calls that currently discard errors). Keep `os.Remove` as `_ =` (file-not-found after lock update is expected); wrap the *generator* calls at L472 (`RoutineDashboard`), L483 (`WorkspaceClaudeMD`), L487 (`cfg.Save`), L488 (`SettingsJSON`) with error collection, return `errors.Join`. |
| 130 | +- `cmd/update.go` L113-140 — wrap L134, L136, L137, L138 generators, return `errors.Join`. |
| 131 | + |
| 132 | +**4b. Caller error surfaces (4 sites).** |
| 133 | + |
| 134 | +- `cmd/init.go` L249-257 — already checks `results[10]`, keep. |
| 135 | +- `cmd/add.go` L234 — already checks `addOutcome.spinnerErr`. Verify `runAddSpinner` plumbs the errors.Join result into `addOutcome.spinnerErr`. |
| 136 | +- `cmd/remove.go` after L126 Run call, before L141 `if !asBool(results[0])` — extract spinner result at index 1 (after the initial Review step), if it's a non-nil error, `tui.Warning("Removal error: " + err.Error())` and `return nil`. |
| 137 | +- `cmd/remove.go` after L338 Run call — extract spinner result at index 2 (after optional Select + Lazy). Same `tui.Warning` path. |
| 138 | +- `cmd/update.go` after L155 Run call, before L170 conflict picker — extract spinner result at index `len(agentsWithDiscoveries)` (the spinner slot). Same `tui.Warning`. |
| 139 | + |
| 140 | +**4c. Keep `os.Remove` swallows where they're legitimate.** `cmd/remove.go:451,457,463,464` — these are best-effort filesystem cleanups where ENOENT is expected; don't aggregate. Leave as `_ =`. Similarly `cmd/root.go:157` (backup file write — best effort). |
| 141 | + |
| 142 | +**4d. Import `errors` where needed.** Every cmd file touched here should already have `errors` imported (they use `errors.Is`/`errors.As`). Double-check after edits. |
| 143 | + |
| 144 | +**Acceptance:** injected error in a generator (e.g. wrong path permissions) now produces a `Warning: Generation error: <err>` panel instead of silent success + broken output. Update `memory.md` note about ~30 callsites — mark "migrated to errors.Join" after this step. |
| 145 | + |
| 146 | +### Step 5 — SpinnerStep action goroutine recover() |
| 147 | + |
| 148 | +**File:** `internal/tui/harness/steps.go:746-757` (`SpinnerStep.Init`). |
| 149 | + |
| 150 | +Current code runs the action in a `tea.Cmd` closure that panics propagate through the BubbleTea event loop → harness crashes with no panel. Plan 15 iter 3 added `recoverBuilder` for Build/Splice; this is the gap. |
| 151 | + |
| 152 | +**Fix:** wrap the action invocation in a `defer recover()` that converts panic → `spinnerDoneMsg{err: fmt.Errorf("spinner action panic: %v", r)}`: |
| 153 | + |
| 154 | +```go |
| 155 | +func (s *SpinnerStep) Init() tea.Cmd { |
| 156 | + runner := s.action |
| 157 | + prev := s.initPrev |
| 158 | + if s.actionP != nil { |
| 159 | + runner = func() error { return s.actionP(prev) } |
| 160 | + } |
| 161 | + return tea.Batch( |
| 162 | + s.sp.Tick, |
| 163 | + func() (msg tea.Msg) { |
| 164 | + defer func() { |
| 165 | + if r := recover(); r != nil { |
| 166 | + msg = spinnerDoneMsg{err: fmt.Errorf("spinner action panic: %v", r)} |
| 167 | + } |
| 168 | + }() |
| 169 | + return spinnerDoneMsg{err: runner()} |
| 170 | + }, |
| 171 | + ) |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +**Acceptance:** unit test `TestSpinnerStepRecoversFromPanic` — action closure calls `panic("boom")`, harness runs to completion, result is an error matching `spinner action panic:`. Add to `internal/tui/harness/steps_test.go`. |
| 176 | + |
| 177 | +### Step 6 — NewConditional nil-predicate guard |
| 178 | + |
| 179 | +**File:** `internal/tui/harness/steps.go:807`. |
| 180 | + |
| 181 | +Current code accepts a nil predicate; `Init()` at L832 calls `c.predicate(c.initPrev)` → nil deref panic. |
| 182 | + |
| 183 | +**Fix:** in `NewConditional`, if `predicate == nil`, substitute `func(prev []any) bool { return true }` (default to NOT skip — safer than silently skipping). Document with a one-line comment: `// nil predicate = always show (safer default than skip)`. |
| 184 | + |
| 185 | +**Acceptance:** unit test `TestConditionalNilPredicateDefaultsToShow` — build with `NewConditional(inner, nil)`, call SetPrior(nil), call Init, assert `Done()` mirrors inner's Done (i.e. predicate defaulted to show path, not skip). |
| 186 | + |
| 187 | +### Step 7 — ConditionalStep predicate re-evaluation on Esc-back |
| 188 | + |
| 189 | +**Files:** `internal/tui/harness/harness.go:284-295`, `internal/tui/harness/steps.go:863-870`. |
| 190 | + |
| 191 | +**Problem:** Esc-back loop calls `Reset()` on each step from new cursor → origCursor, but does NOT call `SetPrior()` first. So when the new cursor lands on a `ConditionalStep`, its `initPrev` still holds the stale snapshot from the original forward traversal. If the user changed upstream picks, the predicate should re-evaluate against the new picks — but the step has no way to see them. |
| 192 | + |
| 193 | +Compounding: `ConditionalStep.Reset()` zeros `skip`/`skipDone` but doesn't re-evaluate the predicate. A forward `Done()` check after Reset returns the stale state until some later path re-invokes Init. |
| 194 | + |
| 195 | +**Fix 7a — harness.go Esc-back loop:** before `Reset()` each step, call `SetPrior(h.priorResults())` if the step implements `priorAware`. That way `initPrev` holds fresh results when `Reset()` fires. |
| 196 | + |
| 197 | +```go |
| 198 | +for i := h.cursor; i <= origCursor && i < len(h.steps); i++ { |
| 199 | + if pa, ok := h.steps[i].(priorAware); ok { |
| 200 | + pa.SetPrior(h.priorResults()) |
| 201 | + } |
| 202 | + if r, ok := h.steps[i].(resetter); ok { |
| 203 | + if cmd := r.Reset(); cmd != nil && i == h.cursor { |
| 204 | + cmds = append(cmds, cmd) |
| 205 | + } |
| 206 | + } |
| 207 | +} |
| 208 | +``` |
| 209 | + |
| 210 | +**Fix 7b — ConditionalStep.Reset():** re-evaluate predicate using the (now fresh) `initPrev`, and set `skip`/`skipDone` accordingly so `Done()` immediately reflects the re-evaluation. If predicate now says skip → `skip = true`, `skipDone = true`. If predicate now says show → `skip = false`, `skipDone = false`, delegate to inner resetter. |
| 211 | + |
| 212 | +```go |
| 213 | +func (c *ConditionalStep) Reset() tea.Cmd { |
| 214 | + c.skip = !c.predicate(c.initPrev) |
| 215 | + if c.skip { |
| 216 | + c.skipDone = true |
| 217 | + return nil |
| 218 | + } |
| 219 | + c.skipDone = false |
| 220 | + if r, ok := c.inner.(resetter); ok { |
| 221 | + return r.Reset() |
| 222 | + } |
| 223 | + return nil |
| 224 | +} |
| 225 | +``` |
| 226 | + |
| 227 | +**Acceptance:** new test `TestEscBackReevaluatesConditional` in `harness_test.go` — steps: [Select, Conditional(inner=Text, predicate=first-selection-was-'a')]. User picks 'a' → Cond shows → Text gets input → Esc-back to Select → picks 'b' → advances forward. Expect: Conditional skips this time. Test the whole harness run, not just the Reset method. |
| 228 | + |
| 229 | +Also keep existing `TestConditionalStepResetReevaluates` passing (it exercises the manual SetPrior→Reset→Init path; with 7b, the Init re-eval at L832 still works). |
| 230 | + |
| 231 | +### Step 8 — Drop duplicate Tech-Lead message in `bonsai add` |
| 232 | + |
| 233 | +**File:** `cmd/add.go:148-154` (in-AltScreen NoteStep) + `cmd/add.go:219-226` (post-harness ErrorDetail). |
| 234 | + |
| 235 | +**Fix:** delete the NoteStep branch entirely at L148-154. Let the LazyGroup return `nil` in the tech-lead-required case — the spinner's predicate (which checks `prev[len-1].(bool)`) will see a string (the agent type) at the tail and return false → spinner skips → harness ends clean. Post-harness ErrorDetail at L219-226 already handles this case on stdout. |
| 236 | + |
| 237 | +Concretely replace L145-155: |
| 238 | + |
| 239 | +```go |
| 240 | +// Require tech-lead before adding other agents. The user can still |
| 241 | +// pick "tech-lead" here to bootstrap — we only block when the pick |
| 242 | +// is a non-tech-lead agent and no tech-lead is installed yet. The |
| 243 | +// error surfaces post-harness on stdout (see L219) so it persists |
| 244 | +// after AltScreen exits. |
| 245 | +if agentType != "tech-lead" { |
| 246 | + if _, hasTechLead := cfg.Agents["tech-lead"]; !hasTechLead { |
| 247 | + return nil |
| 248 | + } |
| 249 | +} |
| 250 | +``` |
| 251 | + |
| 252 | +**Acceptance:** `bonsai add backend` on a project with no tech-lead → user sees exactly ONE "Tech Lead required" panel (the post-harness ErrorDetail), not two. |
| 253 | + |
| 254 | +## Security |
| 255 | + |
| 256 | +> [!warning] |
| 257 | +> Refer to `Playbook/Standards/SecurityStandards.md` for all security requirements. |
| 258 | +
|
| 259 | +- **Step 1 (CRLF fix):** no security impact. Line-ending normalization is a correctness fix, not a trust boundary. |
| 260 | +- **Step 2 (tree labels):** purely cosmetic output — no user input crosses a trust boundary. |
| 261 | +- **Step 3 (dedup):** output is `.bonsai.yaml` which the user already owns. No new I/O paths. |
| 262 | +- **Step 4 (errors.Join):** **net security improvement** — silent generator failures currently mask permission errors (e.g. `.claude/settings.json` write fails → user never sees hooks are broken). Surfacing errors makes tampering or permission-downgrade attacks more visible. |
| 263 | +- **Step 5 (panic recover):** no new attack surface — panic messages in Go can include format-string-interpolated content; the `fmt.Errorf("spinner action panic: %v", r)` interpolates via `%v` which is safe for arbitrary values. Do NOT use `%s` with unsanitized input. |
| 264 | +- **Steps 6–8:** no external input handled. |
| 265 | + |
| 266 | +No new secrets, no new network calls, no new file-write paths outside what the generator already writes. |
| 267 | + |
| 268 | +## Verification |
| 269 | + |
| 270 | +Run all of these, each must pass: |
| 271 | + |
| 272 | +- [ ] `make build` — binary compiles clean. |
| 273 | +- [ ] `go test ./...` — all tests green, including new tests added in Steps 1, 3, 5, 6, 7. |
| 274 | +- [ ] `go vet ./...` — clean. |
| 275 | +- [ ] `golangci-lint run` (CI v1.64.8) — clean. **Note:** local `golangci-lint` on this machine is v2.11.4 and errors out with `unsupported version`. Trust CI, or install v1.64.8 locally first. |
| 276 | +- [ ] Manual CRLF check: `grep -l $'\r' agent/Sensors/*.sh` on freshly-init'd project returns no matches. |
| 277 | +- [ ] Manual tree check: `bonsai init` in a temp dir with `docs_path: station`, then `bonsai add backend` with workspace `src`. Confirm the post-add "Created"/"Updated" panels render two trees rooted at `src/` and `station/` (no cross-leakage). |
| 278 | +- [ ] Manual dedup check: `bonsai update` twice in a row on the same project, pick same files. Confirm `.bonsai.yaml` lists have no duplicates. |
| 279 | +- [ ] Manual error surfacing: `chmod 000 .claude/` then `bonsai add skill <name>`. Confirm user sees a Warning panel, not silent success. |
| 280 | +- [ ] Manual Esc-back: `bonsai add`, pick an agent with Conditional downstream steps, Esc all the way back to the first select, change selection, forward-advance. Confirm skipped/shown state matches new selection. |
| 281 | +- [ ] Manual duplicate-message check: fresh project with no tech-lead, `bonsai add backend`. Exactly one "Tech Lead required" panel visible. |
| 282 | +- [ ] `git diff --stat` covers every file the plan says it should. No stray formatting churn outside the intended lines. |
| 283 | +- [ ] PR description enumerates all 8 items with bullet per file change. |
0 commit comments