ho-04.4 — The ingest dashboard
- Ho-04.4-AT-01.md
- Ho-04.4-AT-02.md
- Ho-04.4-AT-03.md
- ho-04.2 Decision 1 (per-key arrow walk)
sharibako init shows every secret it finds on one screen and lets the practitioner route all of them from that single view — set each key's fate, bulk-apply to a marked set or the whole list, then commit once. This replaces the per-key arrow walk from ho-04.2, which read fine at three keys and fell apart at twelve.
This is motion off ho-04.2's . That dogfooded the per-key walk against a real twelve-key .env and found the model structurally wrong at scale: each answered key's menu stayed stacked on screen, move-to-shared recorded a corrupted slug, and there was no bulk action for a .env that size. This ho ho-04.2 Decision 1 — the per-key sequential walk — and replaces it with the one-page dashboard. ho-04.2 is not edited; it stands as the record of what was known then. This ho is the response to what the surfaced.
Replacing the prompt wholesale also retires two bugs ho-04.2's Reflect documented as known-in-main, rather than patching a surface that's being thrown out:
- the frame-stacking (
InteractiveSelect.renderFramenever cleared its frame on exit) - the
move-to-sharedslug corruption (a barereadLine()after raw mode, stray arrow bytes echoing into the slug)
Both die as a byproduct of the dashboard owning its screen and its input.
Out of scope:
- Any change to
Materializer.ingest/acceptIngest,KeyDecision, orProposedScope— the ho-03 library surface is consumed as-is, exactly as ho-04.2 consumed it. - The
IngestDecisionSourceprotocol andScriptedIngestDecisionSource— kept unchanged; the dashboard is a new implementation of the same protocol (Decision 7). - Non-interactive
init(--scope/--import-all/--yes) — still deferred (ho-04.2 Decision 6). The dashboard is the interactive surface; the scriptable path lands later as anotherIngestDecisionSourceimplementation feeding the sameacceptIngest. InteractiveSelectitself — it stays as the single-question widget for the yes/no and scope-type prompts around ingest. It is not deleted and not extended into a table (Decision 5).- The GUI ingest matrix — ho-06. The dashboard is the terminal surface; the SwiftUI four-choice flow is that ho's version, and the dashboard's one-page model is the closer analog for it than the per-key walk was.
.envdeletion or rewrite after ingest — unchanged from ho-04.2 Decision 4; the source.envis left byte-for-byte untouched.
What carries forward unchanged from ho-04.2: first-run key generation (Decision 5), Touch-ID-free ingest (Decision 2), scope-ID collision reuse (Decision 3), re-init reconcile (Decision 7), source-.env untouched (Decision 4). Those decisions held under the dogfood. InitCommand keeps that scaffolding; only the decision-collection call changes from the per-key prompt to the dashboard.
Phase 1 — Think
The interaction model was resolved in conversation with the practitioner. What follows records the decisions.
Decision 1 — One page: every key a row, navigate and set in place
init renders all detected keys as a vertical list, one row per key, and the practitioner routes each from that single view. This supersedes ho-04.2 Decision 1's per-key sequential prompt.
Each row shows the key name and its current choice — one of import / link / move / leave / skip — rendered as a colour change plus a ✓, not a reverse-video block (the reverse-video block read as heavy at twelve rows). The active row is marked distinctly (a cursor glyph and/or highlight). Rows are spaced for legibility rather than packed.
Navigation:
- ↑/↓ move the active row.
- ←/→ cycle the active row's choice through the available actions in place. The choice updates live as colour +
✓. - space toggles a bulk-mark on the active row (see Decision 2).
- Enter commits the whole screen (Decision 6).
- q / Esc cancels (Decision 6).
Initial state honours the Materializer's suggestions: a key with nameMatchedSharedID set starts on link with its matched entry pre-resolved and named in the row; every other key starts on import (the dominant real choice). So the first frame already reflects the sensible default and the common case is often "glance, commit."
suggestedKeysNeedingValues and parseWarnings are context, not decidable rows — they render as a read-only banner (keys that appeared only in .env.example with no value; any parse warnings the ingest collected), placed above or below the table. They inform; they are never navigated.
Decision 2 — Bulk: space marks, a applies to the marked set or the whole list
A large .env wants one motion that sets many keys at once. The model layers a lightweight selection onto Decision 1's per-row control:
- space marks/unmarks the active row. Marked rows carry a visible marker.
- a invokes ALL: choose one action, and it applies to every marked row — or, when nothing is marked, to every row. This is the practitioner's stated semantic: no marks means "bulk them all," marks means "bulk these."
ALL offers the target-free actions only — import / leave / skip. link and move are excluded from bulk because each needs its own target: link needs a specific shared entry, move needs a distinct new slug per key. Bulking many keys onto one shared target is incoherent, so those two stay per-row (←/→ plus their secondary input, Decision 4). This keeps ALL fast and unambiguous — the overwhelmingly common bulk is "import everything," which ALL does in two keystrokes (a, pick import).
ALL overwrites: applying it re-sets the affected rows regardless of their current choice, including name-matched rows. That is deliberate — ALL is invoked explicitly, so overwriting a default is a chosen act, not a stumble.
Decision 3 — Alternate screen buffer, cursor-addressed redraw
The dashboard runs on the terminal's alternate screen (smcup/rmcup — the mode vim, less, and htop use): on entry it switches to a throwaway full screen, on exit it restores the prior screen and the practitioner's scrollback is untouched. This makes the frame-stacking failure structurally impossible — the dashboard cannot pollute scrollback because it never draws into it.
Within the alternate screen the table is redrawn cursor-addressed (position, clear, paint) on each keystroke. Restore is SIGINT-safe: the module-level saved-termios + signal-handler pattern already in InteractiveSelect extends to also leave the alternate screen on Ctrl-C, so an interrupt returns a cooked terminal on the original screen, never a mangled one.
The renderer honours the project's colour discipline — TerminalDetector/OutputRenderer conventions, degrading to plain markers when colour is off.
Decision 4 — Secondary inputs collected inside the dashboard
link and move each need a second input, and the old code collected them wrong — it dropped out of raw mode to a bare readLine() with stray arrow bytes still buffered, which is how move-to-shared recorded a corrupted slug. The dashboard collects both inside its own render loop, owning the input the whole time:
- link → shared-entry pick. Selecting link on a row opens an in-dashboard sub-list of the available shared IDs (from
vaultCore.listShared()), navigated the same way, defaulting tonameMatchedSharedIDwhen present. The resolved entry shows in the row. - move → new-slug entry. Selecting move opens an in-dashboard single-line text field the widget drives in raw mode — printable bytes append, backspace deletes, Enter confirms, Esc cancels back to the prior choice. The entered slug is sanitized with the same rules the scope ID uses. No
readLine(), no stdin left mid-line, no leaked escape bytes.
Owning input end-to-end is what retires the corruption bug — it is a construction property of the dashboard, not a patch.
Decision 5 — A new widget; InteractiveSelect kept, InteractiveIngestPrompt retired
The dashboard is a new full-screen widget, IngestDashboard, not an extension of InteractiveSelect. InteractiveSelect answers one question with a single-select list; the dashboard holds persistent multi-row state on an alternate screen and collects secondary inputs. Growing one into the other would overload a small, working widget with a mode flag and two behaviours. InteractiveSelect stays exactly as it is, still used for the first-run "Generate a key? [Y/n]" and scope-type prompts that bracket ingest.
The per-key TTY source, InteractiveIngestPrompt, is superseded — its whole job was the per-key walk. init routes through a new IngestDecisionSource implementation driven by the dashboard, and InteractiveIngestPrompt (with its buggy resolveMove/resolveLink) is removed. The IngestDecisionSource protocol and ScriptedIngestDecisionSource are untouched.
This decision draws the decomposition seam (Phase 2): the reusable dashboard widget and its decision source are one task; wiring init to it and retiring the old prompt is the next.
Decision 6 — Navigate and set freely, commit once, cancel cleanly
Nothing is written until the practitioner commits. Enter commits the whole screen: it collapses the dashboard back to the normal terminal, prints a one-line summary of what's about to happen (counts per action), asks a single confirmation, and on yes calls acceptIngest exactly once with the full [KeyDecision] set. q/Esc cancels — restores the terminal, writes nothing, and init exits as a clean cancel (no scope, no marker). There are no partial writes and no mid-walk commits; the whole routing is one atomic decision handed to acceptIngest, the same call ho-04.2 already proved out.
Decision 7 — The IngestDecisionSource seam holds
The dashboard is a third implementation of IngestDecisionSource.decisions(for:sharedIDs:), alongside the scripted double and the (now-retired) per-key prompt. acceptIngest, KeyDecision, and ProposedScope are unchanged; the protocol signature is unchanged; ScriptedIngestDecisionSource still drives InitCommand's end-to-end tests through every decision branch without a terminal. Non-TTY input still refuses via CLIError.notInteractiveTerminal before any raw read — the dashboard checks isInteractive() on entry exactly as InteractiveSelect does. The raw-mode renderer (byte reads, alternate-screen escapes, termios) is coverage-excluded with a comment; the decision state machine — navigation, cycle, mark, ALL application, secondary-input resolution, and the final map to [KeyDecision] — is driven through injected readByte/write and fully covered.
Deferred to execution
- Exact on-screen layout: banner placement (top vs bottom), the key legend/footer, column alignment, and the active-row marker glyph — feel decisions tuned against a real terminal.
- Whether ALL's action pick is a small
InteractiveSelectoverlay or an inline cycle on a dedicated line — either is fine; the agent picks the cleaner construction. - The precise line-editing affordances of the move slug field beyond append/backspace/Enter/Esc (e.g. whether left/right editing within the field is worth it) — start minimal.
- Internal division of labour between
IngestDashboard(the widget) and theIngestDecisionSourceimplementation that wraps it — thin-composition vs the widget returning richer results — the agent's call, provided the injected-byte state machine stays testable and the protocol seam stays intact. - Whether the dashboard shares any small rendering helper with
InteractiveSelect(cursor moves, clear-line) or duplicates the few escapes it needs — factor only if it's genuinely shared, don't force it.
Phase 2 — Execute
Two agent tasks, the same two- rhythm as ho-04.2: the reusable full-screen machinery first, verifiable in isolation, then the command that drives it and the retirement of the old prompt.
Ho-04.4-AT-01 — IngestDashboard widget + dashboard decision source
The IngestDashboard full-screen widget — alternate-screen raw-mode table, ↑/↓ navigation, ←/→ per-row cycle, space-mark, a ALL over the marked-or-all set, in-dashboard link pick and move slug field, SIGINT-safe restore, non-TTY refusal — and the IngestDecisionSource implementation that builds it from a ProposedScope and maps the result to [KeyDecision]. Pure terminal-UX infrastructure with its own acceptance criteria, testable through injected readByte/write before InitCommand is rewired. The raw-mode renderer is coverage-excluded; the state machine carries the coverage.
→ /agent-tasks/Ho-04.4-AT-01.md
Ho-04.4-AT-02 — Rewire InitCommand, retire InteractiveIngestPrompt
Point InitCommand at the dashboard decision source in place of InteractiveIngestPrompt, remove InteractiveIngestPrompt and its tests, and keep every other part of the init flow (first-run key generation, scope-ID/type prompts, reconcile, reporting) intact. End-to-end tests keep running through ScriptedIngestDecisionSource. Depends on AT-01.
→ ho-process/agent-tasks/Ho-04.4-AT-02.md
Ho-04.4-AT-03 — Dogfood corrections: viewport, cycle-without-trap, legibility
The first manual dogfood (twelve-key scratch .env, real terminal) landed the dashboard but found it unusable as built: content overflowed a short terminal with no scroll, cycling ←/→ onto move or link dropped the user into a modal input and trapped them there, the key column read as a monotone wall, and the footer wording ("commit", "cancel") was unclear. A corrective task inside this still-open ho: a scrolling viewport that keeps the active row visible at any height; cycling that only changes the label with move slug and link target collected at commit in cancelable prompts (and a lone-Esc read timeout so Esc no longer hangs); coloured key names; reworded footer (move / change / select / bulk / save / quit). All inside IngestDashboard; run() still returns [ResolvedChoice], so DashboardIngestPrompt and InitCommand are untouched.
→ ho-process/agent-tasks/Ho-04.4-AT-03.md
Testing and iteration approach
AT-01 lands and tests green before AT-02 opens — the dashboard's state machine and secondary-input collection are verifiable through a scripted readByte sequence and captured write frames, no init command required. AT-02 rewires init and is tested end-to-end through ScriptedIngestDecisionSource returning a mix of all five decisions, asserting the vault and marker states acceptIngest produces — the same assertions ho-04.2's InitCommand tests already make, since only the decision source changed. The alternate-screen renderer is coverage-excluded with a comment; every decision branch is covered through the interface. Coverage floor stays ≥ 85% on the new CLI code with the raw-mode renderer honestly excluded, consistent with ho-04 and ho-04.2.
One manual dogfood pass after AT-02: init against the same real multi-key .env that broke the per-key walk, driving the dashboard on a real terminal — set several keys, mark a subset and bulk-import, route one key to move (confirming the slug is now clean), commit once, and confirm the vault, the marker, and an untouched source .env. This is where "one page, not a slog" gets its only real test; the automated suite verifies logic, not feel.
Done means
sharibako initin a directory with a.envshows all detected keys on one screen; ↑/↓ moves, ←/→ sets each key's choice as colour +✓, and the choice is legible at a dozen-plus rows without stacking- space marks rows and
aapplies a chosen target-free action (import/leave/skip) to the marked set, or to all rows when none are marked - name-matched keys start on link with the matched entry named; every other key starts on import
- link opens an in-dashboard shared-entry pick; move opens an in-dashboard slug field that records a clean, sanitized slug (the ho-04.2 corruption is gone)
- Enter commits the whole screen through a single
acceptIngest; q/Esc cancels, restores the terminal, and writes nothing - the dashboard runs on the alternate screen and leaves scrollback untouched on exit and on Ctrl-C
InteractiveIngestPromptis removed;InitCommandroutes through the dashboard source;InteractiveSelect, theIngestDecisionSourceprotocol,ScriptedIngestDecisionSource, andSharibakoCoreare unchanged- non-TTY invocation refuses via
notInteractiveTerminalrather than hanging - every decision branch is covered through the scripted source; CI clean, both linters clean, coverage ≥ 85% with the raw-mode renderer excluded by comment
Phase 3 — Reflect
The dashboard was built, dogfooded, found unusable, and superseded before it became init's real surface. The and the lesson:
What was built
AT-01 landed the IngestDashboard widget and DashboardIngestPrompt source — 405 tests, both linters clean (19887bf). AT-02 rewired InitCommand to it and retired InteractiveIngestPrompt (0d6c536). The IngestDecisionSource seam held exactly as designed: InitCommand's end-to-end tests never changed when the live source swapped.
What the dogfood found
Driven on a real terminal against a twelve-key .env, the dashboard did not work:
- No viewport. On a terminal shorter than the content, the overflow rows were unreachable — the alternate screen has no scrollback.
- Cycling
←/→onto move or link opened a modal input and trapped the user, with no clean exit — lone Esc hangs waiting for a second byte to disambiguate it from an arrow. - The key column read as a monotone wall; the footer wording ("commit", "cancel") was unclear.
405 passing tests and it trapped the practitioner on the first keystroke. The tests verified the state-machine logic, which was correct. They could not verify feel, which was wrong.
Where the correction went
AT-03 was authored to fix all four (viewport, cycle-without-trap with at-commit collection, an Esc read-timeout, legibility). Its agent could not complete — the response exceeded the 32k output-token cap. The task was too large to produce in one pass, which is itself the evidence.
The lesson — the machine was wrong, not the details
Three signals converged: the third build of the same surface, a fully-tested implementation that was unusable on contact, and a correction too large to complete. The honest read: a -rolled, full-screen, scrolling, raw-mode TUI is the wrong amount of machine for a command run once per project. It is ~600 lines of termios to make a terminal imitate a spatial one-page table — something the GUI (ho-06) gets for free from SwiftUI (a List of Pickers). The framing that the dashboard was "the closer analog" to the GUI (carried from ho-04.2's Reflect) was backwards: it pushed the CLI to mirror the GUI's , which is exactly what over-built it. Each surface should be idiomatic to its medium and meet at the library seam — Materializer.ingest / acceptIngest / KeyDecision — not at the UX metaphor.
The process miss underneath: we polished before validating. A dogfood should hit a rough version, not a finished 600-line one. The loop was inverted — build fully, discover it's wrong, build again.
What this hands forward
- ho-04.6 (new) — plain-prompt init. Print the detected keys, "import all? [Y/n]" for the dominant case, a short per-key prompt for the exceptions. Plain
readLine, no raw mode, testable through the injected line-reader. It supersedes this ho's dashboard and replacesDashboardIngestPromptas theIngestDecisionSource; the dashboard files (IngestDashboard,DashboardIngestPrompt) are removed there. The seam is unchanged — the plain prompt is another implementation ofdecisions(for:sharedIDs:). - The dashboard code stays in history (AT-01
19887bf, AT-020d6c536); it is simply not the surfaceinitships. AT-03 is authored but not executed. - Methodology: dogfood rough, then harden. Applied to ho-04.6 directly.
Authored: 2026-07-02 (Think ).
Execution: AT-01 (19887bf), AT-02 (0d6c536); AT-03 authored, not completed — superseded.
Reflect: complete 2026-07-02 — dashboard superseded by ho-04.6 (plain prompt).
Rendered from the corpus, verbatim · source on GitHub →