@zhuxixi/pi-agent-board 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/PROGRESS.md +18 -3
  2. package/README.md +295 -72
  3. package/VERIFY.md +3 -3
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
  5. package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +223 -0
  6. package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
  7. package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +37 -0
  8. package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
  9. package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
  10. package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
  11. package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
  12. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +128 -0
  13. package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
  14. package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +77 -0
  15. package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +80 -0
  16. package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
  17. package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
  18. package/package.json +1 -1
  19. package/runner/job-runner.mjs +29 -3
  20. package/runner/pty-runner.mjs +139 -25
  21. package/runner/state-runner.mjs +7 -2
  22. package/runner/title-runner.mjs +1 -1
  23. package/src/core/atomic.mjs +40 -1
  24. package/src/core/code-refs-store.mjs +315 -0
  25. package/src/core/code-refs.mjs +861 -0
  26. package/src/core/host-crash.mjs +39 -0
  27. package/src/core/launch.mjs +6 -0
  28. package/src/core/paths.mjs +24 -1
  29. package/src/core/pty-attach-jiggle-controller.mjs +71 -17
  30. package/src/core/pty-attach-reconnect.mjs +43 -0
  31. package/src/core/pty-scroll.mjs +4 -3
  32. package/src/core/repo.mjs +56 -0
  33. package/src/core/rows.mjs +50 -0
  34. package/src/core/store.mjs +4 -1
  35. package/src/core/types.mjs +12 -0
  36. package/src/core/worktree.mjs +1 -0
  37. package/src/runtime/service.mjs +7 -1
  38. package/src/ui/dashboard.ts +20 -4
  39. package/src/ui/pty-attach.ts +124 -27
@@ -0,0 +1,223 @@
1
+ # Code-Refs Badges Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Show each board row's associated issue number and submitted PR number as inline badges, extracted locally from session evidence via a platform-agnostic regex-rule engine.
6
+
7
+ **Architecture:** Pure extraction engine (`src/core/code-refs.mjs`, zero I/O) driven by per-platform regex rule bundles ("providers": builtin GitHub/GitLab + user `providers.json`, append-merged); artifact persistence in `src/core/code-refs-store.mjs` (per-view `github.json`, atomic writes); write-through hooks at all five `writeEvidence` call sites; rendering via existing RowView badge + peek detail patterns. Spec: `docs/superpowers/specs/2026-08-29-code-refs-badges-design.md` (decisions D1–D5 govern).
8
+
9
+ **Tech Stack:** Node 20+ ESM `.mjs` (JSDoc types, no TS in core), `node --test`, pi-agent-board store layout.
10
+
11
+ ## Global Constraints
12
+
13
+ - **Work only in this worktree**: `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-40-code-refs-badges`. Never touch the main checkout.
14
+ - Core modules are `.mjs` with JSDoc typedefs; **indent with tabs** (match existing files); `node:assert/strict` + `node:test` for tests; tmp dirs via `mkdtempSync(join(tmpdir(), ...))` with `rmSync(..., {recursive:true, force:true})` cleanup.
15
+ - All artifact writes go through `atomicWriteJson` from `src/core/atomic.mjs`; reads through `readJson`.
16
+ - Commit per task, conventional commits (`feat:`/`test:`/`fix:`), stage files explicitly (`git add <file>`), never `git add -A`.
17
+ - Coverage gate (CI-enforced): lines 85% / functions 80% / branches 70% (`npm run test:coverage`); new core modules must be thoroughly covered.
18
+ - No network calls anywhere in v1. `gh`/`glab` are only ever *parsed as text*, never executed. Only `git` may be shelled out to (repo.mjs pattern: `execFileSync` with `stdio:["ignore","pipe","ignore"]`, try/catch → null).
19
+ - Kill switch: env `AGENT_BOARD_CODE_REFS=off` disables extraction (checked in the hook helper).
20
+ - English code comments and commit messages.
21
+
22
+ ---
23
+
24
+ ### Task 1: `gitRemoteHost` in repo.mjs
25
+
26
+ **Files:**
27
+ - Modify: `src/core/repo.mjs`
28
+ - Test: `test/repo.test.mjs`
29
+
30
+ **Interfaces:**
31
+ - Produces: `gitRemoteHost(repoRoot: string) → string|null` — host of `origin` remote, lowercase, no port. Supports `https://host/owner/repo(.git)` and `git@host:owner/repo(.git)`. Module-level `Map` cache keyed by repoRoot (failures cached as null). Also `clearRemoteHostCacheForTests() → void`.
32
+ - Consumed by: Task 4 (store) / Task 5 (hook helper).
33
+
34
+ - [ ] **Step 1: failing tests** in `test/repo.test.mjs` (follow existing temp-repo style, `skip: !gitAvailable()`):
35
+ - https remote `https://github.com/zhuxixi/pi-agent-board.git` → `"github.com"`
36
+ - ssh remote `git@gitlab.example.com:team/demo.git` → `"gitlab.example.com"`
37
+ - no remote → `null`; not a repo → `null`
38
+ - cache: second call returns same value; after `git remote set-url` + no cache clear, still old value; after `clearRemoteHostCacheForTests()`, new value
39
+ - [ ] **Step 2: run tests, see them fail** (`node --test test/repo.test.mjs`)
40
+ - [ ] **Step 3: implement** in `src/core/repo.mjs` (same `execFileSync("git", ["-C", root, "remote", "get-url", "origin"], …)` pattern as `gitRepoRoot`; parse with two regexes; cache both hits and misses)
41
+ - [ ] **Step 4: tests pass**
42
+ - [ ] **Step 5: commit** `feat(repo): gitRemoteHost with per-root cache`
43
+
44
+ ---
45
+
46
+ ### Task 2: provider layer in code-refs.mjs (schema, builtins, merge, host match)
47
+
48
+ **Files:**
49
+ - Create: `src/core/code-refs.mjs`
50
+ - Test: `test/code-refs-providers.test.mjs`
51
+
52
+ **Interfaces:**
53
+ - Produces (all pure, zero I/O except `loadProviders` reading one JSON file path passed in):
54
+ - `builtinProviders() → Provider[]` — GitHub + GitLab bundles (rules below)
55
+ - `genericFallbackProvider() → Provider` — name `"generic"`, hosts `[]`, URL-only rules: `/issues/(\d+)` (issue/view), `/pull/(\d+)` (pr/action), `/-/issues/(\d+)` (issue/view), `/-/merge_requests/(\d+)` (pr/action); prefixes `#` / `▸#`; no urlTemplates
56
+ - `validateProvider(raw: any) → { provider: Provider|null, errors: string[] }` — each rule compiled with `new RegExp(pattern)`; invalid regex → skipped with error message; missing required fields → error
57
+ - `mergeProviders(builtins: Provider[], user: Provider[]) → Provider[]` — same `name` → user rules **prepended** to builtin rules, user scalar fields (hosts/prefixes/urlTemplates) override; unknown names appended as-is
58
+ - `loadProviders(root: string) → Provider[]` — reads `<root>/providers.json` if present (via `readJson` from atomic.mjs), validates + merges with builtins; on any error returns builtins alone (never throws). Result cached by file mtime in a module Map.
59
+ - `matchProvider(providers: Provider[], host: string|null) → Provider` — exact host match (lowercase); null host or no match → `genericFallbackProvider()`
60
+ - Provider typedef: `{ name, hosts: string[], issuePrefix: string, prPrefix: string, urlTemplates: { issue?: string, pr?: string }|null, rules: Rule[] }`; Rule typedef: `{ regex: RegExp, pattern: string, kind: "issue"|"pr", strength: "claim"|"action"|"view", numberFrom: "capture"|"outputUrl" }`
61
+ - Consumed by: Task 3 (engine), Task 5 (hook helper calls `loadProviders`).
62
+
63
+ Builtin GitHub rules (patterns are matched **unanchored** against full command strings and assistant texts):
64
+ ```
65
+ claim: gh\s+issue\s+edit\s+#?(\d+)(?=[\s\S]*--add-assignee) kind: issue
66
+ action: gh\s+issue\s+(?:comment|edit|close|reopen)\s+#?(\d+) kind: issue
67
+ action: gh\s+issue\s+create\b kind: issue, numberFrom: outputUrl
68
+ action: gh\s+pr\s+(?:checkout|merge|comment|review|close)\s+#?(\d+) kind: pr
69
+ action: gh\s+pr\s+create\b kind: pr, numberFrom: outputUrl
70
+ action: github\.com/[\w.-]+/[\w.-]+/pull/(\d+) kind: pr
71
+ view: gh\s+issue\s+view\s+#?(\d+) kind: issue
72
+ view: gh\s+pr\s+(?:view|diff|checks)\s+#?(\d+) kind: pr
73
+ view: github\.com/[\w.-]+/[\w.-]+/issues/(\d+) kind: issue
74
+ urlTemplates: issue "https://{host}/{owner}/{repo}/issues/{number}", pr "https://{host}/{owner}/{repo}/pull/{number}"
75
+ ```
76
+ Builtin GitLab rules:
77
+ ```
78
+ claim: glab\s+issue\s+(?:edit|update)\s+#?(\d+)(?=[\s\S]*--assignee) kind: issue
79
+ action: glab\s+issue\s+(?:note|comment|close|reopen)\s+#?(\d+) kind: issue
80
+ action: glab\s+mr\s+(?:checkout|merge)\s+!?(\d+) kind: pr
81
+ action: glab\s+mr\s+create\b kind: pr, numberFrom: outputUrl
82
+ action: /-/merge_requests/(\d+) kind: pr
83
+ view: glab\s+(?:issue|mr)\s+view\s+!?#?(\d+) kind: issue-or-pr by matched subcommand — implement as two rules: glab\s+issue\s+view\s+#?(\d+) (issue) and glab\s+mr\s+view\s+!?(\d+) (pr)
84
+ view: /-/issues/(\d+) kind: issue
85
+ prefixes: issue "#", pr "!"
86
+ urlTemplates: issue "https://{host}/{owner}/{repo}/-/issues/{number}", pr "https://{host}/{owner}/{repo}/-/merge_requests/{number}"
87
+ ```
88
+
89
+ - [ ] **Step 1: failing tests**: builtin shape sanity (every rule regex compiles, kinds/strengths in enum); validateProvider rejects bad regex / missing kind; mergeProviders prepends user rules and overrides prefixes; loadProviders with missing file → builtins; with broken JSON → builtins; with valid user file → merged; matchProvider exact/lowercase/fallback
90
+ - [ ] **Step 2: run, fail**
91
+ - [ ] **Step 3: implement**
92
+ - [ ] **Step 4: tests pass**
93
+ - [ ] **Step 5: commit** `feat(code-refs): provider schema, builtin github/gitlab rules, append-merge loading`
94
+
95
+ ---
96
+
97
+ ### Task 3: extraction + scoring engine in code-refs.mjs
98
+
99
+ **Files:**
100
+ - Modify: `src/core/code-refs.mjs`
101
+ - Test: `test/code-refs-extract.test.mjs`
102
+
103
+ **Interfaces:**
104
+ - Consumes: Provider/Rule from Task 2.
105
+ - Produces:
106
+ - `extractCodeRefs(input, provider) → CodeRefsResult`
107
+ - `input: { commands: Array<{ command: string }>, assistantTexts: string[], worktreePath: string|null, branch: string|null, repoUrl: string|null }` (`repoUrl` = `owner/repo` path part of the remote, used for urlTemplates; may be null)
108
+ - `CodeRefsResult: { provider: string, issue: Ref|null, pr: Ref|null, allRefs: Ref[] }`
109
+ - `Ref: { kind: "issue"|"pr", number: number, strength: "claim"|"action"|"view"|"mention", confidence: "high"|"medium"|"low", source: string, url: string|null, lastIndex: number }`
110
+ - `parseRepoPath(repoRoot) → string|null` — **move-free helper**: parse `owner/repo` from remote URL. NOTE: Task 1 didn't produce this; add `gitRemoteUrl(repoRoot)` to repo.mjs here (same pattern, also cached) returning the raw URL, and keep URL→host and URL→path parsing pure in code-refs.mjs (`parseRemoteHost(url)`, `parseRemotePath(url)`). Refactor Task 1's `gitRemoteHost` to use `gitRemoteUrl` + `parseRemoteHost` internally. Update repo tests accordingly.
111
+
112
+ Engine rules (implement exactly):
113
+ 1. Scan `commands` in array order; each rule regex applied to `command`; capture group 1 = number (rules with `numberFrom:"outputUrl"` yield no number here — record a *pending create* marker with kind+index).
114
+ 2. Scan `assistantTexts` (treat as ordered sequence after commands, indexes continue) with only the URL rules (patterns containing `/` path segments — select rules whose pattern contains `issues/|pull/|merge_requests/`).
115
+ 3. Pending-create resolution: for each `pr create`/`issue create`/`mr create` marker, search **subsequent** texts/commands for the provider's URL rule of the same kind; first hit assigns the number at strength `action`, `source:"create-url"`. Unresolved markers contribute nothing (outputPreview bug #41 means bash output is unreadable — do **not** read outputPreview).
116
+ 4. PR back-link: within a `pr create` command string, `(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|issue)\s+#(\d{1,7})` → issue ref at `claim` strength, `source:"pr-body"`.
117
+ 5. Worktree naming (engine-builtin, not configurable): `(?:^|[/\\])issue-(\d{1,7})(?:-|$)` against `worktreePath` and `branch` → issue ref at `claim`, `source:"worktree"`.
118
+ 6. Mention fallback (only if no issue candidate of strength ≥ view exists): count `#(\d{1,7})` over the **last 20** assistantTexts; winner needs count ≥3 **and** ≥ 2× runner-up; strength `mention`, confidence `low`, kind issue, no url.
119
+ 7. Aggregation per kind: candidate with highest strength wins; tie → highest `lastIndex`; `view` candidates with total count < 2 are discarded first. Confidence: claim/action → `high`, view → `medium`, mention → `low`. `allRefs`: distinct (kind,number) sorted by strength desc then lastIndex desc, max 10. `url`: fill from provider.urlTemplates when repoUrl + template exist (`{host}` needs host — pass host in via provider match context: extend input with `host: string|null`; substitute `{host}/{owner}/{repo}` — owner/repo split from repoUrl path, repo name minus trailing `.git`).
120
+ 8. Empty input → `{ provider: provider.name, issue: null, pr: null, allRefs: [] }`.
121
+
122
+ Test cases must include (synthetic but modeled on real observed sessions):
123
+ - assign claim beats later plain `issue view` of another number
124
+ - worktree path `issue-40-code-refs-badges` yields issue 40 claim with zero commands
125
+ - `gh pr create --body "…issue #40…"` sets both pr (pending → resolved by later assistant URL text) and issue 40 (pr-body claim)
126
+ - ambiguity case: commands viewing 439/440/441 each once, commenting 453 twice → issue = none from view (count<2), pr… construct exact expectation per rules (453 is a pr comment → pr=453 action)
127
+ - mention fallback: `#40` ×5, `#7` ×2 → issue 40 low confidence; `#40` ×3, `#41` ×3 → no winner (not 2×)
128
+ - unresolved `gh pr create` (no later URL) → pr null
129
+ - empty input; null host with generic fallback provider still extracts from URLs
130
+
131
+ - [ ] **Step 1: failing tests** (above list, one test each)
132
+ - [ ] **Step 2: run, fail**
133
+ - [ ] **Step 3: implement** (including the repo.mjs `gitRemoteUrl` refactor)
134
+ - [ ] **Step 4: tests pass; re-run task-1 tests**
135
+ - [ ] **Step 5: commit** `feat(code-refs): extraction engine with 4-tier signal scoring`
136
+
137
+ ---
138
+
139
+ ### Task 4: github.json artifact + store plumbing
140
+
141
+ **Files:**
142
+ - Create: `src/core/code-refs-store.mjs`
143
+ - Modify: `src/core/paths.mjs`, `src/core/types.mjs`, `src/core/store.mjs`
144
+ - Test: `test/code-refs-store.test.mjs`, extend `test/store.test.mjs`
145
+
146
+ **Interfaces:**
147
+ - `paths.mjs`: `providersPath(root) → <root>/providers.json`; `codeRefsPath(root, viewId) → <root>/views/<id>/github.json`
148
+ - `code-refs-store.mjs`:
149
+ - `emptyCodeRefsSnapshot({viewId}) → snapshot` `{ version:1, viewId, updatedAt, provider:null, issue:null, pr:null, allRefs:[] }`
150
+ - `normalizeCodeRefsSnapshot(raw, {viewId})` (same defensive shape as evidence's normalize)
151
+ - `readCodeRefs(root, viewId) → snapshot` / `writeCodeRefs(root, snapshot) → snapshot` (atomicWriteJson, bumps updatedAt)
152
+ - `summarizeCodeRefs(snapshot) → { provider, issue, pr, allRefs }`
153
+ - `updateCodeRefsFromEvidence(root, viewId, evidence) → boolean` — the hook helper: returns false without writing when `AGENT_BOARD_CODE_REFS=off`; reads meta via `readMeta` (lazy import cycle check — store.mjs must not import code-refs-store.mjs if code-refs-store imports store.mjs: therefore `updateCodeRefsFromEvidence` takes `meta` as a parameter instead; callers pass `row.meta`/config). Resolves repoRoot = `meta.repoRoot ?? meta.cwd`, host via `gitRemoteUrl`+parse, provider via `loadProviders(root)`+`matchProvider`, worktreePath/branch (`branch`: `git -C <cwd> branch --show-current`, best-effort cached 60s in module Map), builds engine input from `evidence.commands` + last 20 `assistantEvidence[].text`, writes snapshot only when serialized content changed. Never throws (catch → appendDiagnostic `code_refs_extract_failed`, return false).
154
+ - `types.mjs`: `CodeRefsSummary` typedef; `ViewState` optional `codeRefs`; Row typedef gains `codeRefs`.
155
+ - `store.mjs` `readViewArtifactSummaries`: add `codeRefs: summarizeCodeRefs(readCodeRefs(root, viewId))` (mirror evidence lines; archived short-circuit untouched).
156
+
157
+ - [ ] **Step 1: failing tests**: paths shape; snapshot normalize (garbage in → safe defaults); write→read roundtrip; summarize; updateCodeRefsFromEvidence with a fabricated evidence (commands containing `gh issue view 40` ×2) + fabricated meta (cwd = temp repo with github remote) → github.json contains issue 40 medium confidence; `AGENT_BOARD_CODE_REFS=off` → no file; broken providers.json in root → still extracts via builtins
158
+ - [ ] **Step 2: run, fail**
159
+ - [ ] **Step 3: implement**
160
+ - [ ] **Step 4: tests pass (incl. existing store.test.mjs)**
161
+ - [ ] **Step 5: commit** `feat(code-refs): per-view github.json artifact + store plumbing`
162
+
163
+ ---
164
+
165
+ ### Task 5: hook the five writeEvidence sites
166
+
167
+ **Files:**
168
+ - Modify: `runner/job-runner.mjs` (2 sites: initial write ~L66, shared `persist()` ~L105), `src/runtime/service.mjs` (syncRowEvent ~L519 and agent_end ~L550), `runner/state-runner.mjs` (~L57)
169
+ - Test: extend `test/service.test.mjs` (or the existing runner integration style) minimally; full coverage arrives in Task 7.
170
+
171
+ **Interfaces:**
172
+ - Consumes: `updateCodeRefsFromEvidence(root, viewId, evidence, meta)` from Task 4.
173
+ - Call it immediately after each `writeEvidence(root, evidence)`:
174
+ - job-runner sites have `config` (with cwd) but need meta → use `readMeta(root, viewId)` once at runner start, reuse
175
+ - service.mjs `syncRowEvent(row, event)` → pass `row.meta`
176
+ - state-runner has `config` → `readMeta(config.root, config.viewId)` once
177
+ - All call sites wrapped so a throw can never escape (helper already never throws; still call inside existing try blocks where present).
178
+
179
+ - [ ] **Step 1: failing test**: service-level — feed `syncRowEvent`-equivalent path (see how service.test.mjs fabricates rows) an event stream whose bash command is `gh issue comment 40 --body hi`; assert `<root>/views/<id>/github.json` exists with issue 40
180
+ - [ ] **Step 2: run, fail**
181
+ - [ ] **Step 3: implement the 5 hook calls**
182
+ - [ ] **Step 4: tests pass**
183
+ - [ ] **Step 5: commit** `feat(code-refs): extract on every evidence write (job-runner/service/state-runner)`
184
+
185
+ ---
186
+
187
+ ### Task 6: RowView badges + peek Refs section + README
188
+
189
+ **Files:**
190
+ - Modify: `src/core/rows.mjs` (rowView), `src/ui/dashboard.ts` (renderRow badges ~L1364-1365; renderPeek after Auto-state block ~L1400), `README.md` (env var table row)
191
+ - Test: `test/rows.test.mjs`, `test/dashboard-render.test.mjs`
192
+
193
+ **Interfaces:**
194
+ - RowView gains: `refsBadge: string` (e.g. `"#40 ▸#45"`, `""` when nothing), `refsLowConfidence: boolean` (true when the winning issue or pr confidence is `low`), `codeRefs: CodeRefsSummary|null` (peek consumes).
195
+ - Badge format: `${issuePrefix}${issue.number}` and `${prPrefix}${pr.number}` joined by space; prefixes from `summary.provider`'s bundle — simplest: store resolved prefixes in the snapshot at write time (add `issuePrefix`/`prPrefix` fields to snapshot in Task 4's normalize with defaults `#`/`▸#`; if you do this, update Task 4 tests — do it as part of this task's implementation and keep Task 4 commit green by amending its tests here).
196
+ - renderRow: append `refsBadge` to `statusBadges` string; when `refsLowConfidence` wrap badge in `t.fg("dim", …)` (compose with existing badge assembly; verify width math still clamps via existing `visibleWidth(badge)` path).
197
+ - renderPeek: after the Auto-state block add a `Refs` section (mirror that block's structure): provider name; one line per allRefs entry `kind #number · confidence · source · url`; section omitted when `codeRefs` is null/empty.
198
+ - README env table: add `AGENT_BOARD_CODE_REFS` row (`off` disables issue/PR badge extraction).
199
+
200
+ - [ ] **Step 1: failing tests**: rows.mjs rowView maps summary → badge strings (incl. dim flag, empty case); dashboard-render test asserting badge appears in the row line and Refs section renders in peek (follow existing dashboard-render.test.mjs patterns)
201
+ - [ ] **Step 2: run, fail**
202
+ - [ ] **Step 3: implement**
203
+ - [ ] **Step 4: tests pass**
204
+ - [ ] **Step 5: commit** `feat(dashboard): inline issue/PR badges + peek Refs section`
205
+
206
+ ---
207
+
208
+ ### Task 7: end-to-end integration + full verify
209
+
210
+ **Files:**
211
+ - Modify: `test-support/fake-pi.mjs` (new `FAKE_PI_MODE=github-refs`: emitted event stream includes a bash tool_execution for `gh issue edit 40 --add-assignee @me`, later an assistant message containing `https://github.com/zhuxixi/pi-agent-board/pull/45`), `test/runner.integration.test.mjs` (new case: run with that mode, assert `github.json` has issue 40 claim + pr 45 action, and row view badge `#40 ▸#45`)
212
+ - Test only.
213
+
214
+ - [ ] **Step 1: write the failing integration test** (mirror existing runner.integration.test.mjs setup: tmp AGENT_BOARD_ROOT etc.)
215
+ - [ ] **Step 2: extend fake-pi.mjs mode**, run test, iterate to green
216
+ - [ ] **Step 3: full `npm run verify`** (typecheck + tests + coverage + pack:dry) — all green; if coverage dips below gate, add focused unit tests to the new modules (do not weaken thresholds)
217
+ - [ ] **Step 4: commit** `test(code-refs): fake-pi github-refs mode + end-to-end badge assertion`
218
+
219
+ ---
220
+
221
+ ## Post-implementation (controller, not a task)
222
+
223
+ - Dispatch final broad code review, then open PR (`Closes #40`), label `zima:needs-review`, monitor CR per zima-pr-monitor skill.
@@ -0,0 +1,308 @@
1
+ # Circular List Navigation Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make dashboard list arrow-key navigation wrap around (last ⇄ first) instead of clamping at boundaries.
6
+
7
+ **Architecture:** Change index clamping to modulo wrap in `moveSelection()` and `peekStep()` in `src/ui/dashboard.ts`. Scroll-follow needs no change (`windowBody()` keeps the selected row visible regardless of direction). Behavior is covered by a new child-process test following the `ui-smoke` pattern.
8
+
9
+ **Tech Stack:** Node 24 (`--experimental-transform-types`), node:test, TypeScript (parameter properties, no typecheck on test-support).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-30-circular-navigation-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Work only in the worktree: `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation` (all paths below are relative to it).
16
+ - `npm test` = `node --test test/*.test.mjs`; `npm run typecheck` = `tsc --noEmit`. Both must stay green.
17
+ - `src/ui/dashboard.ts` uses TS parameter properties → it can only be imported from a child process spawned with `--experimental-transform-types` (the `test-support/ui-smoke.ts` pattern). Never import it directly from `test/*.test.mjs`.
18
+ - `tsconfig.json` excludes `test/` and `test-support/` — no type casts needed in the probe script.
19
+ - Do NOT touch the launch dialog pickers (`dashboard.ts` L539-547) — out of scope for this issue.
20
+ - Commit per task with conventional commits; `git add <file>` per file, never `git add -A`.
21
+ - `cur < 0` fallback semantics: out-of-list `selectedId` uses base index 0 — ↓ yields index 1 (second row, same as before); ↑ wraps to the last row (**changed** from the old clamp's first row — intentional, wrap-consistent, and effectively unreachable since `refresh()` keeps `selectedId` ∈ `orderedIds`).
22
+
23
+ ---
24
+
25
+ ### Task 1: `moveSelection()` 取模回绕 + 行为测试
26
+
27
+ **Files:**
28
+ - Modify: `src/ui/dashboard.ts:250-258` (`moveSelection`)
29
+ - Create: `test-support/navigation-wrap.ts` (probe script, prints JSON)
30
+ - Create: `test/dashboard-navigation.test.mjs` (node:test wrapper)
31
+
32
+ **Interfaces:**
33
+ - Consumes: existing `createService` (`src/runtime/service.mjs`), `createView` (`src/core/store.mjs`), `DashboardComponent` (`src/ui/dashboard.ts`), public method `handleInput(data: string)`.
34
+ - Produces: `test-support/navigation-wrap.ts` prints one JSON line `{ ids: string[], seq: (string|null)[] }`; Task 2 extends the same probe with a peek-mode segment (do not rewrite the file structure).
35
+
36
+ - [ ] **Step 1: Worktree dependency setup**
37
+
38
+ Worktree has no `node_modules`. Symlink from the main checkout (instant; postinstall patches already applied there):
39
+
40
+ ```bash
41
+ WT=/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
42
+ ln -s /home/elling/git-repo/github/pi-agent-board/node_modules "$WT/node_modules"
43
+ ls "$WT/node_modules/@mariozechner" # sanity: pi-tui present
44
+ ```
45
+
46
+ (Fallback if the symlink misbehaves: `cd "$WT" && npm ci`.)
47
+
48
+ - [ ] **Step 2: Write the failing test probe**
49
+
50
+ Create `test-support/navigation-wrap.ts`:
51
+
52
+ ```ts
53
+ // Nav wrap probe: construct a dashboard with 3 views, drive arrow keys through
54
+ // the public handleInput(), and report the selection sequence as JSON.
55
+ // Run via `node --experimental-transform-types` (dashboard.ts uses TS
56
+ // parameter properties). Not typechecked (tsconfig excludes test-support).
57
+ import { mkdtempSync } from "node:fs";
58
+ import { tmpdir } from "node:os";
59
+ import { join } from "node:path";
60
+ import { createService } from "../src/runtime/service.mjs";
61
+ import { createView } from "../src/core/store.mjs";
62
+ import { DashboardComponent } from "../src/ui/dashboard.ts";
63
+
64
+ const root = mkdtempSync(join(tmpdir(), "agentview-nav-wrap-"));
65
+ createView(root, { id: "v1", name: "one", cwd: root });
66
+ createView(root, { id: "v2", name: "two", cwd: root });
67
+ createView(root, { id: "v3", name: "three", cwd: root });
68
+
69
+ const service = createService({
70
+ root,
71
+ runnerScript: "/no/runner.mjs",
72
+ piCommand: "pi",
73
+ piArgsPrefix: [],
74
+ defaultCwd: root,
75
+ launch: () => ({ pid: null, configPath: "/no/config.json" }),
76
+ launchHost: () => ({ pid: null, configPath: "/no/host-config.json" }),
77
+ launchTitle: () => ({ pid: null, configPath: "/no/title-config.json" }),
78
+ });
79
+
80
+ const writes = [];
81
+ const tui = {
82
+ terminal: { rows: 24, cols: 80, columns: 80, write: (d) => writes.push(d) },
83
+ requestRender: () => {},
84
+ };
85
+ const theme = { fg: (_c, t) => t, bold: (t) => t };
86
+
87
+ const dash = new DashboardComponent(tui, theme, {}, () => {}, {
88
+ service,
89
+ root,
90
+ defaultCwd: root,
91
+ availableModels: [],
92
+ currentModel: null,
93
+ currentThinkingLevel: "off",
94
+ });
95
+
96
+ // TS-private is runtime-accessible; read white-box state for assertions.
97
+ const ids = dash.orderedIds;
98
+ const seq = [];
99
+ seq.push(dash.selectedId); // initial selection = ids[0]
100
+ dash.handleInput("\x1b[B"); // ↓ -> ids[1]
101
+ seq.push(dash.selectedId);
102
+ dash.handleInput("\x1b[B"); // ↓ -> ids[2]
103
+ seq.push(dash.selectedId);
104
+ dash.handleInput("\x1b[B"); // ↓ at last -> WRAP to ids[0]
105
+ seq.push(dash.selectedId);
106
+ dash.handleInput("\x1b[A"); // ↑ at first -> WRAP to ids[2]
107
+ seq.push(dash.selectedId);
108
+
109
+ dash.dispose();
110
+ console.log(JSON.stringify({ ids, seq }));
111
+ ```
112
+
113
+ - [ ] **Step 3: Write the failing test wrapper**
114
+
115
+ Create `test/dashboard-navigation.test.mjs`:
116
+
117
+ ```js
118
+ import assert from "node:assert/strict";
119
+ import { execFileSync } from "node:child_process";
120
+ import { fileURLToPath } from "node:url";
121
+ import { join } from "node:path";
122
+ import test from "node:test";
123
+
124
+ const ROOT_DIR = fileURLToPath(new URL("../", import.meta.url));
125
+ const PROBE = join(ROOT_DIR, "test-support", "navigation-wrap.ts");
126
+
127
+ test("list arrow-key navigation wraps around at both ends (issue #52)", () => {
128
+ // dashboard.ts uses TS parameter properties, which strip-only mode rejects;
129
+ // --experimental-transform-types handles them (Node 22.7+ / 24).
130
+ const out = execFileSync(process.execPath, ["--experimental-transform-types", PROBE], {
131
+ encoding: "utf8",
132
+ timeout: 30_000,
133
+ });
134
+ const { ids, seq } = JSON.parse(out);
135
+ assert.equal(ids.length, 3, "probe must see 3 rows");
136
+ assert.deepEqual(
137
+ seq,
138
+ [ids[0], ids[1], ids[2], ids[0], ids[2]],
139
+ "down past the last row wraps to first; up past the first wraps to last",
140
+ );
141
+ });
142
+ ```
143
+
144
+ - [ ] **Step 4: Run test to verify it fails**
145
+
146
+ ```bash
147
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
148
+ node --test test/dashboard-navigation.test.mjs
149
+ ```
150
+
151
+ Expected: FAIL — `seq[3]` is `ids[2]` (clamped at last) instead of `ids[0]`, and `seq[4]` is `ids[0]` instead of `ids[2]`.
152
+
153
+ If the probe itself crashes (e.g. key sequence mismatch), debug the probe first — `matchesKey(data, Key.up/down)` must match `\x1b[A` / `\x1b[B]`; verify against `@mariozechner/pi-tui` `Key` defaults if not.
154
+
155
+ - [ ] **Step 5: Implement modulo wrap in `moveSelection()`**
156
+
157
+ In `src/ui/dashboard.ts`, replace:
158
+
159
+ ```ts
160
+ private moveSelection(delta: number): void {
161
+ if (this.orderedIds.length === 0) return;
162
+ const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
163
+ const next = Math.max(0, Math.min(this.orderedIds.length - 1, (cur < 0 ? 0 : cur) + delta));
164
+ ```
165
+
166
+ with:
167
+
168
+ ```ts
169
+ private moveSelection(delta: number): void {
170
+ if (this.orderedIds.length === 0) return;
171
+ const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
172
+ const len = this.orderedIds.length;
173
+ // Wrap around both ends: down past last -> first, up past first -> last.
174
+ const next = (((cur < 0 ? 0 : cur) + delta) % len + len) % len;
175
+ ```
176
+
177
+ Everything below that line (`const nextId = ...` through the end of the method) stays unchanged — the `nextId === this.selectedId` early return keeps single-row lists a no-op.
178
+
179
+ - [ ] **Step 6: Run test to verify it passes**
180
+
181
+ ```bash
182
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
183
+ node --test test/dashboard-navigation.test.mjs
184
+ ```
185
+
186
+ Expected: PASS.
187
+
188
+ - [ ] **Step 7: Full test suite + typecheck**
189
+
190
+ ```bash
191
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
192
+ npm test && npm run typecheck
193
+ ```
194
+
195
+ Expected: all green.
196
+
197
+ - [ ] **Step 8: Commit**
198
+
199
+ ```bash
200
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
201
+ git add src/ui/dashboard.ts test-support/navigation-wrap.ts test/dashboard-navigation.test.mjs
202
+ git commit -m "fix: wrap list arrow-key navigation at both ends (issue #52)"
203
+ ```
204
+
205
+ ---
206
+
207
+ ### Task 2: `peekStep()` 取模回绕 + peek 段测试
208
+
209
+ **Files:**
210
+ - Modify: `src/ui/dashboard.ts:1094-1101` (`peekStep`)
211
+ - Modify: `test-support/navigation-wrap.ts` (append peek segment)
212
+ - Modify: `test/dashboard-navigation.test.mjs` (assert extended sequence)
213
+
214
+ **Interfaces:**
215
+ - Consumes: Task 1's probe file and test wrapper; `peekId` / `mode` are TS-private fields on `DashboardComponent`, runtime-accessible from the probe (same white-box pattern as `selectedId`).
216
+ - Produces: probe JSON gains nothing new — `seq` grows by one entry (peek-mode wrap result); test asserts the 6-element sequence.
217
+
218
+ - [ ] **Step 1: Extend the probe with a peek-mode segment (failing)**
219
+
220
+ In `test-support/navigation-wrap.ts`, insert immediately BEFORE `dash.dispose();`:
221
+
222
+ ```ts
223
+ // Peek mode: stepping down from the last row wraps to the first.
224
+ dash.peekId = ids[2];
225
+ dash.selectedId = ids[2];
226
+ dash.mode = "peek";
227
+ dash.handleInput("\x1b[B"); // ↓ at last in peek -> WRAP to ids[0]
228
+ seq.push(dash.selectedId);
229
+ ```
230
+
231
+ And in `test/dashboard-navigation.test.mjs`, replace the final assertion with:
232
+
233
+ ```js
234
+ assert.deepEqual(
235
+ seq,
236
+ [ids[0], ids[1], ids[2], ids[0], ids[2], ids[0]],
237
+ "list and peek navigation wrap around at both ends",
238
+ );
239
+ ```
240
+
241
+ - [ ] **Step 2: Run test to verify it fails**
242
+
243
+ ```bash
244
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
245
+ node --test test/dashboard-navigation.test.mjs
246
+ ```
247
+
248
+ Expected: FAIL — `seq[5]` is `ids[2]` (peek clamped at last) instead of `ids[0]`.
249
+
250
+ - [ ] **Step 3: Implement modulo wrap in `peekStep()`**
251
+
252
+ In `src/ui/dashboard.ts`, replace:
253
+
254
+ ```ts
255
+ private peekStep(delta: number): void {
256
+ if (!this.peekId) return;
257
+ const idx = this.orderedIds.indexOf(this.peekId);
258
+ if (idx < 0) return;
259
+ const next = Math.max(0, Math.min(this.orderedIds.length - 1, idx + delta));
260
+ ```
261
+
262
+ with:
263
+
264
+ ```ts
265
+ private peekStep(delta: number): void {
266
+ if (!this.peekId) return;
267
+ const idx = this.orderedIds.indexOf(this.peekId);
268
+ if (idx < 0) return;
269
+ const len = this.orderedIds.length;
270
+ // Wrap around both ends, same as moveSelection().
271
+ const next = ((idx + delta) % len + len) % len;
272
+ ```
273
+
274
+ The two lines below (`this.peekId = ...; this.selectedId = ...`) stay unchanged. `len` is guaranteed `>= 1` here because `idx >= 0` implies the id was found in `orderedIds`.
275
+
276
+ - [ ] **Step 4: Run test to verify it passes**
277
+
278
+ ```bash
279
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
280
+ node --test test/dashboard-navigation.test.mjs
281
+ ```
282
+
283
+ Expected: PASS.
284
+
285
+ - [ ] **Step 5: Full test suite + typecheck**
286
+
287
+ ```bash
288
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
289
+ npm test && npm run typecheck
290
+ ```
291
+
292
+ Expected: all green.
293
+
294
+ - [ ] **Step 6: Commit**
295
+
296
+ ```bash
297
+ cd /home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-52-circular-navigation
298
+ git add src/ui/dashboard.ts test-support/navigation-wrap.ts test/dashboard-navigation.test.mjs
299
+ git commit -m "fix: wrap peek-mode stepping at both ends (issue #52)"
300
+ ```
301
+
302
+ ---
303
+
304
+ ## Self-Review 记录
305
+
306
+ - **Spec coverage**:spec 改动点 #1 → Task 1;改动点 #2 → Task 2;滚动跟随"不改"已在 Global Constraints 外无对应任务(无需任务);launch picker 非目标 → Global Constraints 显式禁止。测试要求 → 两任务各带 TDD 循环。✅ 无缺口。
307
+ - **Placeholder scan**:无 TBD/TODO;每个代码步骤含完整可运行代码。✅
308
+ - **Type consistency**:`orderedIds` / `selectedId` / `peekId` / `mode` / `handleInput(data: string)` / `dispose()` 名称与 `src/ui/dashboard.ts` 实际成员一致;probe 的 JSON 形状 `{ ids, seq }` 在两个任务间一致(Task 2 只扩 seq 长度)。✅
@@ -0,0 +1,37 @@
1
+ # Plan: fix post-exit timing test failure (issue #46)
2
+
3
+ Branch: `issue-46-post-exit-timing` · Worktree: `.pi/worktrees/issue-46-post-exit-timing`
4
+ Spec: `docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md`
5
+
6
+ ## Task 1 — persist() order in `runner/job-runner.mjs`
7
+ Move `updateCodeRefsFromEvidence(root, viewId, evidence, meta)` to **after**
8
+ `writeState(...)` inside the `persist()` closure (line ~100-108).
9
+ - Verify: `git diff` shows only the reordering in `persist()`.
10
+
11
+ ## Task 2 — manual-completion guards in the exit chain
12
+ 1. `applyHeuristicAutoState`: skip when state.json shows a manual completion
13
+ (`isManualCompletion(readState(...))` early-return before classification).
14
+ 2. Heuristic branch `persist(true)` → `persistUnlessManual(true)`.
15
+ 3. `drainQueuedFollowUp` and `finalizeSteeringIfNeeded`: early-return on
16
+ `isManualCompletion(readState(...))` (no follow-up run / no plan
17
+ resurrection over a manually completed row).
18
+ - Verify: `git diff` shows only these four call-site changes.
19
+
20
+ ## Task 3 — targeted test verification (Node 24)
21
+ ```bash
22
+ node --test --test-name-pattern "clobber a manual completion" test/runner.integration.test.mjs
23
+ ```
24
+ - Run 3×: all pass on the assertion part (364/377).
25
+ - Windows EPERM in the cleanup `finally` is acceptable (pre-existing env noise).
26
+
27
+ ## Task 4 — full test file + typecheck
28
+ ```bash
29
+ node --test test/runner.integration.test.mjs
30
+ npm run typecheck # if script exists
31
+ ```
32
+ - Failure set must not be worse than baseline (before fix: clobber + stopping-the-runner fail).
33
+
34
+ ## Task 5 — commit & summary
35
+ - Commit: `fix: prevent post-exit auto-state from clobbering manual completion (issue #46)`
36
+ - Note: the `stopping the runner finalizes the run as stopped` failure is
37
+ pre-existing (fails on `23c7c46` too) and out of scope.