Plan: suggestions/logs-plan.md
Branch: plan/tier-5 (Tier 4 merged to main as 2a07516)
Baseline: bun test — 133 pass, 0 fail (2026-09-11). After Tier 1: 144. After Tier 3: 190. Tier 4 baseline: 190 pass, 0 fail (2026-09-12); 227 after F-33. Tier 5 baseline: 227 pass, 0 fail (2026-09-14); 260 after F-40. No linter.
Precondition: landed uncommitted fonts, fixture scrub, project links, and worktree pin copying as 91f5dc7.
| ID | Title | Status | Notes |
|---|---|---|---|
| F-01 | Reject cross-origin and DNS-rebinding requests to the API | done | Gate in handle before route. Tray already sends application/json. Page and smoke now send JSON on DELETE too. Smoke not re-run: real board on :4242. |
| F-02 | Fix the edit sheets losing their id | done | openSheet now hides without resetting edit ids. Browser: Edit dashboard kept editingId=dashboard-3001 and Save PUT succeeded; New project had null id; Edit project kept coreagentshub; close then Add/New project did not leak an id. |
| F-03 | Add a LICENSE file | done | MIT, copyright AbbassBaz 2026. package.json license field set. README License section names the OFL 1.1 fonts. |
| F-04 | Track the processes devboard spawns so status is real | done | Tracked in state.json. Unmatched live pid → starting; exit codes and crash.gaveUp on the row. GET /api/services no longer ticks crashes or writes projects. 152 pass. |
| F-05 | Contain log ids to the log directory | done | isValidLogId + resolved-path check. GET/DELETE ..%2F..%2Foutside is 400; fixture unchanged. |
| F-06 | Make the tray build optional in install |
done | No-swift PATH prints the skip line and exits 0 after the symlink. tray:build script added. |
| F-07 | CI on a macOS runner, with a smoke script that cannot touch a real board | done | macos-14 tests + macos-15 tray (Swift 6 skip if missing). Smoke waits for a discovered row after :3999 listens so the F-15 3s snapshot cannot hide it. Busy :4242 still aborts before any API call. |
| F-08 | Rewrite the README for a stranger | done | Pitch, screenshot from throwaway DEVBOARD_HOME, Requirements, Quickstart, env table, Why, Security, Known issues. Page spec moved into design.md. |
| F-09 | Remove or scrub private names in docs/superpowers/ |
done | Deleted the folder. rg for private names is clean outside suggestions/. |
| F-10 | Rotate logs while a server runs, not only at start | done | rotateRunning copies last 2 MB to .log.1 and ftruncates the live file. 3s loop calls it. Cap only while the board runs. |
| F-11 | Compute error counts on the server with one classifier | done | errorCount from classifyLine on last 4000 lines, cached by size+mtime. Page dropped RE_ERR/hydrateLogs. Logs API returns levels. |
| F-12 | Adopt a running row when the port moved | done | Fallback in matchPinned when cwd + port-stripped command is unique. Saved port is not rewritten. |
| F-13 | Make CLI stop-all pin unsaved rows first, like the page |
done | Pins unpinned running dev rows before kill; per-row errors print and continue. |
| F-14 | Fix devboard logs -f stalling after 200 lines |
done | GET /api/logs/:id?from= returns new lines + next + reset. CLI follows by byte offset and prints --- log reset --- after clear. |
| F-15 | One server scan loop with a cached snapshot | done | 3s loop writes the snapshot; GET reuses it for cacheMs. Mutations invalidate. Tests keep cacheMs 0. |
| F-16 | Show readiness on the page | done | Unhealthy running rows use the error-token dot, health <status> · <ms>ms in meta, and N unhealthy in the top bar. Browser-checked on a 500 health URL. |
| F-17 | Mask secret-looking env values and gate /api/env |
done | maskEnv on /api/env and /api/services. Unrelated pid is 404. ?reveal=1 and GET /api/pinned/:id return real values. Home 0700, files 0600. Start still gets the real overrides. |
| F-18 | Validate hand-edited registry JSON and serialize writes | done | Skip bad entries and log one line. GET leaves a { "nope": true } file untouched. Per-path write queue + unique tmp names; 20 concurrent adds keep all 20. |
| F-19 | Reconcile AGENTS.md with the new direction and add CONTRIBUTING.md | done | Loopback-only wording; design.md is the current spec and can be amended in-PR. CONTRIBUTING.md covers the four gotchas. |
| F-20 | Refuse to retire a worktree while a server runs in it | done | Retire and orphan-remove 409 with names while running or starting under the path. Toast offers Stop and retire. Main-worktree protection unchanged. Browser: starting wt-sleep in a throwaway worktree blocked Retire; Stop and retire killed it and removed the checkout. |
| F-21 | Give saved services an identity that survives name collisions | done | add suffixes on id collision when cwd differs. replace keeps the id. Same cwd+port pin replaces in place. Existing services.json loads unchanged. |
| F-22 | CLI parity, --json, and a doctor command |
done | add/rm/pin/open/ls --json/doctor. Smoke step [4] pins via CLI; step [9] round-trips add/ls/open/rm. Non-GET CLI calls always send JSON. |
| F-23 | Shareable pin template checked into a project | done | devboard.json at a project root. Import via + Add, worktree Import pins, Launch, and project Add from folder. Idempotent; never writes live status back. |
| F-24 | Point the tray at the same board as the CLI, and read the version from package.json | done | Tray reads DevboardURL from Info.plist. build-tray.sh writes it from DEVBOARD_URL/PORT and the version from package.json. Rebuild required for a new URL. |
| F-25 | Ask before restarting an unmanaged row with a lossy command | done | commandLooksLossy on rebuilt ps commands. Unpinned POST /api/restart by rootPid is 409 unless confirm. Page opens Edit; Save pins and restarts. |
| F-26 | Allow editing a preset | done | PUT /api/presets/:id keeps the id. Edit in the preset sheet; unknown id is 404. |
| F-27 | Stop running du on every worktree and Attention scan |
done | diskMb cached by .git mtime, 10 min TTL. Attention passes disk: false. Card shows … until size arrives. |
| F-28 | Grow the tray: per-service actions and notifications | done | Per-row menu: Open, Restart, Stop, Copy run command, Logs (?sel=). One notification when unhealthy or crash.gaveUp flips. First poll is primed so existing alerts do not fire. |
| F-29 | Trace one request across several services' logs | done | findIds + GET /api/trace. Clickable ids in the log pane open a Trace view grouped by service. JSON requestId and kin become the token. No time-window fallback. |
| F-30 | Strip every terminal control sequence and resolve carriage returns | done | cleanLine runs CSI, OSC, and single-escape patterns, resolves \r to the last frame, then drops any remaining C0 byte except tab — that last sweep is beyond the plan text but is what makes the "no byte below 0x20" check true. Fixture holds real bytes. decision 1: recommendation used. Smoke could not run: the owner's board holds :4242, so it aborts with "abort: 4242 or 3999 already has a listener; refuse to drive a real board". |
| F-31 | Parse each line once on the server into LogEntry |
done | parseLine and its seven parts in lib/logs.ts, types in lib/types.ts, entries from /api/logs/:id and /api/trace, ERROR_LINE gone from Attention, ten identifiers deleted from app.js. A tagged level wins over the HTTP status, as the plan words it. $ and > lines are no longer dim — the page has no regex for them and entry.marker is the honest signal; design.md says so. decision 3: recommendation used. Smoke aborted as above. |
| F-32 | Fetch the selected log incrementally and append to the DOM | done | logs[id] is {entries, next, base}; ?from= every 1 s, append-only DOM, 10 000 cap with a front drop. Line keys are absolute so a trim never rebuilds: the first attempt rebuilt 10 000 lines (192 ms) every second at the cap and ↓ N new never counted. Idle poll measured at 96 B with curl (no DevTools in this environment). decision 4: recommendation used. Smoke aborted as above. |
| F-33 | Put the page's pure log logic in public/log-view.js and test it with bun test |
done | Eight pure functions, 10 tests, app.js is a module. Two deviations: server.ts had to serve the new file (not in Touches; the page 404'd without it), and the "pure functions move" rule was applied to log-pane logic, not to sidebar or HTML helpers. Smoke aborted as above. |
| F-34 | Level badge gutter and structured line layout | done | 3-character badge in the gutter, warn and info text back to #c3c8d1, error tint kept. logger is a button that searches for it, ctx folds behind {…} with ⌥click for the whole pane, request lines colour method/path/status/duration. Eight new pure functions; linkIds became richText over merged spans so ids keep priority. Open contexts are keyed by base + i, and toggling one patches its node instead of repainting — a repaint would throw away the reading position. Two sets, not one: a head can have both a context and (after F-35) a tail. decision 2: recommendation used. {"pid": 72519} still stays in the message: parseStructured folds only a trailing object of 20 characters or more (F-31, left alone). Smoke aborted as above. |
| F-35 | Fold continuation lines under their parent and collapse repeats | done | foldEntries + collapseRepeats + visibleGroups; the body renders groups, not lines, and errorIndexes returns heads only. Two judgment calls, both commented in the code: the repeat key ignores the folded ctx when the entry has a msg, or the nine livekit plugin registered lines (which differ only inside their JSON) would never collapse and the Done-when could not be met; and only tail-free groups collapse, so a repeated crash dump never silently loses the frames of the copy it replaces. The append path re-renders the last group and appends the rest, which is why a group carries the start and end it covers. Browser: the emitter's dump reads as one head with ▶ +8 lines (eight frames, not the nine of the owner's docs-3010.log, which was not read), exactly one crash open at a time, e lands only on heads, nine near-identical lines collapsed to ×9. Smoke aborted as above. |
| F-36 | The toolbar: search, levels, errors, this run, freeze, clear | done | The contract's row in its order; logFilter, followBtn, traceClose, errOnly, and follow are gone (grep is 0 in both files). levels, runOnly, wrap, showTs persist in localStorage["devboard.logView"]; frozen/held/viewStart replace follow. Contract over item text in two places: viewStart is an absolute line key, not the buffer position the item names, because the buffer trims at 10 000 (Tier 4's rule); and while Trace is open the search field and Levels hide with the This run / Freeze / Clear group, because they do not filter trace hits — the contract only requires that group to become one Close button. Browser at 1280×800 with the sidebar at 380: .log-tools is 39px (38px of content plus the 1px border), one line, ending at x=1264. Smoke aborted as above. |
| F-37 | Search: matching, highlighting, stepping, syntax | done | parseFilter / matches / matchSpans / matchIndexes, memoised by compileFilter. A token whose flags are not real regex flags is a plain term, so /_next/static searches for that path instead of failing as a regex — F-40's own example needs it. <mark> nests inside id and request spans rather than being dropped, so searching POST highlights the method. Browser: POST left 222 lines all marked and read 1/222, ⊘ restored 2674 with the same highlights, /(/ reddened the field and showed nothing, Enter / ⇧Enter / n / N stepped, and a hit inside a folded tail opened its group. Smoke aborted as above. |
| F-38 | Run dividers and the unread divider | done | Markers render as dividers, not lines; run N · time · command · 2m ago. runBoundaries arrived early in F-36 (This run needed it) and is tested here. "Remove it when the tail is reached" is read as reaching the tail deliberately (▶ Live, G, or the Freeze button while frozen): selection change already scrolls a live view to the tail, so clearing it there would delete the divider in the frame it appeared. A full window reload also drops it, because that renumbers from zero. Smoke aborted as above. |
| F-39 | Open URLs and file:line:col from a log line |
done | findLinks marks URLs and file references, skipping node:, webpack-internal:, internal/, and node_modules on the whole token. openInEditor(path, line?, col?) uses -g for cursor and code, file:line:col for subl; POST /api/open takes { path, line?, col?, cwd? } and is 400 (not 500) for a resolved path that does not exist. Deviation: the "resolves under cwd" half of the Done-when is tested on resolveOpenPath in test/worktrees.test.ts, because driving the route with a path that exists spawns a real editor on whatever machine runs bun test; the browser check drove the whole route with a stand-in cursor on PATH that recorded cursor -g /tmp/devboard-tier5/src/app/page.tsx:12:5. Smoke aborted as above. |
| F-40 | Hide rules from the search field | done | Hide these in the search field saves a rule per service in localStorage["devboard.logHide"]; the N hidden chip toggles them and Forget hide rules in ··· drops them. N hidden counts the rules, not the lines: the item's own check (one rule over nine lines reading 1 hidden) only holds that way, and the chip's title spells out the rules. Rules reuse the search syntax, so F-37's "impossible flags means a term" rule is what makes /_next/static work. Browser: hide, reload, toggle back dimmed at 0.45, forget — all as written; with two rules and the error chip the row is still one line ending at x=1264. Smoke aborted as above. |
All doable Tier 1 items are done. No blocked items. Nothing waiting on you for Tier 1.
Continue to Tier 2 only when you say go: F-04, F-10, F-11, F-12, F-15, F-16, F-17, F-18, F-20.
All doable Tier 2 items are done (F-04, F-10, F-11, F-12, F-15, F-16, F-17, F-18, F-20). No blocked items.
Continue to Tier 3 only when you say go: F-21, F-22, F-23, F-24, F-25, F-26, F-27, F-28, F-29.
All four Tier 4 items are done: F-30, F-31, F-32, F-33. Nothing is blocked and nothing is waiting on you. Decisions 1, 3, and 4 were open, so the recommended option was used in each case and is named in the row above; to reverse one, the item that applies it is the only place to change. Decision 2 (badge gutter) belongs to F-34 and is still yours.
bash scripts/smoke.sh could not run at any point: the real board holds :4242, so the
script aborts by design with "abort: 4242 or 3999 already has a listener; refuse to
drive a real board". Everything else was checked, including browser work on a throwaway
board on :4342 with its own DEVBOARD_HOME.
Continue to Tier 5 only when you say go: F-34, F-35, F-36, F-37, F-38, F-39, F-40.
All seven Tier 5 items are done: F-34, F-35, F-36, F-37, F-38, F-39, F-40. Nothing is blocked and nothing is waiting on you. Decision 2 was open, so the recommended option was used — the level badge gutter, text back to the foreground colour, the error tint kept — and it is named in F-34's row; that item is the only place to change to reverse it. Decisions 1, 3, and 4 were applied in Tier 4.
Three Done-when checks were met a different way, each named in its row and in the PR:
viewStart is an absolute line key rather than a buffer position (F-36); the "resolves
under cwd" half of F-39 is tested on resolveOpenPath rather than through the route,
because the route would spawn a real editor during bun test; and N hidden counts hide
rules rather than lines (F-40). The toolbar contract overrode the item text twice, both in
F-36's row.
bash scripts/smoke.sh was run once and could not proceed: the owner's board holds :4242,
so it aborts by design with "abort: 4242 or 3999 already has a listener; refuse to drive a
real board". Everything else was checked, including browser work at 1280×800 on a throwaway
board on :4342 with its own DEVBOARD_HOME and a pin printing a line every 50 ms across all
five levels, plus a Node crash dump, a Python time - LEVEL logger - msg {json} line, a
pino JSON line, and Next request lines.
After merging, restart the board on :4242. It serves public/ from disk but holds its
routes in memory, and /api/open changed in F-39.
Continue to Tier 6 only when you say go: F-41, F-42, F-43, F-44.
suggestions/is untracked and not in.gitignore. Left untracked; not part of the product.- Existing board on
:4242blocked a full localscripts/smoke.shrun. The abort-if-busy path was verified instead. bun run setupwith Swift present was run once by mistake while testing F-06 (replaced the existing tray app). The no-Swift skip path was then verified separately.- AGENTS.md test count was 133; suite is 144 after Tier 1. Updated in F-19.
- No browser test harness (plan Gaps). F-02 was verified by hand on the live board.
- Throwaway screenshot board used
PORT=4342so it would not touch the real board. - Tier 4:
public/cannot importlib/, solog-view.jsandlib/logs.tswill hold two copies ofparseFilter/matcheswhen F-44 lands. The plan names this and asks for a shared fixture test. - Tier 4: the log window is the last 4 000 lines, so displayed line numbers start at 1 for whatever the window begins with, not at the file's own first line. They stay monotonic within a session, which is what F-32 asks for.
- Tier 4:
app.jsis an ES module now, so its top-level bindings are not reachable by name from the devtools console. Debug through the DOM or export fromlog-view.js. - Tier 5: the throwaway board keeps its routes in memory, so a server change (F-39) needs it restarted before a browser check can see it.
public/is read from disk per request and does not. - Tier 5: F-44 will want
parseFilterinlib/logs.tstoo. The page's version treats a/…/token whose flags are not real regex flags as a plain term (so/_next/staticis a path, not a broken regex); the shared fixture test the plan asks for has to cover that, or the two copies will disagree. - Tier 5: nothing in the pane hides behind a right-click or a shortcut alone. If Tier 6 adds an action, the contract says it needs a visible control on the row or an item in the
···menu. - Tier 5: the level badge occupies a 3ch gutter column on every line, and
richTextmerges id, link, and request spans with<mark>nested inside. F-41's diagnosis card and F-42's problems menu should reusevisibleGroupsanderrorKeysrather than re-deriving heads.