Skip to content

Commit 90d6ec3

Browse files
LastStepclaude
andcommitted
docs(station): add Plan 19 — pre-launch bug sweep
Tier 2 plan bundling 4 fresh-install blockers (CRLF, cross-workspace tree, dedup, spinner errors) with 4 harness polish items (recover, nil-predicate guard, Esc-back re-eval, duplicate message). Worktree-dispatched implementation next. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 84479a9 commit 90d6ec3

1 file changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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

Comments
 (0)