50 Commits

Author SHA1 Message Date
adlee-was-taken
7c7efa7c43 release: v0.7.0 — extension restructure complete (Plan C Phases 3/4/6)
Completes the extension restructure begun in v0.6.0. Phases 3 (setup
wizard SW migration + step registry), 4 (vault.ts split + vault_locked
lift), and 6 (get_vault_status + sidebar status indicator) all merged to
main (9df2fee, 3b8368d, 397cc78) via three parallel worktree streams.

This commit is the release-prep wrap-up:
- Version bump to v0.7.0 across the three relicario crates + Cargo.lock,
  extension/package.json, and both extension manifests (the manifests had
  lagged at 0.5.0 — corrected here).
- CHANGELOG.md v0.7.0 entry.
- STATUS.md: extension restructure moved to shipped; Phases 3/4/6 landing
  section added.
- ROADMAP.md: v0.7.0 row added; Up-next now command palette.
- extension/ARCHITECTURE.md: all three phases integrated (new vault-*
  modules, setup-steps.ts, get_vault_status protocol + status indicator,
  vault_locked lift, git-host sync cache).
- Plan completion checkboxes ticked.

Task 7.1 verification: done-criteria sweep all green; 423/423 vitest;
build:all clean (only the pre-existing 4MB WASM size warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:50:17 -04:00
adlee-was-taken
397cc78b86 Merge Plan C Phase 6: get_vault_status + sidebar status indicator
Adds the get_vault_status SW handler (returns cached ahead/behind/lastSyncAt
from state.gitHost + a live pendingItems count from the manifest; no network)
and the sidebar-footer status indicator (renderStatusIndicator wired into the
#vault-status-slot, refreshed on mount + a manual button, no timer polling).
Closes the last relicario-status CLI/extension parity gap.

Also nulls state.gitHost on the explicit lock handler (symmetric with the
session-expiry path) so the indicator can't show a stale lastSyncAt after a
lock then re-unlock within one service-worker lifetime.

Tasks 6.1-6.3. 423 vitest green, build:all clean. Completes the extension
restructure (Plan C); all of Phases 3/4/6 now on main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:12:51 -04:00
adlee-was-taken
675452a9ef fix(ext/sw): null gitHost on explicit lock (Plan C Phase 6)
The explicit lock message handler nulled state.manifest but left
state.gitHost (now carrying the cached lastSyncAt) intact, so a lock then
re-unlock within one service-worker lifetime surfaced a stale sync time.
Null gitHost here too — symmetric with the session-expiry path (index.ts)
and completing Plan C Phase 5's don't-leak-git-host-across-a-lock intent;
unlock rebuilds it on demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:11:02 -04:00
adlee-was-taken
f4b4cf3db7 refactor(ext): simplify Phase 6 — alias VaultStatus + reuse listItems
Two simplify-pass cleanups:
- vault-status.ts: VaultStatus is now an alias of GetVaultStatusResponse['data']
  instead of a re-declared 4-field interface, so the renderer's input shape is
  single-sourced from the message contract and can't drift from the SW handler.
- service-worker/vault.ts: handleGetVaultStatus counts active items via the
  existing listItems() helper rather than re-implementing the trashed_at filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:54:42 -04:00
adlee-was-taken
c662db2875 feat(ext/vault): wire vault-status into sidebar footer (Plan C Phase 6)
Renders the status indicator into #vault-status-slot on sidebar mount and on
a manual ↻ button. No timer polling — get_vault_status returns cached state
and sync is user-initiated. Closes the relicario status CLI/extension parity
gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:33:21 -04:00
adlee-was-taken
5efc3a5491 test(ext/vault): handler→renderer status integration + indicator CSS (Plan C Phase 6)
Pins the 6.1↔6.2 contract: handleGetVaultStatus output feeds straight into
renderStatusIndicator. Adds minimal self-contained .vault-status CSS. Stays
out of vault-sidebar.ts — the footer wiring (Task 6.3) is Dev-B's boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:26:48 -04:00
adlee-was-taken
61275574d4 feat(ext/sw): get_vault_status handler + cached sync state (Plan C Phase 6)
Returns cached ahead/behind/lastSyncAt from the GitHost plus a live count of
active (non-trashed) manifest items. No network call — sync is user-initiated;
the sync handler records lastSyncAt (unix seconds). ahead/behind stay 0 in the
extension (writes go straight to the host, no local commit graph) and exist
for parity with relicario status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:26:48 -04:00
adlee-was-taken
3121431a7e feat(ext/vault): vault-status indicator renderer (Plan C Phase 6)
Renders sidebar-footer indicator with ahead/behind/pending state. Pure
DOM; reuses shared/glyphs (four new status glyphs) and shared/relative-time.
Status fetch happens in the wiring layer (Task 6.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:15:50 -04:00
adlee-was-taken
3b8368db3a Merge phase-c-4-vault-split: Plan C Phase 4 (vault.ts split + vault_locked lift)
Splits the 1037-LOC vault.ts monolith into focused modules: vault.ts trims to
194 LOC of routing+state, with vault-shell, vault-sidebar, vault-list,
vault-drawer, vault-form-wrapper extracted, plus two support modules
(vault-context — the VaultController contract + shared helpers; vault-router —
hash routing + pane dispatch, extracted to hit the <=250 LOC target).
Lifts the vault_locked RPC intercept out of vault.ts into shared/state.ts's
sendMessage wrapper. Adds 80ms debounced sidebar search, ensureDrawerClosedForRoute,
and the #vault-status-slot footer that Dev-C wires in Phase 6 Task 6.3.

Tasks 4.1-4.7. vault_locked count in vault.ts == 0. 407 vitest green, build:all
clean. Unblocks Dev-C Task 6.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:33:24 -04:00
adlee-was-taken
0c722b3a9d refactor(ext/state): lift vault_locked intercept into shared/state.ts (Plan C Phase 4)
The session-lost intercept lived in vault.ts's local sendMessage; both surfaces
now consume it through the shared sendMessage() wrapper. On a vault_locked
response to any non-bypassed request, the wrapper calls host.navigate('locked').
The vault host's navigate gains a 'locked' branch (it shows its lock screen off
state.unlocked); the popup's navigate already handles 'locked'. vault.ts routes
ctx.sendMessage through the shared wrapper and registers a plain transport as
host.sendMessage, so internal RPCs keep the intercept without recursion.
grep -c vault_locked vault.ts == 0. New state-vault-locked.test.ts (TDD, 6 cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
31913b8648 refactor(ext/vault): extract vault-router.ts; trim vault.ts to entry point (Plan C Phase 4)
Moves the routing core — parseHash/setHash, the renderPane pane-dispatch +
teardownPaneComponents, loadManifest, and selectItem — out of vault.ts into
vault-router.ts (carrying the popup-component imports with it). vault.ts is now
just the entry point: state singleton, the VaultController assembly, the
StateHost registration, and the DOMContentLoaded bootstrap (1037 -> 203 LOC).
No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
fecf58e54a refactor(ext/vault): extract vault-form-wrapper.ts (Plan C Phase 4)
Moves renderFormWrapped (sticky save bar + header + dirty-state wiring), the
SAVE_HINT/isMac consts, and the __test__ export out of vault.ts into
vault-form-wrapper.ts, taking the VaultController ctx. Repoints the source-text
form-wrapper test to read the new module. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
7f076b49ac refactor(ext/vault): extract vault-drawer.ts + ensureDrawerClosedForRoute (Plan C Phase 4)
Moves the drawer (open/close/render + getDrawerCoreFields + selectItemForDrawer)
out of vault.ts into vault-drawer.ts, taking the VaultController ctx. Adds
ensureDrawerClosedForRoute(state, route) — called in renderPane before the view
switch — so drawer state cannot leak across navigation to non-list/detail
routes (P2 safety net). New drawer-state.test.ts covers it (TDD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
68cada5593 refactor(ext/vault): extract vault-list.ts (Plan C Phase 4)
Moves the list-pane rendering (renderListPane: row markup, empty state, and
row-click → selectItemForDrawer) out of vault.ts into vault-list.ts, taking
the VaultController ctx. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
9049512e0d refactor(ext/vault): extract vault-sidebar.ts with debounced search (Plan C Phase 4)
Moves the sidebar column out of vault.ts/vault-shell.ts into vault-sidebar.ts:
its markup (now incl. an empty #vault-status-slot footer for Phase 6), the
category nav rendering, nav-button wiring, and search. The search input gains
an 80ms trailing-edge debounce (P2 fix — it re-filtered on every keystroke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
51255b3583 refactor(ext/vault): extract vault-shell.ts + introduce VaultController ctx (Plan C Phase 4)
Introduces vault-context.ts (VaultView/HashRoute/VaultState types, the
VaultController contract, and the pure helpers escapeHtml/typeIcon/typeLabel/
getFilteredEntries). Extracts the shell concerns — render entry, lock screen,
3-column shell scaffolding, type picker panel, color-scheme apply, and the
session_expired listener — into vault-shell.ts. vault.ts now assembles the
ctx object and delegates shell rendering through it. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:26:25 -04:00
adlee-was-taken
9df2fee295 Merge phase-c-3-setup-wizard: Plan C Phase 3 (setup wizard SW migration + step registry)
Moves all setup-wizard crypto orchestration into the service worker via new
create_vault / attach_vault SW handlers (full Option-A flow: embed/unlock,
encrypt+push, register_device+addDevice, persist config+image, session.setCurrent;
failure path locks+frees the handle, ownership transfers only on success).
setup.ts collapses from ~1230 LOC to a 58-LOC UI-only shell + setup-steps.ts
step registry (one-directional import, no cycle, no relicario-wasm import).
clearWizardState bound to beforeunload + goto(mode). Copy-vault-JSON escape
hatch preserved; redundant register-device button dropped.

Tasks 3.1-3.7. 397/397 vitest green; build:all clean. Unblocks nothing
directly (Phase 6 SW handler is Dev-C) but completes the setup-wizard cliff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:16:38 -04:00
adlee-was-taken
eed48e2bbb fix(ext/sw): type-correct session.setCurrent + simplify create/attach handlers
Fixes a TS2345 that npx tsc --noEmit missed (it cannot resolve the generated
wasm/relicario_wasm types, degrading SessionHandle) but the webpack build
catches with real types: session.setCurrent(handle) was passed a
SessionHandle|null. Capture the unlock result in a non-null `const h:
SessionHandle` for the in-scope ops; `handle` remains the ownership tracker
the finally block cleans up.

Simplify pass: extract the shared register_device + addDevice + persist-config
tail into registerDeviceAndPersistConfig (both handlers ended identically),
hoist the Argon2 params literal to DEFAULT_PARAMS_JSON, and fan out the two
independent read-only GETs in the attach path via Promise.all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:39:48 -04:00
adlee-was-taken
8044310fba test(ext/setup): cover SetupStep registry shape + clearWizardState (Plan C Phase 3)
Asserts STEPS has the six steps in canonical order, each renders non-empty
HTML and returns a teardown from attach, and clearWizardState zero-fills the
reachable Uint8Array fields before resetting state. Keeps the existing
finishSetup tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:11:53 -04:00
adlee-was-taken
d300d62c60 polish(ext/setup): honest vault-step button labels + drop needless export
Both vault-step buttons now read "continue" -- they collect input and advance
to the device step, where the SW actually performs create_vault/attach_vault
(with its own busy spinner). The old "create vault" / "verify and attach"
labels implied the action happened on that click, which is no longer true.
Drops the unused export on vaultConfig().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:09:10 -04:00
adlee-was-taken
bceb44f8af refactor(ext/setup): split step registry into setup-steps.ts; restore copy-config escape hatch
Hits the Task 7.1 <=500 LOC gate for setup.ts by extracting the SetupStep
registry, the WizardState singleton, clearWizardState and finishSetup into a
sibling setup-steps.ts; setup.ts is now a thin shell (progress track + render
loop + boot + re-exports). The import is one-directional (setup -> setup-steps),
no cycle. Also restores the non-extension copy-vault-config-JSON escape hatch on
the done step (per product decision) while keeping the redundant register-device
button dropped (the SW handler registers the device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:25:22 -04:00
adlee-was-taken
9fd5e33cd4 refactor(ext/setup): SW migration + step registry + clearWizardState (Plan C Phase 3)
setup.ts is now UI-only: deletes all direct WASM orchestration (loadWasm,
the wasm binding, verifiedHandle, the SessionHandle import). Vault creation
and attach go through sendMessage({type:'create_vault'|'attach_vault'}) fired
from the device step (where the device name is known); the SW owns the entire
crypto+remote+device flow. The six renderStepN/attachStepN pairs collapse into
the SetupStep registry (mode/host/connection/vault/device/done). The done step
drops the now-redundant register-device + copy-JSON paths, keeping reference
download + recovery QR (off the SW session) + open-vault. clearWizardState
zero-fills sensitive Uint8Array fields on beforeunload and on goto('mode').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:08:23 -04:00
adlee-was-taken
0befd4e629 feat(ext/sw): attach_vault handler (Plan C Phase 3)
Same shape as create_vault: the SW owns the attach flow end to end -- fetch
salt/params/manifest from the remote, unlock with the user's reference image,
manifest_decrypt to verify the passphrase+image, register this device, persist
config + reference image, and transition the SW to the unlocked state. On
failure the handle is locked then freed; ownership transfers to the session
only on success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:34:16 -04:00
adlee-was-taken
e3d29c7d1b Merge phase-c-3 Task 3.1: add create_vault/attach_vault/get_vault_status message types
Standalone fast-track merge of the messages.ts type contract (commit 2cf7496)
ahead of the rest of Phase 3, to unblock Dev-C's Phase 6 Task 6.1. Pure
additive: 3 request types + 3 response interfaces + POPUP_ONLY_TYPES entries,
plus a default case in popup-only.ts router to keep the switch exhaustive
(handlers land in Tasks 3.2/3.3/6.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:27:08 -04:00
adlee-was-taken
0e1e1a722d feat(ext/sw): create_vault handler (Plan C Phase 3)
Lifts the full create-vault flow out of setup.ts into the SW: embed image
secret, unlock, encrypt empty manifest + default settings, push the vault
layout (create-only), register this device + write devices.json, persist
config + reference image locally, and transition the SW to the unlocked
state (handle becomes SW-owned, enabling recoveryQrAvailable). On failure
the handle is locked then freed per Plan A's .free() policy; ownership only
transfers to the session on success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:23:52 -04:00
adlee-was-taken
2cf74968e0 feat(ext/messages): add create_vault, attach_vault, get_vault_status (Plan C Phase 3 prep)
Adds the request shapes + response interfaces. POPUP_ONLY_TYPES set grows
by three. SW handlers in service-worker/vault.ts land in the next tasks.

The new union members would make popup-only.ts's exhaustive handle() switch
non-total (TS2366), so a default case is added returning an explicit
"unhandled popup message" error. create_vault/attach_vault get real cases
in Tasks 3.2-3.3; get_vault_status in Dev-C's Phase 6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:36:18 -04:00
adlee-was-taken
34d6155801 docs: add v0.7.0 PM/Dev-A/B/C kickoff prompts (extension restructure Phases 3/4/6)
Three-stream multi-agent lift to finish the extension restructure:
- Dev-A = Phase 3 (setup wizard SW migration + step registry; owns messages.ts)
- Dev-B = Phase 4 (split vault.ts into 5 modules + lift vault_locked channel)
- Dev-C = Phase 6 (get_vault_status + sidebar status indicator; deps on A & B)

PM prompt encodes the cross-stream dependency map (shared messages.ts edit,
vault-sidebar.ts footer-slot handoff, merge order P3 -> P4 -> P6) and the
pre-tag checklist. Launch script spawns a 4-window tmux session + relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 00:53:43 -04:00
adlee-was-taken
e3a1eefb50 docs: add extension-restructure tmux launch script
Auto-generated by release workflow relay-integration pass.
Starts relay if needed, opens PM/Dev-A/Dev-B tmux session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:57:13 -04:00
adlee-was-taken
a00a710e3b feat(workflow): add preflight, cleanup, artifact-scan, version/tag checks
Adds six sanity-check layers to the release workflow:
- preflight: orphaned worktrees, baseline green, plan-state grep, branch collision
- cleanup: removes merged worktrees + branches (git branch -d, never -D)
- debug artifact scan: dbg!/ console.log / TODO / unwrap() in diff (advisory)
- checkbox hygiene: unticked plan tasks before verify (advisory)
- pre-release version consistency across Cargo.toml workspace
- pre-release tag collision check

CLAUDE.md: discipline rules 5 (preflight before develop) and 6 (cleanup after lift).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:52:19 -04:00
adlee-was-taken
88b4176cc7 docs(claude): establish release workflow as default execution layer
Adds a "Release lifecycle" section to CLAUDE.md that makes the release
workflow the canonical way to develop, debug, verify, and tag — for both
single-agent (phone/remote) and multi-agent (supervised, tmux) modes.
Updates the planning section to route plan execution through the workflow
rather than directly to subagent-driven-development or executing-plans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:19:00 -04:00
adlee-was-taken
8d31fc5f45 feat(relay): expand role roster to dev-a through dev-f (6 devs)
Adds dev-d, dev-e, dev-f to Role type, KNOWN_ROLES, RelayQueue map,
and all three MCP tool enums in server.ts. Updates the isRole test
to assert the new roles are valid and dev-g is still rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:05:56 -04:00
adlee-was-taken
042f1eb929 docs: add extension-restructure PM/Dev-A/Dev-B kickoff prompts
Generated via release workflow (multi mode). Covers:
- PM: Phase 3+4+6 oversight, relay wiring, pre-tag checklist
- Dev-A: Phase 3 (setup wizard SW migration + step registry)
- Dev-B: Phase 4 (vault.ts split) + Phase 6 (get_vault_status)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 23:03:51 -04:00
adlee-was-taken
9fc07c3cd1 feat: add unified release workflow (develop/debug/verify/release)
Adds .claude/workflows/release.js — a single Workflow script covering
the full Relicario release lifecycle:
- develop (single: sequential agent pipeline; multi: PM/Dev kickoff generation)
- debug: iterative fix loop up to 5 passes
- verify: full cargo test/build/clippy sweep
- release: CHANGELOG + tag, stops before push

Adds docs/superpowers/RELEASE-WORKFLOW.md with invocation examples
and phone-vs-PC patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 22:55:28 -04:00
adlee-was-taken
8249f9e3d3 docs: Plan C Phases 1, 2, 5 merged; STATUS + ROADMAP updated
Three parallel worktree streams landed 2026-05-30 evening:
- Phase 1 (StateHost typing): c3f8e35
- Phase 2 (SW storage extract): b6707f4
- Phase 5 (P2 cluster: 5 small fixes): 0496dfe

Combined: 389/389 vitest passing. Phases 3, 4, 6 remain. Phase 4 (vault.ts
split) and Phase 6 (status indicator) both unblocked by Phase 1; Phase 3
(setup wizard SW migration) is the biggest single remaining piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 22:07:10 -04:00
adlee-was-taken
0496dfe533 Merge phase-c-5-p2-cluster: Plan C Phase 5 (P2 cluster — 5 small fixes)
5 commits landing 5 independent P2 fixes:
- ba5d218 inactivity timer resets on all non-passive messages (READ_ONLY_CONTENT_CALLABLE exclusion set in session-timer.ts; index.ts inverts the gate)
- 35444e0 state.gitHost cleared on session expiry (alongside state.manifest)
- e43f121 teardownSettingsCommon extracted; both settings.ts + settings-vault.ts call it (parameterized over each file's own activeKeyHandler module variable)
- 39fac68 Promise.allSettled with per-slot fallback in devices.ts (list_devices+list_revoked + sshFingerprint map). trash.ts is a no-op on this branch — it doesn't have a Promise.all to migrate (single list_trashed call); plan was written against a different snapshot.
- fce1962 MutationObserver scan() debounced to 200ms in content/detector.ts (no test harness on this branch — manual verification per plan note)

377/377 vitest tests pass (baseline 371 + 6 new tests in session-timer + devices). Zero regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:49:17 -04:00
adlee-was-taken
fce1962315 perf(ext/content): debounce MutationObserver scan() to 200ms (Plan C Phase 5)
DEV-C P2: SPA churn was re-running the full scan many times per second.
Trailing-edge debounce coalesces bursts so scan() runs at most once per
quiet 200ms window.

No test harness exists for content/detector.ts on this branch; relies
on manual verification on a real SPA page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:48:08 -04:00
adlee-was-taken
c3f8e3541c Merge phase-c-1-statehost: Plan C Phase 1 (typed StateHost + __resetHostForTests)
P1.6 closed: shared/state.ts is type-checked end to end. StateHost
interface defines every field; double-registration throws; vitest gets
__resetHostForTests to break inter-test leakage. View + PopupState moved
to shared/popup-state.ts (broke the popup→shared circular-dep blocker).

PopupState widened to absorb VaultState's vault-tab-only fields (unlocked,
drawerOpen, typePanelOpen) with optional + commented justification, so the
two surfaces share one typed contract.

378/378 vitest tests pass (baseline 371 + 7 new state.test.ts cases).

Phase 5 still running in parallel. Cross-stream note from Phase 1 subagent:
settings.ts was not touched here, so Phase 5's teardownSettingsCommon
extraction rebases cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:48:07 -04:00
adlee-was-taken
39fac68fc1 fix(ext/popup): defensive Promise.allSettled in devices (Plan C Phase 5)
DEV-C P2: Promise.all meant one rejected RPC failed the whole render.
allSettled + per-slot fallback keeps the active-devices surface usable
when the revoked-list feed (or one bad ssh fingerprint) is down.

Two call sites converted in devices.ts:
  1. list_devices + list_revoked pair — revoked failures now render an
     inline "couldn't load" slot instead of failing the page.
  2. sshFingerprint map — one bad public key falls back to '(unknown)'
     instead of killing the whole device list.

trash.ts only has a single sendMessage in its load path on this branch,
so it has no Promise.all to migrate. Plan was written against a slightly
different snapshot; documented divergence in report.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:47:15 -04:00
adlee-was-taken
31ed5c0384 test(ext/shared): cover StateHost registration + reset (Plan C Phase 1)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:47:05 -04:00
adlee-was-taken
3f2e43753d refactor(ext): sweep View/PopupState imports to shared/popup-state (Plan C Phase 1)
Removes the re-export shim from popup/popup.ts now that all callers point
at the canonical shared/popup-state. No external callers were depending on
the popup.ts re-export, so this drop is a strict tightening.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:46:20 -04:00
adlee-was-taken
547f2d4089 refactor(ext/shared): typed StateHost + sweep as-any casts (Plan C Phase 1)
Replaces the previously any-typed StateHost contract with a typed interface.
Adds double-registration guard and __resetHostForTests for vitest.
sendMessage wrapper is currently a pass-through; Phase 4 will fill its body
with the vault_locked intercept lifted from vault.ts.

Widens PopupState/View on shared/popup-state.ts to cover vault-tab-only
views (history, backup, import) and vault-tab-only fields (unlocked,
drawerOpen, typePanelOpen) so VaultState satisfies StateHost.getState()
without a cast. The popup surface ignores the new optional fields.

Drops the `any` annotations on vault.ts's registerHost callbacks now that
the typed StateHost contract infers them from PopupState.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:45:20 -04:00
adlee-was-taken
b6707f41f2 Merge phase-c-2-storage: Plan C Phase 2 (SW storage extract + itemToManifestEntry dedup)
P1.9 dedup landed: loadDeviceSettings, saveDeviceSettings, loadBlacklist,
saveBlacklist all live in service-worker/storage.ts; itemToManifestEntry
in service-worker/vault.ts. Both router files import from one home each.

popup-only.ts shrank 727 → 690 LOC; content-callable.ts shrank 204 → 171.
376/376 vitest tests pass (baseline 371 + 5 new storage.test.ts cases).

Phases 1 + 5 still running in parallel in their own worktrees.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:45:08 -04:00
adlee-was-taken
e43f121dfb refactor(ext/popup): extract teardownSettingsCommon (Plan C Phase 5)
DEV-C P2: settings.ts:56-65 and settings-vault.ts:15-22 had near-
identical cleanup paths. Single source for closeGeneratorPanel +
activeKeyHandler removal. Helper takes the handler as a parameter and
returns null so each caller still owns its own module-scoped handler
state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:44:54 -04:00
adlee-was-taken
20f074af20 refactor(ext/sw): extract storage.ts + move itemToManifestEntry (Plan C Phase 2)
P1.9: loadDeviceSettings / loadBlacklist / saveBlacklist / saveDeviceSettings
+ itemToManifestEntry were duplicated across popup-only.ts and
content-callable.ts. Lifts the four storage helpers into service-worker/
storage.ts and itemToManifestEntry into service-worker/vault.ts.

Both router files now import from one home each. Adds storage.test.ts
covering round-trips and defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:44:10 -04:00
adlee-was-taken
35444e02be fix(ext/sw): clear state.gitHost on session expiry (Plan C Phase 5)
DEV-C P2: expiry cleared manifest but left the cached git-host client.
The initializer rebuilds gitHost on demand, so clearing here is safe.

No new test: index.ts has top-level chrome.* side effects that make it
expensive to import in a unit test, and the change is a one-liner state
mutation in an inline callback. Manually verified by tracing call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:43:50 -04:00
adlee-was-taken
ba5d218841 fix(ext/sw): inactivity timer resets on all non-passive messages (Plan C Phase 5)
DEV-C P2: an active autofiller never opens the popup, so under the old
rule it got force-locked despite continuous use. Inverts the rule:
reset on all messages except a documented exclusion set (only
get_autofill_candidates today).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:42:44 -04:00
adlee-was-taken
f1621df3e2 refactor(ext/shared): move View + PopupState to shared/popup-state.ts
Foundation for Plan C Phase 1: shared/state.ts (next task) needs to import
PopupState without creating a popup->shared circular dep. popup.ts now
re-exports from the new location so existing callers don't break in this
task. Task 1.4 will sweep them onto the canonical import path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:41:50 -04:00
adlee-was-taken
4a1c553f9d docs(plan): extension restructure — 6-phase implementation plan
24 tasks across 6 phases derived from the 2026-05-04 extension restructure
spec. Per-task bite-sized steps (TDD where new behavior, verify-existing-
tests where pure relocation) with explicit file/line citations and full
code snippets.

Phase 1 (StateHost typing, S-M, blocks 3+4):     5 tasks
Phase 2 (storage.ts + itemToManifestEntry, S):   3 tasks
Phase 3 (setup wizard SW migration + step registry, L): 7 tasks
Phase 4 (vault.ts split into 5 modules + vault_locked lift, M): 7 tasks
Phase 5 (P2 cluster: timer/gitHost/teardown/allSettled/debounce, M): 5 tasks
Phase 6 (get_vault_status + sidebar status indicator, S-M):     3 tasks
Task 7.1 (final verification sweep against spec Done criteria).

Recommended sequence: 1 → 2 → 5 → 4 → 6 → 3 (independents first, then
the typed-StateHost-dependent phases, then Phase 3 last because it's the
biggest single phase and benefits from all the supporting infra in
place). Max subagent parallelism: 3 streams.

Cross-plan: explicit out-of-scope notes for Plan A (security/docs polish,
already shipped) and Plan B (CLI restructure, already shipped). The
wasm.d.ts file is not touched by this plan (verify empty diff at done).

STATUS + ROADMAP updated to point at the plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:36:55 -04:00
adlee-was-taken
39c86ab123 docs: STATUS/ROADMAP — only extension restructure remains outstanding
Post-v0.6.0 spot-check of the three 2026-05-04 architecture-review
specs (per CLAUDE.md rule #4) confirms:

- CLI restructure: shipped as Plan B Cycles 1+2 (b9bd152, 3dd1e1b,
  3759f6a, e69b347). Last gap (read-side refresh_groups_cache
  callers) closed in d717f0d. Done-criteria all met.
- Security polish: shipped as Stream A Cycle 1 (89090a8) plus
  follow-ups for start.sh fourth window (0c9387f) and recovery_qr.rs
  docs (229e483). All four phases done.
- Extension restructure: genuinely outstanding. vault.ts is 1037 LOC
  (criterion ~200); the five-module split has not happened; setup.ts
  still imports relicario-wasm directly; shared/state.ts still has
  any-typed StateHost; SW router helpers still duplicated; CLI parity
  gap (relicario status) still open. Effort estimate: L.

Removes the incorrect "subcommand reorganization, interactive TUI
mode" descriptor — the original CLI restructure spec is about file
structure, not TUI or renames. The TUI descriptor was a roadmap
mis-paraphrase, not a real outstanding item.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:28:03 -04:00
adlee-was-taken
d717f0d4a1 refactor(cli): tighten refresh_groups_cache to pub(crate) (Plan B Phase 4 polish)
Plan B Phase 4 wanted "every mutating handler must call
refresh_groups_cache" to be a compile-time invariant, with all
callers funneled through Vault::after_manifest_change. The
mutating-handler sweep happened, but two read-side callsites
(commands/list.rs and commands/get.rs) still called the public
helper directly for opportunistic shell-completion cache freshness.

Closes the gap:
- helpers::refresh_groups_cache demoted from pub to pub(crate).
- list.rs and get.rs drop their explicit calls. Cache freshness
  between mutations is unaffected: every mutating handler still
  funnels through after_manifest_change. The minor staleness
  window (manifest changed externally via git pull, no local
  mutation since) is the trade-off the spec accepts in exchange
  for the compile-time invariant.

The Plan B done-criterion "grep refresh_groups_cache outside
session.rs returns zero" now passes apart from the function
definition itself, which lives in helpers.rs (the natural place
for a flat utility). The visibility scoping achieves the
architectural intent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:26:34 -04:00
74 changed files with 8492 additions and 2315 deletions

View File

@@ -0,0 +1,678 @@
export const meta = {
name: 'release',
description: 'Relicario release lifecycle: develop features (single/multi-agent), iterate on debug, cut releases',
phases: [
{ title: 'Discover' },
{ title: 'Plan' },
{ title: 'Execute' },
{ title: 'Verify' },
{ title: 'Generate' },
{ title: 'Finalize' },
{ title: 'Cleanup' },
],
}
// ── Schemas ───────────────────────────────────────────────────────────────────
const MANIFEST_SCHEMA = {
type: 'object',
properties: {
plans: { type: 'array', items: { type: 'string' } },
taskCount: { type: 'number' },
domains: { type: 'array', items: { type: 'string' } },
},
required: ['plans', 'taskCount', 'domains'],
}
const TASK_LIST_SCHEMA = {
type: 'object',
properties: {
tasks: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
description: { type: 'string' },
planPath: { type: 'string' },
techDomain: { type: 'string' },
},
required: ['id', 'description', 'planPath', 'techDomain'],
},
},
},
required: ['tasks'],
}
const ASSIGNMENT_SCHEMA = {
type: 'object',
properties: {
devCount: { type: 'number' },
devs: {
type: 'array',
items: {
type: 'object',
properties: {
letter: { type: 'string' },
scope: { type: 'string' },
tasks: { type: 'array', items: { type: 'string' } },
outOfScope: { type: 'array', items: { type: 'string' } },
techDomain: { type: 'string' },
planFiles: { type: 'array', items: { type: 'string' } },
},
required: ['letter', 'scope', 'tasks', 'outOfScope', 'techDomain', 'planFiles'],
},
},
pmScope: { type: 'string' },
},
required: ['devCount', 'devs', 'pmScope'],
}
const DEBUG_RESULT_SCHEMA = {
type: 'object',
properties: {
fixed: { type: 'boolean' },
summary: { type: 'string' },
remainingFailures: { type: 'string' },
},
required: ['fixed', 'summary'],
}
const VERIFY_RESULT_SCHEMA = {
type: 'object',
properties: {
allPass: { type: 'boolean' },
failures: { type: 'array', items: { type: 'string' } },
summary: { type: 'string' },
},
required: ['allPass', 'failures', 'summary'],
}
const WORKTREE_STATUS_SCHEMA = {
type: 'object',
properties: {
stale: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
branch: { type: 'string' },
},
required: ['path', 'branch'],
},
},
active: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
branch: { type: 'string' },
},
required: ['path', 'branch'],
},
},
},
required: ['stale', 'active'],
}
const PLAN_STATE_SCHEMA = {
type: 'object',
properties: {
tickedTasks: { type: 'number' },
totalTasks: { type: 'number' },
gitEvidence: { type: 'array', items: { type: 'string' } },
},
required: ['tickedTasks', 'totalTasks', 'gitEvidence'],
}
const BRANCH_CHECK_SCHEMA = {
type: 'object',
properties: {
collisions: { type: 'array', items: { type: 'string' } },
},
required: ['collisions'],
}
const VERSION_CHECK_SCHEMA = {
type: 'object',
properties: {
consistent: { type: 'boolean' },
versions: { type: 'array', items: { type: 'string' } },
conflicts: { type: 'array', items: { type: 'string' } },
tagExists: { type: 'boolean' },
},
required: ['consistent', 'versions', 'conflicts', 'tagExists'],
}
const CLEANUP_RESULT_SCHEMA = {
type: 'object',
properties: {
removed: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
branch: { type: 'string' },
},
required: ['path', 'branch'],
},
},
kept: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string' },
branch: { type: 'string' },
reason: { type: 'string' },
},
required: ['path', 'branch', 'reason'],
},
},
},
required: ['removed', 'kept'],
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const REPO = '/home/alee/Sources/relicario'
const COORD_DIR = 'docs/superpowers/coordination'
function devRole(letter) {
return 'dev-' + letter.toLowerCase()
}
// ── Routing ───────────────────────────────────────────────────────────────────
const action = (args && args.action) || 'develop'
const mode = (args && args.mode) || 'single'
const release = args && args.release
const context = args && args.context
// ── ACTION: preflight ─────────────────────────────────────────────────────────
if (action === 'preflight') {
if (!release) throw new Error('args.release is required for action=preflight')
const [worktrees, baseline, planState, branches] = await parallel([
() => agent(
`Run: git -C ${REPO} worktree list\n` +
`Parse the output. For each worktree listed, extract its path and branch.\n` +
`Skip the main checkout at ${REPO} itself.\n` +
`Then run: git -C ${REPO} branch --merged main\n` +
`A worktree is stale if its branch appears in the merged list. Otherwise it is active.\n` +
`Return stale (merged worktrees) and active (unmerged worktrees), each as an array of {path, branch}.`,
{ schema: WORKTREE_STATUS_SCHEMA, label: 'worktree-scan', phase: 'Discover' }
),
() => agent(
`cd ${REPO} and run each of these commands, capturing the last 5 lines of output:\n` +
` cargo test --quiet 2>&1 | tail -5\n` +
` pnpm --filter extension test --run 2>&1 | tail -5\n` +
`Report allPass=true only if both commands exit with code 0. ` +
`List any failures with their error messages. Provide a one-line summary.`,
{ schema: VERIFY_RESULT_SCHEMA, label: 'baseline-green', phase: 'Discover' }
),
() => agent(
`Run: git -C ${REPO} log --oneline --all --grep="${release}" | head -20\n` +
`Capture the output as gitEvidence.\n` +
`Then scan ${REPO}/docs/superpowers/plans/ for any files whose filename contains "${release}".\n` +
`For each matching file, count lines matching "- \\[x\\]" (ticked) and "- \\[ \\]" (unticked).\n` +
`Sum across all matching files. Return tickedTasks, totalTasks, and gitEvidence (the git log lines).`,
{ schema: PLAN_STATE_SCHEMA, label: 'plan-state', phase: 'Discover' }
),
() => agent(
`Run: git -C ${REPO} branch --all\n` +
`Return any branch names (local or remote) that contain the string "${release}" as collisions.`,
{ schema: BRANCH_CHECK_SCHEMA, label: 'branch-collision', phase: 'Discover' }
),
])
const issues = []
if (worktrees.stale.length > 0) {
issues.push('orphaned-worktrees')
log(`WARN [worktree-scan]: ${worktrees.stale.length} stale worktree(s) found — run action=cleanup to remove them`)
for (const w of worktrees.stale) {
log(` stale: ${w.path} (${w.branch})`)
}
} else {
log(`[worktree-scan]: clean`)
}
if (!baseline.allPass) {
issues.push('baseline-failing')
log(`FAIL [baseline-green]: ${baseline.failures.length} failure(s): ${baseline.failures.join(' | ')}`)
} else {
log(`[baseline-green]: green`)
}
if (planState.tickedTasks > 0) {
issues.push('plan-partially-done')
log(`WARN [plan-state]: ${planState.tickedTasks}/${planState.totalTasks} tasks already ticked`)
for (const e of planState.gitEvidence) {
log(` evidence: ${e}`)
}
} else {
log(`[plan-state]: clean slate (0 ticked tasks)`)
}
if (branches.collisions.length > 0) {
issues.push('branch-collision')
log(`WARN [branch-collision]: branches already exist for release label "${release}": ${branches.collisions.join(', ')}`)
} else {
log(`[branch-collision]: no collisions`)
}
if (issues.length === 0) {
log(`Preflight PASS`)
} else {
log(`Preflight has ${issues.length} warning(s): ${issues.join(', ')}`)
}
return { status: issues.length === 0 ? 'pass' : 'warn', issues, worktrees, baseline, planState, branches }
}
// ── ACTION: develop ───────────────────────────────────────────────────────────
if (action === 'develop') {
if (!release) throw new Error('args.release is required for action=develop')
phase('Discover')
const manifest = await agent(
`Scan docs/superpowers/plans/ in ${REPO} for plan files belonging to release "${release}". ` +
`A plan file belongs if its filename contains the release label, or its opening lines reference it as its target release. ` +
`Read each matching file, count checkbox tasks (lines starting with - [ ]), and identify tech domains (rust, extension, docs, etc.). ` +
`Return: plans (relative paths from repo root), taskCount, domains.`,
{ schema: MANIFEST_SCHEMA, label: 'discover-plans', phase: 'Discover' }
)
log(`Found ${manifest.plans.length} plan(s), ${manifest.taskCount} tasks — domains: ${manifest.domains.join(', ')}`)
// ── SINGLE MODE ─────────────────────────────────────────────────────────────
if (mode === 'single') {
phase('Plan')
const taskList = await agent(
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
`Extract every checkbox task (- [ ] items) and order them to respect dependencies ` +
`(e.g. core Rust changes before WASM/CLI consumers, schema changes before UI). ` +
`For each task return: id (short slug like S1-step2), description (full step text), ` +
`planPath (which file it came from), techDomain (rust/extension/docs/cli/wasm).`,
{ schema: TASK_LIST_SCHEMA, label: 'pm-plan', phase: 'Plan' }
)
log(`PM ordered ${taskList.tasks.length} tasks for sequential execution`)
phase('Execute')
await pipeline(
taskList.tasks,
(task) => agent(
`You are a senior developer on the ${release} release of Relicario.\n` +
`Repo: ${REPO}\n\n` +
`IMPORTANT: cd into ${REPO} before any git or cargo commands.\n\n` +
`Your task (${task.id}): ${task.description}\n` +
`Plan file for full context: ${task.planPath}\n` +
`Tech domain: ${task.techDomain}\n\n` +
`Instructions:\n` +
`1. Read the plan file for context on this specific step.\n` +
`2. Implement ONLY this step — do not run ahead to the next one.\n` +
`3. Run the relevant tests after your change (cargo test -p <crate> for Rust; pnpm build for extension).\n` +
`4. Commit with a conventional commit message scoped to the change.\n` +
`5. Report: what you did, test result (pass/fail), any blockers.`,
{ label: task.id, phase: 'Execute' }
)
)
// ── Advisory: checkbox hygiene ───────────────────────────────────────────
await agent(
`Read each of these plan files from ${REPO}:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
`Count any lines still matching "- [ ]" (unticked checkboxes). ` +
`Log each unticked item with its file and line text. ` +
`This is advisory only — report findings but do not block or fail.`,
{ label: 'checkbox-check', phase: 'Verify' }
)
phase('Verify')
const verifyResult = await agent(
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
`Commands:\n` +
` cargo test\n` +
` cargo build --all-targets\n` +
` cargo clippy -- -D warnings\n` +
`Report pass/fail for each command. List every failure with its error message.`,
{ schema: VERIFY_RESULT_SCHEMA, label: 'full-verify', phase: 'Verify' }
)
if (!verifyResult.allPass) {
log(`Verify FAILED — ${verifyResult.failures.length} failure(s): ${verifyResult.failures.join(' | ')}`)
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
return { status: 'verify-failed', failures: verifyResult.failures, summary: verifyResult.summary }
}
// ── Advisory: debug artifact scan ────────────────────────────────────────
await agent(
`Run the following command from ${REPO}:\n` +
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
`Examine lines beginning with "+" (additions) in the diff output.\n` +
`Report any occurrences of:\n` +
` - dbg!( in Rust files (warn)\n` +
` - console.log( in TypeScript files (warn)\n` +
` - TODO or FIXME anywhere (warn)\n` +
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
`Log each finding with its file and line. This is advisory only — do not block.`,
{ label: 'debug-artifact-scan', phase: 'Finalize' }
)
phase('Finalize')
await agent(
`Update ${REPO}/STATUS.md to reflect the ${release} work that just completed.\n` +
`Mark any in-flight items as landed. Set what is now in flight next.\n` +
`Commit the STATUS.md update with message "docs: update STATUS for ${release} develop pass".`,
{ label: 'update-status', phase: 'Finalize' }
)
log(`Single-mode develop complete. Run action=release when ready to tag.`)
return { status: 'complete', mode: 'single', release }
}
// ── MULTI MODE ──────────────────────────────────────────────────────────────
phase('Plan')
const assignment = await agent(
`You are the PM for the ${release} release of Relicario at ${REPO}.\n` +
`Read these plan files:\n${manifest.plans.map(p => ' ' + p).join('\n')}\n\n` +
`Decide how many dev streams are needed (one per major domain or plan, max 3). ` +
`Minimize cross-dev dependencies. For each dev assign: ` +
`letter (A/B/C), scope summary (2 sentences), task IDs they own, ` +
`out-of-scope task IDs (owned by other devs), primary techDomain, and which planFiles they need to read. ` +
`Also write a 2-sentence pmScope describing your oversight and review duties.`,
{ schema: ASSIGNMENT_SCHEMA, label: 'pm-assign', phase: 'Plan' }
)
log(`PM assigned ${assignment.devCount} dev stream(s)`)
phase('Generate')
const allRoles = ['pm', ...assignment.devs.map(d => devRole(d.letter))].join(', ')
await parallel([
() => agent(
`Write a self-contained PM kickoff prompt to ${REPO}/${COORD_DIR}/${release}-pm-prompt.md.\n\n` +
`Release: ${release}\n` +
`PM scope: ${assignment.pmScope}\n` +
`Plans: ${manifest.plans.join(', ')}\n` +
`Dev roster:\n${assignment.devs.map(d => ` Dev-${d.letter}: ${d.scope}`).join('\n')}\n\n` +
`The file must include these sections in order:\n` +
`1. Role header ("You are the PM for the ${release} release of Relicario.")\n` +
`2. Working directory: ${REPO}\n` +
`3. Required reading: CLAUDE.md, all plan files listed above\n` +
`4. Authority: approve scope changes, review dev PRs, write CHANGELOG entry, drive doc updates, tag release (with user approval only)\n` +
`5. Boundaries: write NO feature code; NO destructive ops without user confirmation\n` +
`6. Relay server section: localhost:7331, your from="pm", tools: post_message/read_messages/list_pending, recipients: ${allRoles}. Include Python shim fallback.\n` +
`7. Dev roster with each dev letter, branch name (feature/${release}-dev-X), worktree path (${REPO}.dev-x), and scope\n` +
`8. Coordination protocol: DIRECTIVE block format, RELEASE STATUS rollup format\n` +
`9. PR review procedure (gh pr view / gh pr diff)\n` +
`10. Pre-tag checklist (all tests pass, CHANGELOG written, STATUS.md updated, all dev PRs merged)\n` +
`11. First action: read all required files, emit a RELEASE STATUS block confirming context absorbed, then check all dev inboxes\n` +
`Make every section concrete — the receiving Claude has zero prior context.`,
{ label: 'gen-pm', phase: 'Generate' }
),
...assignment.devs.map((dev) => () => agent(
`Write a self-contained Dev-${dev.letter} kickoff prompt to ${REPO}/${COORD_DIR}/${release}-dev-${dev.letter.toLowerCase()}-prompt.md.\n\n` +
`Release: ${release}\n` +
`Dev-${dev.letter} scope: ${dev.scope}\n` +
`Tasks owned: ${dev.tasks.join(', ')}\n` +
`Out of scope: ${dev.outOfScope.join(', ')}\n` +
`Tech domain: ${dev.techDomain}\n` +
`Plan files: ${dev.planFiles.join(', ')}\n\n` +
`The file must include these sections in order:\n` +
`1. Role header ("You are Dev-${dev.letter} for the ${release} release of Relicario.")\n` +
`2. Worktree setup commands (run these FIRST before anything else):\n` +
` git -C ${REPO} worktree add ${REPO}.dev-${dev.letter.toLowerCase()} -b feature/${release}-dev-${dev.letter.toLowerCase()}\n` +
` cd ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
`3. Working directory after setup: ${REPO}.dev-${dev.letter.toLowerCase()}\n` +
`4. CRITICAL subagent rule: every subagent prompt MUST start with "cd ${REPO}.dev-${dev.letter.toLowerCase()} &&" — never rely on working-directory headers alone\n` +
`5. Required reading: CLAUDE.md, ${dev.planFiles.join(', ')}\n` +
`6. Execution mode: use superpowers:subagent-driven-development\n` +
`7. Scope: in-scope tasks (${dev.tasks.join(', ')}), out-of-scope (${dev.outOfScope.join(', ')})\n` +
`8. Hard rules from the plan (copy any HIGH-severity or acceptance-test constraints verbatim)\n` +
`9. Relay: localhost:7331, your from="${devRole(dev.letter)}", call read_messages before each task, post status/questions to "pm". Recipients: ${allRoles}. Include Python shim fallback.\n` +
`10. STATUS UPDATE format: Task / Status (COMPLETE|IN-PROGRESS|BLOCKED) / Notes (what + why) / Next — print locally AND post to pm via relay\n` +
`11. Final test commands for ${dev.techDomain}\n` +
`12. PR procedure: gh pr create targeting main, title "feat(${release}): Dev-${dev.letter} — <scope>"\n` +
`13. First action: run worktree setup, emit STATUS UPDATE "setup complete", start Task 1`,
{ label: `gen-dev-${dev.letter.toLowerCase()}`, phase: 'Generate' }
)),
])
// Check relay, start if needed
await agent(
`Check if the relay server is running on localhost:7331 by running: ` +
`curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1 && echo running || echo stopped\n\n` +
`If the output is "stopped", start it: ` +
`nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
`Then poll curl -sf http://127.0.0.1:7331/sse --max-time 1 once per second for up to 10s. ` +
`Report "relay ready" or "relay failed to start (check /tmp/relay-${release}.log)".`,
{ label: 'relay-check', phase: 'Generate' }
)
await agent(
`Write a bash launch script to ${REPO}/${COORD_DIR}/${release}-launch.sh.\n\n` +
`Header comment: # Auto-generated by release workflow — ${release}\n` +
`set -e\n\n` +
`Section 1 — Relay health check and auto-start:\n` +
` if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then\n` +
` echo "[relay] already running"\n` +
` else\n` +
` echo "[relay] starting..." && nohup npx tsx ${REPO}/tools/relay/server.ts > /tmp/relay-${release}.log 2>&1 &\n` +
` for i in $(seq 1 10); do sleep 1; curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1 && echo "[relay] ready" && break || true; [ $i -eq 10 ] && echo "[relay] ERROR — check /tmp/relay-${release}.log" && exit 1; done\n` +
` fi\n\n` +
`Section 2 — tmux session. Session name is the release label.\n` +
` If tmux session already exists, attach and exit.\n` +
` Otherwise create a new session, then for each role (pm + each dev letter) create a named window\n` +
` that runs: claude\n` +
` After creating windows, print a prompt-paste cheatsheet showing which file to paste in each window.\n` +
` Then attach to the session.\n\n` +
`Devs: ${assignment.devs.map(d => 'Dev-' + d.letter).join(', ')}\n` +
`Prompt files in ${COORD_DIR}:\n` +
` PM: ${release}-pm-prompt.md\n` +
assignment.devs.map(d => ` Dev-${d.letter}: ${release}-dev-${d.letter.toLowerCase()}-prompt.md`).join('\\n') + '\\n\\n' +
`After writing the file, run: chmod +x ${REPO}/${COORD_DIR}/${release}-launch.sh`,
{ label: 'gen-launch-script', phase: 'Generate' }
)
log(`Prompts + launch script ready in ${COORD_DIR}/`)
log(`Run: ${REPO}/${COORD_DIR}/${release}-launch.sh`)
log(`(starts relay if needed, opens tmux session, prompts you which file to paste in each window)`)
return { status: 'prompts-ready', devCount: assignment.devCount, coordDir: COORD_DIR }
}
// ── ACTION: debug ─────────────────────────────────────────────────────────────
if (action === 'debug') {
if (!context) throw new Error('args.context required for action=debug — describe the failure or paste test output')
let currentContext = context
const MAX_ITERATIONS = 5
for (let i = 1; i <= MAX_ITERATIONS; i++) {
phase(`Debug iteration ${i}`)
const result = await agent(
`You are debugging a failure in Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
`Failure context:\n${currentContext}\n\n` +
`Use systematic debugging:\n` +
`1. Form a specific hypothesis about the root cause.\n` +
`2. Read the relevant source files and tests.\n` +
`3. Implement the minimal fix — no unrelated changes.\n` +
`4. Run the failing test(s) to confirm they now pass.\n` +
`5. Run cargo test to confirm no regressions.\n` +
`6. Commit the fix if clean.\n\n` +
`Return fixed=true if all tests pass, fixed=false with remainingFailures if not.`,
{ schema: DEBUG_RESULT_SCHEMA, label: `debug-iter-${i}` }
)
log(`Iteration ${i}: ${result.summary}`)
if (result.fixed) {
log(`Fixed after ${i} iteration(s).`)
return { status: 'fixed', iterations: i, summary: result.summary }
}
currentContext = result.remainingFailures || currentContext
log(`Still failing — next iteration with updated context`)
}
log(`Reached max iterations (${MAX_ITERATIONS}). Manual intervention needed.`)
return { status: 'max-iterations', lastContext: currentContext }
}
// ── ACTION: verify ────────────────────────────────────────────────────────────
if (action === 'verify') {
phase('Verify')
const result = await agent(
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
` cargo test\n` +
` cargo build --all-targets\n` +
` cargo clippy -- -D warnings\n` +
`Report pass/fail for each. List every failure with its error text.`,
{ schema: VERIFY_RESULT_SCHEMA, label: 'verify' }
)
if (result.allPass) {
log(`All checks pass.`)
} else {
log(`FAILED: ${result.failures.join(' | ')}`)
log(`Fix with: Workflow({name:"release", args:{action:"debug", context:"<paste failures>"}})`)
}
return result
}
// ── ACTION: release ───────────────────────────────────────────────────────────
if (action === 'release') {
if (!release) throw new Error('args.release is required for action=release')
phase('Verify')
const verifyResult = await agent(
`Run the full Relicario test suite from ${REPO}. IMPORTANT: cd ${REPO} first.\n` +
` cargo test\n` +
` cargo build --all-targets\n` +
` cargo clippy -- -D warnings\n` +
`Report pass/fail. List failures.`,
{ schema: VERIFY_RESULT_SCHEMA, label: 'pre-release-verify' }
)
if (!verifyResult.allPass) {
log(`Tests failing — cannot cut release. Fix first with action=debug.`)
return { status: 'blocked', failures: verifyResult.failures }
}
// ── Version + tag checks ─────────────────────────────────────────────────
const versionCheck = await agent(
`Read ${REPO}/Cargo.toml and all files matching ${REPO}/crates/*/Cargo.toml.\n` +
`For each file, extract the version field from the [package] section.\n` +
`Check whether all extracted versions are identical.\n` +
`Then run: git -C ${REPO} tag -l "${release}"\n` +
`Set tagExists=true if the output is non-empty (the tag already exists), false otherwise.\n` +
`Return consistent (true if all versions match), versions (list of all extracted version strings), ` +
`conflicts (list of "file: version" strings for any that differ from the majority), and tagExists.`,
{ schema: VERSION_CHECK_SCHEMA, label: 'version-tag-check', phase: 'Finalize' }
)
if (!versionCheck.consistent) {
log(`FAIL [version-tag-check]: version mismatch across crates — ${versionCheck.conflicts.join(' | ')}`)
return { status: 'blocked', reason: 'version-mismatch' }
}
if (versionCheck.tagExists) {
log(`FAIL [version-tag-check]: tag "${release}" already exists — cannot re-tag`)
return { status: 'blocked', reason: 'tag-exists' }
}
log(`[version-tag-check]: Versions consistent (${versionCheck.versions[0]}), tag available`)
// ── Advisory: debug artifact scan ──────────────────────────────────────────
await agent(
`Run the following command from ${REPO}:\n` +
` git -C ${REPO} diff $(git -C ${REPO} describe --tags --abbrev=0)..HEAD\n\n` +
`Examine lines beginning with "+" (additions) in the diff output.\n` +
`Report any occurrences of:\n` +
` - dbg!( in Rust files (warn)\n` +
` - console.log( in TypeScript files (warn)\n` +
` - TODO or FIXME anywhere (warn)\n` +
` - .unwrap() in Rust files (advisory note only, not a hard warn)\n` +
`Log each finding with its file and line. This is advisory only — do not block.`,
{ label: 'debug-artifact-scan', phase: 'Finalize' }
)
phase('Finalize')
await agent(
`Cut the ${release} release for Relicario at ${REPO}. IMPORTANT: cd ${REPO} first.\n\n` +
`Steps (in order):\n` +
`1. Run: git log $(git describe --tags --abbrev=0)..HEAD --oneline\n` +
` Use that output to write a ${release} section in CHANGELOG.md — user-facing language, grouped by type.\n` +
`2. Update STATUS.md: mark ${release} as released, set what is next.\n` +
`3. Update ROADMAP.md: check off the ${release} milestone.\n` +
`4. Commit those doc updates: git commit -m "release: ${release}"\n` +
`5. Create annotated tag: git tag -a ${release} -m "Release ${release}"\n` +
`6. STOP. Print the tag SHA and the push command, then ask the user to confirm before pushing.\n` +
` Do NOT run git push or git push --tags without explicit user confirmation.`,
{ label: 'cut-release', phase: 'Finalize' }
)
return { status: 'tagged', release, note: 'Confirm and push manually.' }
}
// ── ACTION: cleanup ───────────────────────────────────────────────────────────
if (action === 'cleanup') {
phase('Cleanup')
const result = await agent(
`Run: git -C ${REPO} worktree list\n` +
`Run: git -C ${REPO} branch --merged main\n\n` +
`For each worktree listed (skip the main checkout at ${REPO} itself):\n` +
` - If its branch appears in the merged list:\n` +
` Run: git -C ${REPO} worktree remove --force <path>\n` +
` Run: git -C ${REPO} branch -d <branch> (lowercase -d only, never -D)\n` +
` Add to removed: [{path, branch}]\n` +
` - If its branch does NOT appear in the merged list:\n` +
` Add to kept: [{path, branch, reason: "unmerged"}]\n\n` +
`Return removed (worktrees that were deleted) and kept (worktrees that were left in place).`,
{ schema: CLEANUP_RESULT_SCHEMA, label: 'cleanup', phase: 'Cleanup' }
)
log(`Cleanup removed ${result.removed.length} worktree(s):`)
for (const w of result.removed) {
log(` removed: ${w.path} (${w.branch})`)
}
log(`Cleanup kept ${result.kept.length} worktree(s):`)
for (const w of result.kept) {
log(` kept: ${w.path} (${w.branch}) — ${w.reason}`)
}
return { status: 'done', removed: result.removed.length, kept: result.kept.length }
}
log(`Unknown action: "${action}". Valid: develop, debug, verify, release, preflight, cleanup`)
return { status: 'error', action }

View File

@@ -1,5 +1,69 @@
# Changelog
## v0.7.0 — 2026-06-01
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
1/2/5 (StateHost typing, SW storage extract, the P2 cluster) shipped
2026-05-30; this tag adds the remaining three phases — executed as three
parallel worktree streams under PM coordination — which eliminate the
two steepest learning cliffs in the extension and close the last
`relicario status` CLI/extension parity gap. No crypto, wire-format, or
Rust-API changes; this is an internal-architecture + one-feature release.
### Added
- **`relicario status` parity in the extension.** New `get_vault_status`
service-worker message returns a cached sync summary
`{ ahead, behind, lastSyncAt, pendingItems }` with no network call —
`ahead`/`behind`/`lastSyncAt` read straight off the cached git-host
state (populated by the `sync` handler), `pendingItems` a live count of
active (non-trashed) manifest entries. A sidebar-footer status indicator
(`vault-status.ts``renderStatusIndicator`) renders `N pending` /
`N ahead` / `N behind` / `in sync` plus a `last sync …` / `never synced`
line, refreshed on mount and on a manual `↻` button — no timer polling,
matching the no-network-without-user-intent discipline.
### Changed
- **Setup wizard crypto moved into the service worker.** The wizard no
longer imports `relicario-wasm` or orchestrates the master key directly.
New `create_vault` / `attach_vault` SW handlers own the full flow
(image-secret embed/extract, unlock, manifest+settings encrypt + push,
`register_device` + `addDevice`, persist config + reference image,
`session.setCurrent`); on failure the SessionHandle is locked then freed,
with ownership transferring only on success. `setup.ts` collapses from
~1230 LOC to a 58-LOC UI-only shell; the six render/attach step pairs
become a `SetupStep` registry in the new `setup/setup-steps.ts`. Adds
`clearWizardState` (bound to `beforeunload` and `goto('mode')`) to wipe
sensitive Uint8Array fields when the wizard is abandoned. The
non-extension copy-vault-config-JSON escape hatch is preserved.
- **`vault.ts` split from a 1037-LOC monolith to 194 LOC of routing +
state.** Extracted into five focused modules — `vault-shell` (DOM
scaffolding, color-scheme, onMessage wiring), `vault-sidebar` (category
nav, 80ms debounced search, bottom nav, status-indicator footer),
`vault-list` (list + row rendering), `vault-drawer` (open/close/render +
`ensureDrawerClosedForRoute`), `vault-form-wrapper` (wrapped form + sticky
bar) — plus two support modules for an acyclic split (`vault-context`,
the VaultController contract; `vault-router`, hash routing + pane
dispatch).
- **`vault_locked` RPC intercept unified.** Lifted out of `vault.ts` into
the `sendMessage` wrapper in `shared/state.ts`, so both popup and
vault-tab surfaces share one lock-redirect path.
- **`state.gitHost` now nulled on explicit lock**, symmetric with the
session-timer expiry path, so the new status indicator can't surface a
stale `lastSyncAt` after a lock → re-unlock within one service-worker
lifetime.
### Internal
- Three-stream parallel execution (Dev-A Phase 3, Dev-B Phase 4, Dev-C
Phase 6) coordinated via the relay message bus; merges sequenced
Phase 3 → 4 → 6 with per-phase done-criteria verification.
- Final merged-tree validation: **423/423** vitest (62 files);
`npm run build:all` clean for both Chrome and Firefox targets (only the
pre-existing ~4 MB WASM asset-size warning). Task 7.1 done-criteria
sweep all green. No change to `wasm.d.ts`.
## v0.6.0 — 2026-05-30
Rolls up four weeks of post-v0.5.0 work into one tag: the Phase 2B

View File

@@ -88,7 +88,7 @@ Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
## Planning & design specs
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details.
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details. Once a plan exists, execute it via the release workflow (see **Release lifecycle** below) — not directly via subagent-driven-development or executing-plans unless the workflow is unavailable.
Core references (read before touching crypto, data model, or architecture):
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — threat model, entropy analysis, crypto pipeline, crate layout
@@ -97,6 +97,31 @@ Core references (read before touching crypto, data model, or architecture):
After completing any dev iteration, update `STATUS.md` to reflect what shipped and what's now in flight. Update the component doc for any area you changed (see table below).
## Release lifecycle
The `release` workflow (`.claude/workflows/release.js`) is the **default execution layer** for all dev work. Invoke it via the `Workflow` tool or the `/release` skill. Full reference: `docs/superpowers/RELEASE-WORKFLOW.md`.
### Standard actions
| Action | When | How |
|--------|------|-----|
| `develop` + `mode:"single"` | Implement a plan; phone/remote; fire-and-forget | `Workflow({name:"release", args:{action:"develop", mode:"single", release:"<label>"}})` |
| `develop` + `mode:"multi"` | Parallel streams; at PC; PM supervises devs | `Workflow({name:"release", args:{action:"develop", mode:"multi", release:"<label>"}})` |
| `debug` | Fix a failing test or broken feature after manual testing | `Workflow({name:"release", args:{action:"debug", context:"<paste failure>"}})` |
| `verify` | Confirm tests pass before releasing | `Workflow({name:"release", args:{action:"verify"}})` |
| `release` | Cut and tag a version | `Workflow({name:"release", args:{action:"release", release:"<label>"}})` |
### Execution defaults
- **Single-plan work** → `mode:"single"`. One agent works through tasks sequentially; updates `STATUS.md` automatically on completion.
- **Multi-plan or multi-phase work** → `mode:"multi"`. PM agent reads plans, assigns dev streams (up to 6), generates prompt files + a `<release>-launch.sh` in `docs/superpowers/coordination/`. Run the launch script — it starts the relay and opens a tmux session.
- **Debugging** → always `action:"debug"`. Never hand-fix without at least trying the debug loop first.
- **Releasing** → always `action:"release"`. It verifies first, writes CHANGELOG, tags, and stops before push.
### Multi-agent relay
The relay server (`tools/relay/`) supports roles `pm`, `dev-a` through `dev-f`. The launch script starts it automatically. If you need to start it manually: `cd tools/relay && ./start.sh`. Protocol reference: `docs/superpowers/coordination/RELAY.md`.
## Roadmap & status
Current in-flight work: `STATUS.md`. Full roadmap with release targets: `ROADMAP.md`. Wire format reference: `docs/FORMATS.md`.
@@ -129,3 +154,7 @@ Four rules to prevent the kind of drift the 2026-05-30 audits found:
4. **Plan-state hygiene.** Plan checkboxes and `STATUS.md`/`ROADMAP.md` must reflect what's actually shipped. Two halves:
- **Ship side:** when a commit lands work that maps to a plan task, tick that plan's checkboxes in the same commit (or the immediately-following docs commit). Same for `STATUS.md` — the "Up next" list does not get to lag the actual state of `main` by weeks.
- **Execute side:** before starting execution of a plan whose checkboxes are all unchecked, spot-check git log (`git log --oneline --all --grep <distinctive-name>`) or grep for a distinctive symbol/file the plan would create. A plan whose work already merged is the worst kind of plan to re-execute. The 2026-05-30 status-audit found Phase 2B, v0.5.1 Streams A/B/C, and 1C-γ all stealth-shipped two-to-three weeks earlier because nobody ran this check.
5. **Pre-flight before develop.** Before running `action:"develop"` on any release, run `action:"preflight"` first. If preflight reports FAIL (baseline not green or version mismatch), fix the failure before proceeding. WARN results (orphaned worktrees, partially-done plan) require a judgement call — acknowledge them explicitly before proceeding.
6. **Cleanup after every lift.** Once all PRs for a release are merged into main, run `Workflow({name:"release", args:{action:"cleanup"}})` to remove the lift's worktrees and feature branches. Stale worktrees accumulate silently and create confusion for the next lift's branch-collision check.

6
Cargo.lock generated
View File

@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relicario-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"anyhow",
"arboard",
@@ -2185,7 +2185,7 @@ dependencies = [
[[package]]
name = "relicario-core"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"argon2",
"base64",
@@ -2231,7 +2231,7 @@ dependencies = [
[[package]]
name = "relicario-wasm"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64",
"ed25519-dalek",

View File

@@ -7,6 +7,7 @@
| Version | Highlights |
|---|---|
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
@@ -14,18 +15,13 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
## Up next
All three are specced but have no implementation plan yet. Writing a plan is the first move on any of them.
All three 2026-05-04 architecture-review specs are now shipped (CLI restructure = Plan B Cycles 1+2; security polish = Stream A Cycle 1; extension restructure = Plan C Phases 16, completed v0.7.0 2026-06-01). The next committed item is:
- **CLI restructure** — subcommand reorganization, interactive TUI mode
Spec: `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
- **Extension restructure** — bundle / message-routing cleanup
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
- **Security polish** — follow-up hardening from the architecture review
Spec: `docs/superpowers/specs/2026-05-04-security-polish-design.md`
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
## Medium-term
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
_(promote here once specced)_
## Long-term / backlog

View File

@@ -5,7 +5,7 @@
## Version
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
**Active track:** picking the next initiative (CLI restructure / extension restructure / security polish all have specs, no plans yet)
**Active track:** **extension restructure (Plan C) — COMPLETE.** All six phases merged. Phases 1, 2, 5 merged 2026-05-30; Phases 3, 4, 6 merged 2026-05-31/06-01 via three parallel worktree streams (Dev-A/B/C under PM coordination). Versions bumped to v0.7.0; tag pending.
## What landed on main since the v0.5.0 version bump
@@ -98,6 +98,19 @@ Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md
- Item-history-index pane — top-level "items with history" list (`32e1632`)
- Sidebar slot wiring + `#history/<id>` route with `#field-history/<id>` legacy normalization (`88d7228`)
### Extension restructure — Plan C Phases 3, 4, 6 (merged 2026-05-31 → 06-01, v0.7.0)
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
Plan: `docs/superpowers/plans/2026-05-30-extension-restructure.md`
Three parallel worktree streams under PM coordination (relay-bus), completing the restructure begun with Phases 1/2/5:
- **Phase 3 — setup wizard SW migration + step registry** (Dev-A, merge `9df2fee`). `create_vault` / `attach_vault` SW handlers own the full vault-creation/attach flow (embed/unlock, encrypt+push, register_device+addDevice, persist config+image, `session.setCurrent`; failure path locks+frees the handle). `setup.ts` collapses 1230→58 LOC (UI-only shell, no `relicario-wasm` import); step registry + state + `clearWizardState` + `finishSetup` extracted to new `setup/setup-steps.ts`. `clearWizardState` bound to `beforeunload` + `goto('mode')`. Copy-vault-JSON escape hatch preserved.
- **Phase 4 — vault.ts split + vault_locked lift** (Dev-B, merge `3b8368d`). `vault.ts` 1037→194 LOC. Five named modules (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`) plus two support modules (`vault-context` — the VaultController contract; `vault-router` — hash routing + pane dispatch, to hold vault.ts ≤250). `vault_locked` RPC intercept lifted into `shared/state.ts`'s `sendMessage` wrapper. 80ms debounced sidebar search (`SEARCH_DEBOUNCE_MS`); `ensureDrawerClosedForRoute`; `#vault-status-slot` footer staged for Phase 6.
- **Phase 6 — get_vault_status + sidebar status indicator** (Dev-C, merge `397cc78`). `get_vault_status` SW handler returns cached `{ahead, behind, lastSyncAt, pendingItems}` with no network call; `vault-status.ts` renders the sidebar-footer indicator (`renderStatusIndicator` into `#vault-status-slot`, refreshed on mount + manual `↻` button, no timer polling). Closes the last `relicario status` CLI/extension parity gap. Also nulls `state.gitHost` on the explicit `lock` handler (symmetric with session-expiry) so the indicator can't show a stale `lastSyncAt`.
Final merged-tree validation: **423/423 vitest** (62 files), `build:all` clean (only the pre-existing 4MB WASM size warning). Task 7.1 done-criteria sweep: all green.
### Doc-structure redesign (2026-05-30, complete)
Spec: `docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md`
@@ -124,10 +137,12 @@ Plan: `docs/superpowers/plans/2026-05-30-doc-structure-redesign.md` (all 37 sub-
## Up next
The "Up next" queue at v0.6.0 is the three 2026-05-04 architecture-review specs. Each is specced but has no implementation plan yet — writing a plan is the first move on any of them.
Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review specs:
1. **CLI restructure** (spec `2026-05-04-cli-restructure-design.md`) — subcommand reorganization + interactive TUI mode.
2. **Extension restructure** (spec `2026-05-04-extension-restructure-design.md`) — bundle / message-routing cleanup.
3. **Security polish** (spec `2026-05-04-security-polish-design.md`) — follow-up security hardening from the architecture review.
- **CLI restructure** (`2026-05-04-cli-restructure-design.md`) — *already shipped* as Plan B Cycles 1+2 (`b9bd152`, `3dd1e1b`, `3759f6a`, `e69b347`); the last gap (read-side `refresh_groups_cache` callers in list/get) closed in `d717f0d`. Done-criteria all met.
- **Security polish** (`2026-05-04-security-polish-design.md`) — *already shipped* as Stream A Cycle 1 (`89090a8`) plus follow-ups (`0c9387f` start.sh fourth window, `229e483` recovery_qr.rs docs). All four phases done.
- **Extension restructure** (`2026-05-04-extension-restructure-design.md`, plan `docs/superpowers/plans/2026-05-30-extension-restructure.md`) — ✅ **COMPLETE** (all six phases merged; see the dated landing section above). Phases 1/2/5 merged 2026-05-30; Phases 3/4/6 merged 2026-05-31 → 06-01. Final tree: 423/423 vitest, build:all clean. v0.7.0 versions bumped; tag pending.
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.5.0` entry, dated 2026-05-02 — predates the v0.5.1 train work and will be revised when the next tag cuts).
Beyond extension restructure, ROADMAP medium-term holds Phase 4 command palette (no spec yet). Long-term: relay server, mobile.
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers this extension-restructure completion).

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-cli"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
description = "CLI for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -8,7 +8,6 @@ pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;

View File

@@ -12,7 +12,6 @@ pub fn cmd_list(
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
None => None,

View File

@@ -142,7 +142,13 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
///
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
/// not a correctness problem.
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
///
/// Visibility note: this is `pub(crate)` so only `session::after_manifest_change`
/// can call it. The Plan B Phase 4 done-criterion requires every mutating
/// handler to funnel through the wrapper — exposing this helper to commands/
/// would let a caller refresh the cache without updating the manifest, breaking
/// the invariant.
pub(crate) fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
let mut set = std::collections::BTreeSet::<String>::new();
for entry in manifest.items.values() {
if let Some(g) = entry.group.as_ref() {

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-core"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
description = "Core library for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -1,6 +1,6 @@
[package]
name = "relicario-wasm"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
description = "WASM bindings for relicario password manager"
license = "GPL-3.0-or-later"

View File

@@ -0,0 +1,161 @@
# Release Workflow
Unified lifecycle workflow at `.claude/workflows/release.js`.
Invoke from any Claude Code session via the `Workflow` tool.
---
## Actions at a glance
| Action | When | Mode |
|--------|------|------|
| `develop` | Implement plan tasks | `single` (phone/remote) or `multi` (PC, supervised) |
| `verify` | Check tests pass | — |
| `debug` | Fix a failing test or broken feature | — (always sequential) |
| `release` | Cut and tag a version | — |
---
## Add features / implement a plan
### Single-agent (phone-friendly, fire-and-forget)
One agent works through all plan tasks sequentially. Kick off and check the progress tree later.
```js
Workflow({
name: 'release',
args: { action: 'develop', mode: 'single', release: 'v0.5.0' }
})
```
**What happens:**
1. Discovers all plan files matching `v0.5.0`
2. PM agent reads plans, orders tasks respecting dependencies
3. One dev agent per task runs sequentially
4. Full `cargo test` + `cargo build` + `cargo clippy` verify pass
5. Updates `STATUS.md`
### Multi-agent (PC, supervised by PM)
PM reads the plans, decides N dev streams, writes kickoff prompt files. You open the terminals.
```js
Workflow({
name: 'release',
args: { action: 'develop', mode: 'multi', release: 'v0.5.0' }
})
```
**What happens:**
1. Discovers plans
2. PM agent assigns tasks to N dev streams
3. Generates PM + N dev prompt files in `docs/superpowers/coordination/`
4. Prints terminal-open instructions
**Then you:**
```bash
cd tools/relay && ./start.sh # start relay server
# open N+1 terminal windows
# PM window: paste coordination/v0.5.0-pm-prompt.md
# Dev-A window: paste coordination/v0.5.0-dev-a-prompt.md
# Dev-B window: paste coordination/v0.5.0-dev-b-prompt.md
```
The PM supervises devs in real time via the relay. You watch all terminals.
---
## Run tests only
```js
Workflow({ name: 'release', args: { action: 'verify' } })
```
Runs `cargo test`, `cargo build --all-targets`, `cargo clippy`. Returns pass/fail summary.
---
## Debug iteration
After you find a broken test or unexpected behavior, hand the failure context to the debug action. It loops up to 5 times: hypothesize → read code → fix → verify → commit.
```js
Workflow({
name: 'release',
args: {
action: 'debug',
context: 'cargo test output:\n...<paste failing test output here>...'
}
})
```
Returns `{ status: "fixed", iterations: N }` when clean, or `{ status: "max-iterations" }` if it needs your eyes.
---
## Cut a release
Runs verify first; blocked if tests fail.
Writes CHANGELOG, updates STATUS + ROADMAP, creates annotated tag.
**Stops before pushing** — you confirm manually.
```js
Workflow({
name: 'release',
args: { action: 'release', release: 'v0.5.0' }
})
```
After it stops, review the tag then:
```bash
git push && git push --tags
```
---
## Full lifecycle example
```
1. DEVELOP features
Workflow({ name:"release", args:{ action:"develop", mode:"single", release:"v0.6.0" } })
2. VERIFY manually (you run the extension in browser, test your flows)
3. DEBUG any failures you find
Workflow({ name:"release", args:{ action:"debug", context:"<paste failure>" } })
# repeat as needed
4. VERIFY again to confirm clean
Workflow({ name:"release", args:{ action:"verify" } })
5. RELEASE
Workflow({ name:"release", args:{ action:"release", release:"v0.6.0" } })
# review tag, then: git push && git push --tags
```
---
## Phone vs PC
| Scenario | Recipe |
|----------|--------|
| Kick off a release from your phone / remote session | `develop` + `mode:"single"` — fires in background, check `/workflows` |
| At your PC, want to supervise and intervene | `develop` + `mode:"multi"` — generates prompts, open terminals |
| Quick sanity check | `verify` |
| Fixing a bug you found while testing | `debug` with failure context |
| Cutting and tagging | `release` — always confirms before push |
---
## Plan file discovery
The `develop` action scans `docs/superpowers/plans/` for files whose filename or opening lines reference the release label. To be explicit, pass plan paths directly (not yet wired — add `args.plans` if needed).
---
## Relay server roles
The relay at `localhost:7331` supports roles: `pm`, `dev-a`, `dev-b`, `dev-c`.
Start it before opening terminal sessions: `cd tools/relay && ./start.sh`
See `docs/superpowers/coordination/RELAY.md` for protocol details.

View File

@@ -0,0 +1,193 @@
# Dev-A Kickoff Prompt — Relicario extension-restructure (Phase 3)
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are **Dev-A** for the Relicario extension-restructure release.
**Goal:** Own Phase 3 in its entirety — migrating the setup wizard's direct WASM orchestration into the service worker as two new SW handlers (`create_vault` and `attach_vault`), then converting the six `renderStepN`/`attachStepN` pairs into the `SetupStep` step-registry pattern and adding `clearWizardState`. This is the largest single phase: seven tasks, heavy orchestration logic, and builds on Phase 1's typed `StateHost` foundation (already shipped).
**Architecture:** Phase 3 is entirely in the extension. `setup.ts` shrinks from ~1220 LOC to ~500 LOC. No Rust crates, no `relicario-wasm` WASM surface, and no new runtime dependencies are added.
**Tech Stack:** TypeScript, vitest + happy-dom, webpack.
---
## Setup — run these FIRST
```bash
git -C /home/alee/Sources/relicario worktree add /home/alee/Sources/relicario.ext-restructure-a -b feature/extension-restructure-phase-a
```
Then confirm the worktree exists:
```bash
ls /home/alee/Sources/relicario.ext-restructure-a
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.ext-restructure-a`.**
Every subagent prompt MUST begin with:
```
cd /home/alee/Sources/relicario.ext-restructure-a &&
```
Never rely on working-directory headers alone — subagents ignore them.
---
## Already-shipped context
- **Phase 1** (typed `StateHost` + `__resetHostForTests`): MERGED to main.
- **Phase 2** (SW router helpers extracted to `storage.ts` + `vault.ts`): MERGED to main.
- **Phase 5** (5 P2 fixes): MERGED to main.
- Baseline: **389/389 vitest tests pass** on main as of the start of this session.
- Do NOT re-do any Phase 1, 2, or 5 work. If you find those files already updated, that is expected — proceed.
---
## Required reading
Read these before touching any code:
1. `/home/alee/Sources/relicario.ext-restructure-a/CLAUDE.md` — project rules (Spanish sprinkle in replies; auto-yes on recommended options; pause before destructive ops)
2. `/home/alee/Sources/relicario.ext-restructure-a/docs/superpowers/plans/2026-05-30-extension-restructure.md` — the full plan; Phase 3 is Tasks 3.1-3.7
3. `/home/alee/Sources/relicario.ext-restructure-a/extension/ARCHITECTURE.md` — bundle structure, SW↔popup contract, component architecture
4. `/home/alee/Sources/relicario.ext-restructure-a/extension/src/setup/setup.ts` — read fully before Task 3.2; the SW handlers must mirror this orchestration exactly
---
## Execution mode
Use **`superpowers:subagent-driven-development`**. Spawn a fresh subagent per task. Two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.ext-restructure-a &&`.
---
## Scope — own exactly this
**Phase 3 (Tasks 3.1-3.7):**
| Task | Summary |
|---|---|
| 3.1 | Add `create_vault` / `attach_vault` / `get_vault_status` to `messages.ts` |
| 3.2 | Implement `create_vault` SW handler in `service-worker/vault.ts` + tests |
| 3.3 | Implement `attach_vault` SW handler in `service-worker/vault.ts` + tests |
| 3.4 | Delete WASM dynamic-import + `loadWasm` + `verifiedHandle` from `setup.ts` |
| 3.5 | Replace WASM calls with `sendMessage(create_vault / attach_vault)` + convert `renderStepN`/`attachStepN` pairs to `SetupStep` step-registry |
| 3.6 | Add `clearWizardState()` + `beforeunload` binding + call on `goto('mode')` |
| 3.7 | Update setup tests to assert on step-registry shape; add `clearWizardState` test |
**Out of scope — do not touch:**
- Phase 4 (Tasks 4.1-4.7): vault.ts split into 5 focused modules
- Phase 6 (Tasks 6.1-6.3): `get_vault_status` parity feature (vault-status.ts + sidebar indicator)
If you find bugs outside Phase 3 scope, file a `## QUESTION TO PM` block and relay it. Do not fix them yourself.
---
## Hard rules
- **Maintain or grow the 389-test baseline.** No vitest regressions. If a task temporarily breaks tests (Tasks 3.4 and 3.5 do — by design, before 3.7 fixes them), track it explicitly and fix before the final commit.
- **TDD for new logic.** Write failing tests before implementing `create_vault` and `attach_vault` handlers (Tasks 3.2, 3.3).
- **Commit after each logical step.** Per the plan's commit messages: Task 3.1 = one commit; Task 3.2 = one commit; Task 3.3 = one commit; Tasks 3.4-3.7 = one cohesive commit (the plan bundles them because they only compile together).
- **Do not merge to main.** The PM owns merges.
- **Do not re-use `git amend` on previous commits.** Always create new commits.
- **Do not skip hooks (`--no-verify`).**
---
## Relay server
Relay runs at `localhost:7331`. Your identity is `from="dev-a"`.
Read your inbox with this Python shim (run from any directory):
```bash
cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-a"}'
```
Post to PM:
```bash
cd /home/alee/Sources/relicario/tools/relay && python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
```
Recipients: `pm`, `dev-a`, `dev-b`. Read your inbox before each task. Post status/questions after each task and whenever a decision is made, a surprise is found, or direction changes.
---
## STATUS UPDATE format
Print locally AND relay to `pm` after every task and at each meaningful moment:
```
## STATUS UPDATE — DEV-A
Time: <iso8601>
Task: <N of 7>
Status: COMPLETE | IN-PROGRESS | BLOCKED
Notes: <what you did + why, 3 sentences max>
Next: <next task or "waiting for PM">
```
---
## Narration discipline
Emit IN-PROGRESS updates (locally and relayed) at:
- Each subagent dispatched
- Each significant decision made (e.g., "chose to export `__test__` for test-only access rather than polluting the public API")
- Each surprise found (unexpected type error, missing stub, existing test that conflicts)
- Any direction change mid-task
---
## Task detail reference
The full task steps (including exact code snippets, grep commands, and commit messages) live in:
```
/home/alee/Sources/relicario.ext-restructure-a/docs/superpowers/plans/2026-05-30-extension-restructure.md
```
Sections: `## Phase 3 — Setup wizard SW migration + step registry (P1.4)` through `### Task 3.7`.
Key orchestration note for Tasks 3.2 and 3.3: the SW handlers must mirror the exact sequence currently in `setup.ts`. Read `setup.ts` fully before implementing — the plan cannot enumerate every line because `setup.ts` is the source of truth.
---
## Final verification
After all seven tasks are committed, run:
```bash
cd /home/alee/Sources/relicario.ext-restructure-a && pnpm --filter extension test && pnpm --filter extension build
```
All 389+ tests must pass. Build must be clean.
---
## Pull request
When tests and build are clean:
```bash
gh pr create --base main --title "feat(extension): restructure Phase 3 (Tasks 3.1-3.7): add create_vault/attach_vault/get_vault_status to messages.ts; implement create_vault SW handler + tests; implement attach_vault SW handler + tests; delete WASM imports/loadWasm/verifiedHandle from setup.ts; replace WASM calls with sendMessage + step-registry conversion; add clearWizardState + beforeunload binding; update setup tests + add clearWizardState test — Dev-A"
```
Return the PR URL in a STATUS UPDATE to PM.
---
## First action
1. Run the worktree setup command above.
2. Confirm the worktree path exists.
3. Emit a STATUS UPDATE: Task 0 of 7 / Status: COMPLETE / Notes: Worktree created at /home/alee/Sources/relicario.ext-restructure-a on branch feature/extension-restructure-phase-a. / Next: Task 3.1 — add message types.
4. Relay that status to pm.
5. Read your inbox (`read_messages for="dev-a"`).
6. Start Task 3.1.

View File

@@ -0,0 +1,247 @@
# Dev-B Kickoff Prompt — extension-restructure (Phase 4 + Phase 6)
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are **Dev-B** for the Relicario **extension-restructure** release.
**Goal:** Own Phase 4 and Phase 6 in sequence. Phase 4 splits the 1027-LOC `vault.ts` monolith into five focused modules (`vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`) and lifts the `vault_locked` RPC intercept into `shared/state.ts`, building on the Phase 1 `StateHost` foundation that is already shipped. Phase 6 closes the CLI/extension parity gap by implementing the `get_vault_status` SW handler and wiring the sidebar status indicator — it depends on the `vault-sidebar.ts` module that Phase 4 produces.
**Architecture:** TypeScript extension only. No Rust crates touched. All new modules live in `extension/src/vault/` (Phase 4) and `extension/src/service-worker/` (Phase 6). The `StateHost` foundation (`shared/state.ts`, typed `PopupState`, `__resetHostForTests`) was shipped in Phase 1 and is already on `main`. Do not redo it.
**Tech Stack:** TypeScript, vitest + happy-dom, webpack, Rust core via WASM (no new WASM entry points needed).
---
## Step 0 — Worktree setup (do this FIRST, before anything else)
```bash
git -C /home/alee/Sources/relicario worktree add /home/alee/Sources/relicario.ext-restructure-b -b feature/extension-restructure-phase-b
```
Then all subsequent work happens in `/home/alee/Sources/relicario.ext-restructure-b`.
**ALL subagent prompts MUST begin with:**
```
cd /home/alee/Sources/relicario.ext-restructure-b &&
```
Never rely on working-directory headers alone — subagents may commit to `main` if they do not force-cd into the worktree at prompt start.
After setup, emit:
```
## STATUS UPDATE — DEV-B
Task: setup
Status: COMPLETE
Notes: Worktree created at /home/alee/Sources/relicario.ext-restructure-b on branch feature/extension-restructure-phase-b. Baseline test count confirmed.
Next: Phase 4 Task 4.1
```
Post this update to the relay (see Relay section below).
---
## Already-shipped context
Phases 1, 2, and 5 have been merged to `main`. The following are done — do not redo:
- `shared/popup-state.ts``View` + `PopupState` types extracted
- `shared/state.ts` — typed `StateHost` with `registerHost`, `__resetHostForTests`, `sendMessage` wrapper
- `shared/__tests__/state.test.ts` — 7 StateHost tests
- `service-worker/storage.ts``loadDeviceSettings`, `saveDeviceSettings`, `loadBlacklist`, `saveBlacklist`
- Phase 5 P2 fixes (inactivity-timer invert, `Promise.allSettled` in devices/trash, MutationObserver debounce, `teardownSettingsCommon`, WASM stub rounding-out)
**Baseline:** 389/389 vitest tests pass on `main`. You must maintain or grow this count. Never let tests regress.
---
## Required reading
Before writing any code, read:
1. `CLAUDE.md` — project rules (always applies)
2. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — authoritative plan; Phase 4 and Phase 6 task details are defined there
3. `extension/ARCHITECTURE.md` — bundle structure, SW message protocol, component architecture
4. `extension/src/vault/vault.ts` — the 1027-LOC monolith you will split (read it in full before Task 4.1)
5. `extension/src/shared/state.ts` — shipped StateHost contract (Phase 4 lifts `vault_locked` into `sendMessage` here)
---
## Execution mode
Use the **`superpowers:subagent-driven-development`** skill. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.ext-restructure-b &&`.
---
## Scope
### Phase 4 — Split `vault.ts` monolith (Tasks 4.14.7)
You own all seven tasks:
- **Task 4.1** — Extract `vault-shell.ts`: DOM scaffolding, color-scheme apply, `onMessage` wiring
- **Task 4.2** — Extract `vault-sidebar.ts`: sidebar categories, debounced search, nav buttons, status slot wiring
- **Task 4.3** — Extract `vault-list.ts`: list pane rendering and row rendering
- **Task 4.4** — Extract `vault-drawer.ts` + `ensureDrawerClosedForRoute` + `drawer-state.test.ts`
- **Task 4.5** — Extract `vault-form-wrapper.ts`: `renderFormWrapped`, sticky bar, header
- **Task 4.6** — Trim `vault.ts` to ~200 LOC of routing + state (delete everything extracted above)
- **Task 4.7** — Lift `vault_locked` RPC intercept into `shared/state.ts` `sendMessage` + write `state-vault-locked.test.ts`
### Phase 6 — CLI/extension parity: `get_vault_status` (Tasks 6.16.3)
Phase 6 depends on `vault-sidebar.ts` from Phase 4. Do not start Phase 6 until Phase 4 is complete and all tests pass.
- **Task 6.1** — Implement `get_vault_status` SW handler in `extension/src/service-worker/vault.ts` + write `vault-status.test.ts`
- **Task 6.2** — Create `vault-status.ts` renderer (sidebar-footer status indicator) + write `status-indicator.test.ts`
- **Task 6.3** — Wire the status indicator into `vault-sidebar.ts` sidebar footer
### Out of scope
Phase 3 (Tasks 3.13.7) is owned by another developer. Do NOT touch `setup.ts`, `setup/__tests__/setup.test.ts`, or the SW `create_vault` / `attach_vault` handlers. If you need to coordinate on a shared file, post a question to the relay.
---
## Hard rules
- **Maintain or grow the 389-test baseline.** No vitest regressions — ever.
- **TDD for all new logic.** Write the failing test first, then the implementation.
- **Commit after each task** (not each step — one logical commit per task, bundling its files).
- **No `as any` casts.** The typed `StateHost` contract is in place; use it.
- **Do not push or open a PR until both phases are complete and the final test run passes.**
- **Do not merge to `main`.** The PM owns merges.
---
## Relay
A message-bus server is running at `localhost:7331`. Your identity is `from="dev-b"`.
**Python shim (use this to call the relay):**
```bash
cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-b"}'
cd /home/alee/Sources/relicario/tools/relay && python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
```
Recipients: `pm`, `dev-a`, `dev-b`.
**Before each task:** call `read_messages` with `{"for":"dev-b"}` to drain your inbox.
**After each status update:** call `post_message` to relay your STATUS UPDATE block to `pm`.
---
## STATUS UPDATE format
Use this format for every update — print it locally AND relay it to `pm`:
```
## STATUS UPDATE — DEV-B
Task: <task id, e.g. 4.1>
Status: COMPLETE | IN-PROGRESS | BLOCKED
Notes: <what was done, why the approach was taken, any surprise found — 3 sentences max>
Next: <next task id or "waiting for PM">
```
Emit IN-PROGRESS updates at meaningful moments: when a subagent is dispatched, a key architectural decision is made, a surprise is found, or a direction change occurs. Do not wait for phase boundaries.
---
## Phase 4 task details
Refer to `docs/superpowers/plans/2026-05-30-extension-restructure.md` for the full step-by-step breakdown of each task. The plan is authoritative. Below is a summary of what each task produces to orient you before you read the plan:
**Task 4.1 — `vault-shell.ts`**
Extracts: the `initVaultShell(container)` bootstrapper, `applyColorScheme()`, `document.addEventListener('message', ...)` wiring. `vault.ts` imports `initVaultShell` and calls it at startup.
**Task 4.2 — `vault-sidebar.ts`**
Extracts: `renderSidebar(container, state)`, debounced search input handler, category nav button click wiring, and a `<div class="vault-sidebar__status">` slot at the footer (empty until Phase 6 Task 6.3). Exports `renderSidebar` and `updateSidebarStatus(text: string)`.
**Task 4.3 — `vault-list.ts`**
Extracts: `renderList(container, entries, state)` and `renderRow(entry, state)`. The list pane is a pure render function — no side effects beyond DOM mutation.
**Task 4.4 — `vault-drawer.ts` + drawer tests**
Extracts: `openDrawer(view)`, `closeDrawer()`, `renderDrawerContent(view, state)`, and `ensureDrawerClosedForRoute(route)` (closes the drawer automatically when navigating to list/unlock). Creates `extension/src/vault/__tests__/drawer-state.test.ts` covering the auto-close behavior.
**Task 4.5 — `vault-form-wrapper.ts`**
Extracts: `renderFormWrapped(container, title, renderBody)` — the sticky-header + save-bar scaffold used by add/edit/detail views.
**Task 4.6 — Trim `vault.ts` to ~200 LOC**
After extracting all the above, `vault.ts` should contain only: route dispatch (`handleRoute`), top-level state management (`initVault`, `setState`), and import wiring. Delete the extracted code. Run the full test suite to confirm nothing broke.
**Task 4.7 — Lift `vault_locked` intercept into `shared/state.ts`**
The pre-Phase-4 `vault.ts` has a `vault_locked` channel intercept inside its local `sendMessage` wrapper. Lift this into the `sendMessage` export in `shared/state.ts` (Phase 1 left a placeholder comment there). Write `extension/src/shared/__tests__/state-vault-locked.test.ts` that:
- registers a mock host
- dispatches a `sendMessage` that returns `{ ok: false, error: 'vault_locked' }`
- asserts that `navigate('unlock')` was called on the host
- asserts the original rejection is re-thrown (or rethrown as appropriate per the existing intercept logic)
---
## Phase 6 task details
Do not start Phase 6 until Phase 4 is fully committed and all 389+ tests pass.
**Task 6.1 — `get_vault_status` SW handler**
Add a `get_vault_status` case to `extension/src/service-worker/vault.ts`. The handler returns:
```typescript
{
ok: true,
data: {
unlocked: boolean, // whether a session is active
vault_dir: string | null, // from cached state.vaultDir
git_host: string | null, // from cached state.gitHost
item_count: number, // manifest entry count or 0
}
}
```
Add `get_vault_status` to `extension/src/shared/messages.ts` as a new `Request` variant.
Write `extension/src/service-worker/__tests__/vault-status.test.ts` covering: unlocked path, locked path, and missing-vault path.
**Task 6.2 — `vault-status.ts` renderer**
Create `extension/src/vault/vault-status.ts` with:
```typescript
export function renderVaultStatus(container: HTMLElement, status: VaultStatusData): void;
```
The renderer fills `container` with a one-line status indicator: a colored dot + short text (`Unlocked · 42 items` or `Locked` or `No vault`). Write `extension/src/vault/__tests__/status-indicator.test.ts` covering all three states with happy-dom.
**Task 6.3 — Wire indicator into `vault-sidebar.ts`**
At sidebar boot, call `sendMessage({ type: 'get_vault_status' })` and pass the result to `renderVaultStatus(statusSlot, data)`. Re-fetch on every `setState` call so the count stays current. The status slot element (`<div class="vault-sidebar__status">`) was created in Task 4.2.
---
## Final verification
Before opening a PR, run:
```bash
cd /home/alee/Sources/relicario.ext-restructure-b && pnpm --filter extension test && pnpm --filter extension build
```
All tests must pass. Build must be clean. Post your final STATUS UPDATE to `pm` with Status: COMPLETE.
---
## Opening the PR
Once both phases are complete and the final run passes:
```bash
gh pr create --base main --title "feat(extension): restructure Phase 4 (Tasks 4.1-4.7): extract vault-shell.ts; extract vault-sidebar.ts with debounced search; extract vault-list.ts; extract vault-drawer.ts + ensureDrawerClosedForRoute + drawer-state tests; extract vault-form-wrapper.ts; trim vault.ts to ~200 LOC routing+state; lift vault_locked intercept into shared/state.ts + state-vault-locked tests+Phase 6 (Tasks 6.1-6.3): implement get_vault_status SW handler + vault-status.test.ts; create vault-status.ts renderer + status-indicator tests; wire indicator into vault-sidebar.ts sidebar footer — Dev-B"
```
Return the PR URL in your final STATUS UPDATE.
---
## First action
1. Run the worktree setup command above.
2. Confirm the baseline: `cd /home/alee/Sources/relicario.ext-restructure-b && pnpm --filter extension test 2>&1 | tail -5`
3. Emit STATUS UPDATE "setup complete" locally and relay it to `pm`.
4. Begin Phase 4 Task 4.1 by reading `extension/src/vault/vault.ts` in full, then dispatching a subagent.

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Auto-generated by release workflow — extension-restructure
set -e
REPO="/home/alee/Sources/relicario"
RELAY_DIR="$REPO/tools/relay"
COORD="$REPO/docs/superpowers/coordination"
RELEASE="extension-restructure"
SESSION="$RELEASE"
# ── 1. Relay ─────────────────────────────────────────────────────────────
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
echo "[relay] already running on :7331"
else
echo "[relay] starting..."
cd "$RELAY_DIR"
nohup npx tsx server.ts > /tmp/relay-extension-restructure.log 2>&1 &
for i in $(seq 1 10); do
sleep 1
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
echo "[relay] ready on :7331"
break
fi
if [ "$i" -eq 10 ]; then
echo "[relay] ERROR: failed to start — check /tmp/relay-extension-restructure.log"
exit 1
fi
done
fi
# ── 2. tmux session ──────────────────────────────────────────────────────
if tmux has-session -t "$SESSION" 2>/dev/null; then
echo "[tmux] session '$SESSION' already exists — attaching"
exec tmux attach-session -t "$SESSION"
fi
echo "[tmux] creating session '$SESSION'..."
tmux new-session -d -s "$SESSION" -n "PM"
tmux send-keys -t "$SESSION:PM" "claude" Enter
tmux new-window -t "$SESSION" -n "Dev-A"
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
tmux new-window -t "$SESSION" -n "Dev-B"
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
tmux select-window -t "$SESSION:PM"
echo ""
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ extension-restructure — prompt cheatsheet ║"
echo "╠══════════════════════════════════════════════════════════════════╣"
echo "║ PM window → paste $COORD/$RELEASE-pm-prompt.md ║"
echo "║ Dev-A window → paste $COORD/$RELEASE-dev-a-prompt.md ║"
echo "║ Dev-B window → paste $COORD/$RELEASE-dev-b-prompt.md ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
exec tmux attach-session -t "$SESSION"

View File

@@ -0,0 +1,184 @@
# PM Kickoff Prompt — Relicario extension-restructure (Phases 3, 4, 6)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **PM for the Relicario extension-restructure release (Phases 3, 4, 6)**. Two senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all three terminals; a relay server routes messages between you so the user does not need to copy-paste directives.
## Working directory
`/home/alee/Sources/relicario`
Stay on `main` in your own session. Do not check out feature branches. All file reads are against `main`. All doc/CHANGELOG edits happen here too.
## Required reading (read in this order before acting)
1. `CLAUDE.md` — project rules. Pay attention to: Spanish flourish in chat replies only, product name capitalization ("Relicario"), "default to yes" autonomy, never run destructive git ops without asking the user.
2. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the canonical implementation plan for this release. Phases 3, 4, and 6 are the live work. Phases 1, 2, and 5 are already merged (do not re-do them).
3. `extension/ARCHITECTURE.md` — bundle structure, SW ↔ popup message contract, component/pane architecture. Required to review PRs intelligently.
## Already-shipped context
Phases 1, 2, and 5 merged into `main` as of commit `8249f9e` (docs update) and `c3f8e35` (Phase 1 merge). The typed `StateHost` foundation (Phase 1) is in `extension/src/shared/state.ts` now. Phase 2 consolidated `storage.ts` and `itemToManifestEntry`. Phase 5 shipped the five P2 fixes (inactivity-timer inversion, `state.gitHost` clear, `teardownSettingsCommon`, `Promise.allSettled`, detector debounce).
**Current test baseline: 389/389 vitest passing.** This is the floor. Neither dev may land a PR that drops below this.
Do NOT re-implement any Phase 1, 2, or 5 work. If a dev proposes a change that touches already-shipped territory without a clear regression-fix justification, push back.
## Your authority
- Approve or deny scope changes from devs.
- Review PRs: run `gh pr view <n>` and `gh pr diff <n>` before approving.
- Write the CHANGELOG entry summarizing what shipped (the extension-restructure section).
- Request a tag once all done-criteria pass — **tag requires explicit user approval before you run `git tag`**.
- Edit `STATUS.md` and `ROADMAP.md` once all streams land.
- Run the final Task 7.1 verification sweep yourself (see Pre-tag checklist below).
## Your boundaries
- Write NO feature code. Editing `CHANGELOG.md`, `STATUS.md`, `ROADMAP.md`, and coordination docs is fine.
- Run NO destructive git operations (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`) without explicit user confirmation.
- Do not approve a PR until the dev signals `REVIEW-READY` in the relay.
- Do not tag without user approval.
- If you are uncertain about a PR's correctness, invoke the `superpowers:requesting-code-review` skill before approving.
## Relay server
A message-bus server is running at `localhost:7331`. Three native MCP tools are available in your session:
- `post_message(from, to, kind, body)` — push a message. Your `from` is always `"pm"`.
- `read_messages(for)` — drain your inbox. Call with `for="pm"`.
- `list_pending(for)` — check inbox count without consuming.
Recipients: `pm`, `dev-a`, `dev-b`.
**Python shim fallback** (use if MCP tools are not registered — this happens when the relay was not running when your session opened):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
python3 call.py read_messages '{"for":"pm"}'
python3 call.py list_pending '{"for":"pm"}'
```
The shim connects over HTTP and has identical semantics to the MCP tools. Narrate what you are doing between tool calls so the user can follow your reasoning.
## Dev roster
| Role | Branch | Worktree path | Scope |
|---|---|---|---|
| Dev-A | `feature/extension-restructure-phase-3` | `/home/alee/Sources/relicario/.worktrees/ext-restructure-phase-3` | Phase 3 entirely: migrate setup wizard's direct WASM orchestration into two new SW handlers (`create_vault`, `attach_vault`); convert the six `renderStepN`/`attachStepN` pairs into the `SetupStep` step-registry pattern; add `clearWizardState`. Tasks 3.13.7. Depends on Phase 1's typed `StateHost` (already shipped). |
| Dev-B | `feature/extension-restructure-phase-4-6` | `/home/alee/Sources/relicario/.worktrees/ext-restructure-phase-4-6` | Phase 4 then Phase 6 in sequence: Phase 4 splits the 1027-LOC `vault.ts` monolith into five focused modules (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`) and lifts the `vault_locked` RPC intercept into `shared/state.ts`. Tasks 4.14.7. Then Phase 6 adds the `get_vault_status` SW handler and wires the sidebar status indicator. Tasks 6.16.3. Phase 6 depends on the `vault-sidebar.ts` module that Phase 4 produces — Dev-B must fully merge Phase 4 before starting Phase 6. |
Both branches fork from the current `main` tip (commit `9fc07c3`).
## Coordination protocol
### DIRECTIVE block format
When you send work instructions to a dev, structure the relay body like this:
```
DIRECTIVE [phase/task]
---
<concise instruction — what to do, what files to touch, what to verify>
DONE SIGNAL: Reply with REVIEW-READY + PR number when complete.
```
### RELEASE STATUS rollup format
When reporting status to the user (or to yourself at phase boundaries), use:
```
RELEASE STATUS — extension-restructure [date]
Phase 3 (Dev-A): [NOT STARTED | IN PROGRESS task N | REVIEW-READY | MERGED]
Phase 4 (Dev-B): [NOT STARTED | IN PROGRESS task N | REVIEW-READY | MERGED]
Phase 6 (Dev-B): [NOT STARTED — waiting on Phase 4 | IN PROGRESS task N | REVIEW-READY | MERGED]
Test baseline: [389 | current count] vitest passing
Blockers: [none | describe]
Next PM action: [describe]
```
Emit a RELEASE STATUS rollup:
- After absorbing required reading (your first action).
- Whenever a dev signals `REVIEW-READY`.
- After each PR merge.
- When a blocker surfaces.
## Merge-order safety rules (enforce strictly)
1. **Dev-B must fully merge Phase 4 before starting Phase 6.** `vault-sidebar.ts` is the wiring target for Phase 6's `get_vault_status` status indicator. If Dev-B opens a Phase 6 PR while Phase 4 is still open, reject it.
2. **Both devs depend on Phase 1's typed `StateHost` foundation (already on `main` at `c3f8e35`).** If either dev's branch diverges from current `main` before starting, ask them to rebase.
3. **Phase 3 and Phase 4 are independent of each other** — they can proceed in parallel. Dev-A and Dev-B may work simultaneously.
4. **Do not let either dev touch** `extension/src/wasm.d.ts` unless they have a concrete compilation error that demands it. The plan explicitly states this file is untouched for this release.
## PR review process
1. Dev signals `REVIEW-READY` with a PR number in the relay.
2. You run `gh pr view <n>` to read the description.
3. You run `gh pr diff <n>` to read the diff.
4. Check that the PR only touches files in the plan's scope for that phase.
5. Check the vitest count in the PR CI (or ask the dev to paste `npx vitest run` output).
6. If uncertain about correctness, invoke the `superpowers:requesting-code-review` skill before approving.
7. Approve with `gh pr review <n> --approve` and then merge with `gh pr merge <n> --merge`.
8. Post `DIRECTIVE` to dev confirming merge and what to do next.
## Pre-tag checklist (Task 7.1 — you run this yourself)
Run all of the following from `/home/alee/Sources/relicario/extension` after both Phase 3 and Phase 4+6 PRs are merged:
```bash
# 1. TypeScript clean build
npx tsc --noEmit 2>&1 | tail -5
# Expected: no output
# 2. Full vitest suite
npx vitest run
# Expected: all 389+ tests pass (count must equal or exceed baseline)
# 3. Production webpack build
npm run build:all 2>&1 | tail -5
# Expected: both Chrome + Firefox targets compile with no errors
# (only the pre-existing 4 MB WASM size warning is acceptable)
```
Then run the done-criteria checklist from the plan's Task 7.1 (lines 25492597 of `docs/superpowers/plans/2026-05-30-extension-restructure.md`). Key grep checks:
```bash
# No as-any in shared/state.ts public surface
grep -c ": any\|<any>" extension/src/shared/state.ts
# Router files have no duplicated storage helpers
grep -c "function loadDeviceSettings\|function loadBlacklist\|function saveBlacklist" extension/src/service-worker/router/*.ts
# setup.ts does not import relicario-wasm directly
grep -c "relicario-wasm" extension/src/setup/setup.ts
# SW handles all three new messages
grep -c "case 'create_vault'\|case 'attach_vault'\|case 'get_vault_status'" extension/src/service-worker/router/popup-only.ts
# vault.ts does not contain the vault_locked intercept
grep -c "vault_locked" extension/src/vault/vault.ts
# Sidebar search is debounced
grep "SEARCH_DEBOUNCE_MS" extension/src/vault/vault-sidebar.ts
```
All of the above must pass. If any check fails, send the dev a DIRECTIVE to fix it before tagging.
Once all checks pass:
1. Write the CHANGELOG entry (under a new `## [Unreleased]` or the appropriate version header).
2. Update `STATUS.md`: move extension-restructure from in-flight to shipped.
3. Update `ROADMAP.md`: advance the pointer to whatever comes next.
4. Commit those docs: `git add CHANGELOG.md STATUS.md ROADMAP.md && git commit -m "docs: extension-restructure (Phases 3+4+6) complete; update STATUS/ROADMAP/CHANGELOG"`
5. **Ask the user for approval before tagging.**
## Your first action
Do these steps in order:
1. Read `CLAUDE.md`, then `docs/superpowers/plans/2026-05-30-extension-restructure.md`, then `extension/ARCHITECTURE.md`.
2. Emit a RELEASE STATUS block confirming you have absorbed the context (include the current main tip commit hash from `git log --oneline -1`).
3. Drain your relay inbox: `read_messages(for="pm")` — note any pending messages from devs.
4. Send a DIRECTIVE to Dev-A kicking off Phase 3, and a DIRECTIVE to Dev-B kicking off Phase 4. Both can start in parallel. Remind Dev-B that Phase 6 must wait until Phase 4 is fully merged.

View File

@@ -0,0 +1,174 @@
# Dev A Kickoff Prompt — v0.7.0 Plan A (Phase 3)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Plan A for the v0.7.0 "finish the extension restructure" release.
Your plan is **Phase 3 — Setup wizard SW migration + step registry** (Tasks 3.13.7) of the extension restructure. You move all setup-wizard crypto orchestration out of `setup.ts` and into the service worker behind three new messages (`create_vault`, `attach_vault`, `get_vault_status`), collapse the six `renderStepN`/`attachStepN` pairs into a `SetupStep` registry, and add `clearWizardState()`. `setup.ts` drops from ~1220 LOC to ≤500 and no longer imports `relicario-wasm`. This is the biggest single phase (effort: L). Phase 1 (the typed `StateHost` foundation you depend on) is already merged.
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard -b phase-c-3-setup-wizard
cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
```
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard`** before any other instruction — otherwise the subagent may commit to main.
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-a"}'
```
**Common pitfalls (avoid):**
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 3 / P1.4 only**)
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 3, Tasks 3.13.7**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
## Execution mode
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario/.worktrees/phase-c-3-setup-wizard
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:** Phase 3 Tasks 3.13.7 — `messages.ts` additions (`create_vault`, `attach_vault`, `get_vault_status` request shapes + response interfaces + `POPUP_ONLY_TYPES`), `create_vault` + `attach_vault` SW handlers in `service-worker/vault.ts`, dispatch wiring in `service-worker/router/popup-only.ts`, WASM-stub round-out, deletion of WASM orchestration from `setup.ts`, the `SetupStep` step registry, `clearWizardState`, and the setup test updates.
**Out of scope:** Phase 4 (Dev-B owns `vault.ts` split + `vault_locked` lift) and Phase 6 (Dev-C owns the `get_vault_status` *handler*, *renderer*, and *sidebar wiring*). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- **You own `extension/src/shared/messages.ts` for this release.** Task 3.1 adds all three new request types — including `get_vault_status`, which Dev-C (Phase 6) will *consume* but not redefine. Land Task 3.1 early so Dev-C is unblocked; tell the PM the moment it's committed/merged so they can clear Dev-C.
- You add the `create_vault` and `attach_vault` *handlers* to `service-worker/vault.ts`; Dev-C adds the `get_vault_status` handler to the same file. Coordinate via PM — your Phase 3 should merge before Dev-C's SW handler to minimize conflict on the import block / dispatch switch.
- The crypto orchestration body (embed_image_secret → unlock → register_device → manifest_encrypt for create; extract_image_secret → unlock → register_device for attach) must be copied from the *existing* `setup.ts` flow verbatim — do not invent new steps. `setup.ts` is the source of truth for the exact sequence.
- Follow Plan A's `.free()` policy: every `SessionHandle.free()` must be preceded by `wasm.lock(handle)`. The handler's `finally` block locks-then-frees only if it still owns the handle.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task. The `Notes` field narrates WHAT happened and WHY — not just "Phase X done". Three sentences max; quality over length. Print every STATUS UPDATE locally before/after sending it.
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
```
## STATUS UPDATE — DEV-A
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
Branch: phase-c-3-setup-wizard
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line of message>
Tests: <green | red (which failed) | N/A>
Notes: <anything PM needs to know — keep to 3 sentences max>
```
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
```
## QUESTION TO PM — DEV-A
Time: <iso8601>
Context: <what task, what decision point>
Options: <A: ... / B: ... / C: ...>
Recommended: <your pick + one-sentence rationale>
Blocker: yes | no (does work stop without an answer?)
```
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
**Speed without spaghetti — required before every REVIEW-READY:**
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
- Do not create parallel implementations of an existing helper. If you write similar code twice, extract.
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
- Default to no comments unless the WHY is non-obvious.
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
## Authority within the plan
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging (don't fudge — debug); a discovered bug not in your plan; anything destructive; before opening the PR for review.
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
```
Then push and open the PR:
```bash
git push -u origin phase-c-3-setup-wizard
gh pr create --base main --head phase-c-3-setup-wizard --title "feat(ext): Plan C Phase 3 — setup wizard SW migration + step registry" --body "$(cat <<'EOF'
## Plan C Phase 3 — Setup wizard SW migration + step registry
Part of v0.7.0 (finish the extension restructure). Implements Phase 3 (Tasks 3.13.7) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`.
### What changed
- `shared/messages.ts`: added `create_vault`, `attach_vault`, `get_vault_status` request shapes + response interfaces; +3 to `POPUP_ONLY_TYPES`.
- `service-worker/vault.ts`: `handleCreateVault` + `handleAttachVault` (SW now owns the crypto orchestration lifted from setup.ts).
- `service-worker/router/popup-only.ts`: dispatch cases for the new messages.
- `setup/setup.ts`: dropped direct WASM orchestration + `loadWasm` + `verifiedHandle`; six `renderStepN`/`attachStepN` pairs collapsed into the `SetupStep` registry; added `clearWizardState()` bound to `beforeunload` + `goto('mode')`. ~1220 LOC → ≤500.
- Tests: `service-worker/__tests__/vault.test.ts`, updated `setup/__tests__/setup.test.ts` (step-registry shape + clearWizardState).
### Coordination notes
- This PR owns the only `messages.ts` change for the release; Dev-C's Phase 6 consumes `get_vault_status` (defined here) without re-declaring it.
- Merge before Dev-C's Phase 6 SW handler to keep the `service-worker/vault.ts` import block / dispatch switch conflict-free.
### Verification
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
- Done-criteria greps from the plan's Task 7.1 pass (`setup.ts` ≤500 LOC, no `relicario-wasm` import, 3 dispatch cases, `clearWizardState` bound).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-3-setup-wizard`). Then — because you own `messages.ts` which Dev-C needs — prioritize Task 3.1 and tell the PM the moment it lands. Then continue with Task 3.2.

View File

@@ -0,0 +1,173 @@
# Dev B Kickoff Prompt — v0.7.0 Plan B (Phase 4)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Plan B for the v0.7.0 "finish the extension restructure" release.
Your plan is **Phase 4 — Split `vault.ts` + lift `vault_locked` channel** (Tasks 4.14.7) of the extension restructure. You split the 1037-LOC `vault.ts` monolith into 5 focused modules — `vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts` — trimming `vault.ts` to ≤~250 LOC of routing + state, add the debounced sidebar search, and lift the `vault_locked` RPC intercept out of `vault.ts` into `shared/state.ts`'s `sendMessage` wrapper (whose signature Phase 1 already laid). Effort: M. Phase 1 (the typed `StateHost` foundation) is already merged.
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split -b phase-c-4-vault-split
cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
```
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split`** before any other instruction — otherwise the subagent may commit to main.
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-b"}'
```
**Common pitfalls (avoid):**
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 4 / P1.5 only**)
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 4, Tasks 4.14.7**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
## Execution mode
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario/.worktrees/phase-c-4-vault-split
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:** Phase 4 Tasks 4.14.7 — create `vault-shell.ts`, `vault-sidebar.ts` (with the 80ms debounced search per DEV-C P2), `vault-list.ts`, `vault-drawer.ts` (incl. `ensureDrawerClosedForRoute` + drawer auto-close on non-list nav), `vault-form-wrapper.ts`; trim `vault.ts` to routing + state ≤~250 LOC; remove the `vault_locked` intercept from `vault.ts` and fill the body of `shared/state.ts`'s `sendMessage` wrapper with it; the drawer-state + (any vault) tests.
**Out of scope:** Phase 3 (Dev-A owns `setup.ts` + `messages.ts` + the `create_vault`/`attach_vault` SW handlers) and Phase 6 (Dev-C owns `get_vault_status` + the `vault-status.ts` renderer + its sidebar-footer wiring). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- **You create `extension/src/vault/vault-sidebar.ts`. Dev-C (Phase 6, Task 6.3) will later modify it to wire the status indicator into the sidebar footer.** To make that handoff clean, when you build `vault-sidebar.ts`, include a clearly-labelled footer slot in the sidebar markup (an empty `<div id="vault-status-slot"></div>` inside a `vault-sidebar__footer` element is fine) even though you don't populate it — leave a one-line comment that Phase 6 wires it. Tell the PM the moment Phase 4 is REVIEW-READY/merged so Dev-C can start Task 6.3.
- The `vault_locked` intercept logic is *moved*, not rewritten: lift the exact behavior from `vault.ts` (the pre-Phase-4 RPC intercept) into `sendMessage` in `shared/state.ts`. After the move, `grep -c "vault_locked" extension/src/vault/vault.ts` must return 0.
- Each module extraction is a no-behavior-change refactor — run `npx vitest run` after each and keep it green. Paste function bodies verbatim from `vault.ts`; don't redesign them.
- Do not touch `shared/messages.ts` — that's Dev-A's file for this release. If you think you need a message change, escalate to PM.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task. The `Notes` field narrates WHAT happened and WHY. Three sentences max; quality over length. Print every STATUS UPDATE locally before/after sending it.
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")` first, then post via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
```
## STATUS UPDATE — DEV-B
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
Branch: phase-c-4-vault-split
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line of message>
Tests: <green | red (which failed) | N/A>
Notes: <anything PM needs to know — keep to 3 sentences max>
```
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
```
## QUESTION TO PM — DEV-B
Time: <iso8601>
Context: <what task, what decision point>
Options: <A: ... / B: ... / C: ...>
Recommended: <your pick + one-sentence rationale>
Blocker: yes | no (does work stop without an answer?)
```
**You'll receive**: `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
**Speed without spaghetti — required before every REVIEW-READY:**
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
- Do not create parallel implementations of an existing helper. If you write similar code twice, extract.
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
- Default to no comments unless the WHY is non-obvious.
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
## Authority within the plan
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; before opening the PR for review.
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
```
Then push and open the PR:
```bash
git push -u origin phase-c-4-vault-split
gh pr create --base main --head phase-c-4-vault-split --title "refactor(ext): Plan C Phase 4 — split vault.ts + lift vault_locked channel" --body "$(cat <<'EOF'
## Plan C Phase 4 — Split vault.ts + lift vault_locked channel
Part of v0.7.0 (finish the extension restructure). Implements Phase 4 (Tasks 4.14.7) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`.
### What changed
- Split the 1037-LOC `vault/vault.ts` into 5 modules: `vault-shell.ts` (DOM scaffolding + color-scheme + onMessage), `vault-sidebar.ts` (categories nav + 80ms debounced search + bottom nav + footer status slot), `vault-list.ts` (list/row rendering), `vault-drawer.ts` (open/close/render + `ensureDrawerClosedForRoute`), `vault-form-wrapper.ts` (`renderFormWrapped` + sticky bar + header).
- `vault.ts` trimmed to ≤~250 LOC of routing + state.
- Lifted the `vault_locked` RPC intercept out of `vault.ts` into `shared/state.ts`'s `sendMessage` wrapper (Phase 1 laid the signature; this fills the body).
- Tests: `vault/__tests__/drawer-state.test.ts` (drawer auto-close on navigation) + state `vault_locked` channel coverage.
### Coordination notes
- `vault-sidebar.ts` ships with an empty footer status slot (`#vault-status-slot`); Dev-C's Phase 6 Task 6.3 wires the indicator into it. Merge this PR before Dev-C's wiring commit.
- No `messages.ts` changes (that's Dev-A's file this release).
### Verification
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
- Done-criteria greps from the plan's Task 7.1 pass (5 `vault-*.ts` modules, `vault.ts` ≤~250 LOC, `vault_locked` count 0 in vault.ts, `SEARCH_DEBOUNCE_MS` present).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-4-vault-split`), then start Task 4.1. Remember to leave the footer status slot in `vault-sidebar.ts` for Dev-C, and ping the PM when you're REVIEW-READY so Dev-C can begin Task 6.3.

View File

@@ -0,0 +1,178 @@
# Dev C Kickoff Prompt — v0.7.0 Plan C (Phase 6)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Plan C for the v0.7.0 "finish the extension restructure" release.
Your plan is **Phase 6 — `get_vault_status` SW handler + sidebar status indicator** (Tasks 6.16.3) of the extension restructure. You add the `get_vault_status` service-worker handler (returning cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` plus a live `pendingItems` count — no network call), build the `vault-status.ts` renderer for the sidebar-footer indicator, and wire it into the sidebar (refresh on mount + a manual ↻ button, **no timer polling**). This closes the last `relicario status` CLI/extension parity gap. Effort: S-M.
**⚠️ Your phase has cross-stream dependencies — read the coordination rules carefully.** Phase 6 depends on Phase 3 (Dev-A) for the `get_vault_status` message type and on Phase 4 (Dev-B) for the `vault-sidebar.ts` module you wire into.
A PM in another terminal coordinates you with the other two senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status -b phase-c-6-vault-status
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
pwd # should print /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
```
**ALL subsequent work happens in `/home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`**. Per project memory (`CLAUDE.md` + the subagent-worktree-cd rule), **every subagent prompt you write MUST start with `cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status`** before any other instruction — otherwise the subagent may commit to main.
Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-c"}'
```
**Common pitfalls (avoid):**
- **Prefer single-line `body` content.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Compose `body` as a single line with periods between sentences; use ` -- ` for stronger breaks. Reserve actual newlines for STATUS UPDATEs you print locally only.
- **Python f-string footgun in inbox-monitor scripts.** If a polling script does `print(f"... {m.get(\"from\")} ...")`, Python errors with `SyntaxError`. Use single quotes inside brace expressions: `{m.get('from')}`.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — spec (your scope is **Phase 6 only**)
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — your plan is **Phase 6, Tasks 6.16.3**. Execute task by task. (Phases 1, 2, 5 are already merged — do not redo them.)
## Execution mode
Use **subagent-driven-development** (project default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario/.worktrees/phase-c-6-vault-status
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:** Phase 6 Tasks 6.16.3 — `handleGetVaultStatus` in `service-worker/vault.ts` + cached `ahead`/`behind`/`lastSyncAt` fields on the git-host state + populating them in the `sync` handler + dispatch wiring in `popup-only.ts`; the `vault-status.ts` renderer + any new glyphs in `shared/glyphs.ts`; wiring the indicator into `vault-sidebar.ts`'s footer (mount + manual refresh). Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
**Out of scope:** Phase 3 (Dev-A owns `setup.ts`, ALL of `messages.ts`, and the `create_vault`/`attach_vault` handlers) and Phase 4 (Dev-B owns the `vault.ts` split, including *creating* `vault-sidebar.ts`). You only *modify* `vault-sidebar.ts` to add the wiring in Task 6.3. If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
**Hard rules — sequencing (this is the crux of your phase):**
- **Do NOT touch `shared/messages.ts`.** Dev-A (Phase 3, Task 3.1) defines the `get_vault_status` request type + `GetVaultStatusResponse` interface. You *import* `GetVaultStatusResponse` from `../shared/messages`; you never declare it. **Before you can compile Task 6.1, Dev-A's Task 3.1 must have landed on main** (or be available to merge). Confirm with the PM at kickoff. If it hasn't landed, ask the PM whether to wait or to proceed against a temporary local type and reconcile at merge — prefer waiting if Dev-A is close.
- **Stage your tasks 6.1 → 6.2 → 6.3.** Tasks 6.1 (SW handler) and 6.2 (renderer) are independent of Phase 4 and you can build them as soon as the `get_vault_status` type exists. **Task 6.3 wires into `vault-sidebar.ts`, which Dev-B (Phase 4) creates — you MUST wait for Dev-B's Phase 4 PR to merge before doing Task 6.3.** Ask the PM to confirm Phase 4 is merged, then pull main into your branch and do the wiring. Dev-B has been told to leave an empty `#vault-status-slot` footer element for you.
- Your `get_vault_status` handler is additive in `service-worker/vault.ts` alongside Dev-A's `create_vault`/`attach_vault` handlers. Expect a possible small merge conflict on the import block / dispatch switch in `service-worker/vault.ts` + `popup-only.ts`; the PM will sequence your SW handler merge after Dev-A's Phase 3.
- **No network in `get_vault_status`** — return cached state only. The spec is explicit: sync is user-initiated. **No timer polling** in the wiring — refresh on mount + manual ↻ button only.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard` / `git branch -D` / `git worktree remove`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of multiple terminals. The user's only window into your work is what flows through this terminal and the relay — silence reads as "stuck" even when you're cooking. Narrate.
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments: when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you change direction or hit something unexpected, when you start a new task, **and especially when you are blocked waiting on Dev-A's or Dev-B's merge** (so the PM knows your idle is a dependency wait, not a stall). The `Notes` field narrates WHAT happened and WHY. Three sentences max. Print every STATUS UPDATE locally before/after sending it.
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")` and also print it here. Format:
```
## STATUS UPDATE — DEV-C
Time: <iso8601 like 2026-05-31T14:30:00-07:00>
Branch: phase-c-6-vault-status
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line of message>
Tests: <green | red (which failed) | N/A>
Notes: <anything PM needs to know — keep to 3 sentences max>
```
**When you need PM input mid-task** (e.g. "is Phase 3's `get_vault_status` type merged yet?" / "is Phase 4 merged so I can do 6.3?"): post via `post_message(kind="question")` with format:
```
## QUESTION TO PM — DEV-C
Time: <iso8601>
Context: <what task, what decision point>
Options: <A: ... / B: ... / C: ...>
Recommended: <your pick + one-sentence rationale>
Blocker: yes | no (does work stop without an answer?)
```
**You'll receive**: `## DIRECTIVE TO DEV-C` blocks from the PM via relay. Acknowledge and act.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny. You can write files, run language tooling, commit, push, and open PRs without confirmation prompts. Move at speed.
**Hard guardrails:** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`. If you genuinely need one, surface a `## QUESTION TO PM` block.
**Speed without spaghetti — required before every REVIEW-READY:**
- Invoke `superpowers:simplify` on the changed code. Either accept its findings (fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes.
- Do not create parallel implementations of an existing helper (reuse `shared/relative-time.ts` for the timestamp; reuse the existing glyph family in `shared/glyphs.ts`).
- Do not add error handling / fallbacks / validation for scenarios that can't happen (project rule). Trust internal code and framework guarantees.
- Default to no comments unless the WHY is non-obvious.
- Half-finished implementations are forbidden. Ship a complete sub-task or surface a `## QUESTION TO PM` block.
## Authority within the plan
You don't need PM permission to: execute task-to-task per the plan, make implementation decisions consistent with plan + spec, write tests, refactor your own code, fix bugs you introduce, push commits to your feature branch.
You **do** escalate to PM when: a scope question outside the plan; a test you can't make green after honest debugging; a discovered bug not in your plan; anything destructive; **the dependency waits (Phase 3 type / Phase 4 sidebar)**; before opening the PR for review.
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd extension && npx tsc --noEmit && npx vitest run && npm run build:all
```
Then push and open the PR:
```bash
git push -u origin phase-c-6-vault-status
gh pr create --base main --head phase-c-6-vault-status --title "feat(ext): Plan C Phase 6 — get_vault_status + sidebar status indicator" --body "$(cat <<'EOF'
## Plan C Phase 6 — get_vault_status + sidebar status indicator
Part of v0.7.0 (finish the extension restructure). Implements Phase 6 (Tasks 6.16.3) of `docs/superpowers/plans/2026-05-30-extension-restructure.md`. Closes the `relicario status` CLI/extension parity gap.
### What changed
- `service-worker/vault.ts`: `handleGetVaultStatus` — returns cached `ahead`/`behind`/`lastSyncAt` from `state.gitHost` + live `pendingItems` from the manifest. No network call.
- `service-worker/git-host.ts`: cached `lastSyncAt`/`ahead`/`behind` fields, populated by the `sync` handler.
- `service-worker/router/popup-only.ts`: `get_vault_status` dispatch case.
- `vault/vault-status.ts`: sidebar-footer indicator renderer (in sync / N ahead / N behind / N pending / never synced); reuses `shared/relative-time.ts` + glyph family.
- `vault/vault-sidebar.ts`: wired the indicator into the footer slot — refresh on mount + manual ↻ button, no timer polling.
- Tests: `service-worker/__tests__/vault-status.test.ts`, `vault/__tests__/status-indicator.test.ts`.
### Coordination notes
- Consumes the `get_vault_status` message type defined by Dev-A's Phase 3 (`messages.ts`); does not redefine it.
- Task 6.3 wiring lands on top of Dev-B's Phase 4 `vault-sidebar.ts` (merged first).
### Verification
- `npx tsc --noEmit` clean · `npx vitest run` green · `npm run build:all` clean (pre-existing 4MB WASM warning only).
- Done-criteria greps from the plan's Task 7.1 pass (`get_vault_status` dispatched + rendered, no network in handler, no polling timer).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `phase-c-6-vault-status`). Then immediately ask the PM (via `## QUESTION TO PM`) whether Dev-A's Phase 3 `get_vault_status` type has landed yet — that gates Task 6.1. While you wait, you can prepare the Task 6.2 renderer (`vault-status.ts`) since it only needs the local `VaultStatus` shape, not `messages.ts`.

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Auto-generated by multi-agent-kickoff — v0.7.0 (finish the extension restructure)
# Streams: Dev-A = Phase 3, Dev-B = Phase 4, Dev-C = Phase 6
set -e
REPO="/home/alee/Sources/relicario"
RELAY_DIR="$REPO/tools/relay"
COORD="$REPO/docs/superpowers/coordination"
RELEASE="v0.7"
SESSION="$RELEASE"
# ── 1. Relay ─────────────────────────────────────────────────────────────
if curl -sf http://127.0.0.1:7331/sse --max-time 2 > /dev/null 2>&1; then
echo "[relay] already running on :7331"
else
echo "[relay] starting..."
cd "$RELAY_DIR"
nohup npx tsx server.ts > /tmp/relay-v0.7.log 2>&1 &
for i in $(seq 1 10); do
sleep 1
if curl -sf http://127.0.0.1:7331/sse --max-time 1 > /dev/null 2>&1; then
echo "[relay] ready on :7331"
break
fi
if [ "$i" -eq 10 ]; then
echo "[relay] ERROR: failed to start — check /tmp/relay-v0.7.log"
exit 1
fi
done
fi
# ── 2. tmux session ──────────────────────────────────────────────────────
if tmux has-session -t "$SESSION" 2>/dev/null; then
echo "[tmux] session '$SESSION' already exists — attaching"
exec tmux attach-session -t "$SESSION"
fi
echo "[tmux] creating session '$SESSION'..."
tmux new-session -d -s "$SESSION" -n "PM"
tmux send-keys -t "$SESSION:PM" "claude" Enter
tmux new-window -t "$SESSION" -n "Dev-A"
tmux send-keys -t "$SESSION:Dev-A" "claude" Enter
tmux new-window -t "$SESSION" -n "Dev-B"
tmux send-keys -t "$SESSION:Dev-B" "claude" Enter
tmux new-window -t "$SESSION" -n "Dev-C"
tmux send-keys -t "$SESSION:Dev-C" "claude" Enter
tmux select-window -t "$SESSION:PM"
echo ""
echo "╔══════════════════════════════════════════════════════════════════════╗"
echo "║ v0.7.0 — finish the extension restructure — prompt cheatsheet ║"
echo "╠══════════════════════════════════════════════════════════════════════╣"
echo "║ PM window → paste $COORD/v0.7-pm-prompt.md ║"
echo "║ Dev-A window → paste $COORD/v0.7-dev-a-prompt.md ║"
echo "║ Dev-B window → paste $COORD/v0.7-dev-b-prompt.md ║"
echo "║ Dev-C window → paste $COORD/v0.7-dev-c-prompt.md ║"
echo "╠══════════════════════════════════════════════════════════════════════╣"
echo "║ A = Phase 3 (setup wizard SW migration) ║"
echo "║ B = Phase 4 (split vault.ts + vault_locked lift) ║"
echo "║ C = Phase 6 (get_vault_status + status indicator) — deps on A & B ║"
echo "╚══════════════════════════════════════════════════════════════════════╝"
echo ""
echo "[tmux] attaching — use Ctrl-b n / Ctrl-b p to switch windows"
exec tmux attach-session -t "$SESSION"

View File

@@ -0,0 +1,129 @@
# PM Kickoff Prompt — v0.7.0 finish the extension restructure
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the v0.7.0 "finish the extension restructure" release. 3 senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all 3+1 terminals and relays messages between them.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-05-31. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
python3 call.py read_messages '{"for":"pm"}'
```
The shim connects over HTTP and has the same semantics as the MCP tools.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — the bundle spec
3. `docs/superpowers/plans/2026-05-30-extension-restructure.md` — the implementation plan. **Phases 1, 2, 5 already merged (2026-05-30).** This release finishes the remaining three:
- Plan A (Dev-A) → **Phase 3** (Tasks 3.13.7): setup wizard SW migration + step registry + `clearWizardState`
- Plan B (Dev-B) → **Phase 4** (Tasks 4.14.7): split `vault.ts` into 5 modules + lift the `vault_locked` channel into `shared/state.ts`
- Plan C (Dev-C) → **Phase 6** (Tasks 6.16.3): `get_vault_status` SW handler + sidebar status indicator
## Your authority
- Approve or deny scope changes from devs
- Review and merge PRs from each dev's feature branch
- Drive any release-prep work that isn't a feature plan (Task 7.1 final verification sweep, CHANGELOG, version bumps to v0.7.0, STATUS.md / ROADMAP.md updates) — this is your hands-on work
- Tag `v0.7.0` once everything is integrated **— but only after explicit user approval**
## Your boundaries
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` / STATUS / ROADMAP are fine.
- Don't deviate from the spec without user approval.
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
- Don't tag without user approval.
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
## ⚠️ Critical: cross-stream dependencies (the whole reason you exist this release)
Per the plan's "Notes on execution order": **Phase 4 blocks Phase 6, and Phase 3 owns a file Phase 6 needs.** Your central job is sequencing the merges and arbitrating the two shared edits:
1. **`extension/src/shared/messages.ts`** — Dev-A (Phase 3, Task 3.1) adds all three new request types: `create_vault`, `attach_vault`, **and `get_vault_status`**, plus their response interfaces, plus the three additions to `POPUP_ONLY_TYPES`. Dev-C (Phase 6) *consumes* `get_vault_status` but must NOT redefine it. **Directive at kickoff:** Dev-A owns every `messages.ts` change; Dev-C imports `GetVaultStatusResponse` from `messages.ts` and does not touch that file. If Dev-C starts before Dev-A's Task 3.1 lands, have Dev-C either (a) wait on the type, or (b) work against a local type alias and you reconcile at merge — prefer (a) if Dev-A is close.
2. **`extension/src/vault/vault-sidebar.ts`** — Dev-B (Phase 4, Task 4.2) *creates* this file. Dev-C (Phase 6, Task 6.3) *modifies* it to wire the status indicator into the sidebar footer. **Directive:** Dev-C should land Tasks 6.1 (SW handler) and 6.2 (renderer `vault-status.ts`) — both independent of Phase 4 — first, then HOLD on Task 6.3 until Dev-B's Phase 4 PR merges. Sequence the merges: **Phase 4 merges before Phase 6's wiring commit.**
3. **`extension/src/service-worker/vault.ts`** — Dev-A (Phase 3: `create_vault` / `attach_vault` handlers) and Dev-C (Phase 6: `get_vault_status` handler) both append handlers here, and both add a dispatch case to `service-worker/router/popup-only.ts`. These are additive and shouldn't conflict, but you may get a small merge conflict on the import block / switch statement. Merge Dev-A (Phase 3) before Dev-C's SW handler if possible to minimize churn. A trivial conflict here is expected — resolve it at merge or have the second dev rebase.
**Recommended merge order:** Phase 3 (Dev-A) → Phase 4 (Dev-B) → Phase 6 (Dev-C). Confirm this with the devs at kickoff so Dev-C knows to stage 6.1/6.2 early and 6.3 last.
## Coordination protocol
You are one of 3+1 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action. If the relay MCP tools are not registered in your session, fall back to the Python shim (see **Relay server** section above) or ask the user to relay manually.
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is the PM terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks, either from the relay inbox or relayed by the user if the relay is down.
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
```
## DIRECTIVE TO DEV-<letter>
Time: <iso8601>
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
Notes: <one paragraph max>
Next: <one concrete instruction or "continue plan">
```
When asked "status?" by the user at any time, give a current rollup:
```
## RELEASE STATUS — v0.7.0
Devs: <per-dev one-line state>
PM: <what you're working on>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev A REVIEW-READY", "tag v0.7.0">
```
## Reviewing PRs
When a dev posts `Action: REVIEW-READY` with a PR URL:
1. `gh pr view <url>` to read description and CI status
2. `gh pr diff <url>` to read changes
3. Check the diff against the spec and plan acceptance criteria (the plan's "Final Verification" Task 7.1 lists the exact done-criteria greps — use them)
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash — project rule: git history is the audit log)
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
## Pre-tag checklist
Before tagging `v0.7.0`:
- [ ] Every dev branch merged to main (Phases 3, 4, 6)
- [ ] Task 7.1 done-criteria sweep passes (all greps in the plan's Final Verification section)
- [ ] `cd extension && npx tsc --noEmit` clean
- [ ] `cd extension && npx vitest run` green (baseline was 389/389 + new Phase 3/4/6 tests)
- [ ] `cd extension && npm run build:all` clean (only the pre-existing 4MB WASM warning)
- [ ] `cargo test` still green (these phases don't touch Rust, but confirm no accidental breakage)
- [ ] STATUS.md + ROADMAP.md moved extension restructure to "Shipped"; CHANGELOG.md v0.7.0 entry written; version bumped to v0.7.0
- [ ] User-driven smoke test of the merged result
- [ ] Explicit user approval to tag
After all PRs merge, run the project's cleanup (CLAUDE.md rule #6): `Workflow({name:"release", args:{action:"cleanup"}})` to remove this lift's worktrees and branches. **Note:** there are also stale `phase-c-1/2/5` worktrees from the previous lift (under `.worktrees/`) that were never cleaned up — flag this to the user; they may want them removed too (destructive op → ask first).
## First action
1. Call `read_messages(for="pm")` to drain any early inbox messages.
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec, the plan, and the cross-stream dependency map above.
3. Send opening directives to all three devs via `post_message` — at minimum: (a) confirm Dev-A owns ALL of `messages.ts`, (b) confirm the merge order Phase 3 → Phase 4 → Phase 6, (c) tell Dev-C to stage Tasks 6.1/6.2 first and HOLD 6.3 until Phase 4 merges.
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing them to proceed.

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ Firefox build (the vault tab is Chrome-only for the moment). Verify in
| `service-worker` | `src/service-worker/index.ts` | extension SW / bg | yes — initialized lazily on first message |
| `popup` | `src/popup/popup.ts` | popup.html | no — goes through SW |
| `vault` | `src/vault/vault.ts` (Chrome only) | vault.html (tab) | no — goes through SW |
| `setup` | `src/setup/setup.ts` | setup.html (tab) | yes — direct dynamic import (predates SW handle) |
| `setup` | `src/setup/setup.ts` | setup.html (tab) | no — goes through SW (`create_vault`/`attach_vault`) |
| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no |
### What each bundle owns
@@ -183,17 +183,51 @@ before any new render.
### `src/vault/`
- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`,
`#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`,
`#history`, `#history/<id>`, `#backup`, `#import`). Legacy
`#field-history/<id>` URLs are normalized to `#history/<id>` on
`parseHash` (`vault.ts:139-173`); the internal view value stays
`'field-history'` so the per-item pane renders unchanged. Sidebar
bottom-nav: `+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history
· ⏻ lock`. Registers itself as the StateHost so all
`popup/components/*` renderers run unchanged. Maintains its own
`selectedItem` cache so hash navigation between already-loaded items
doesn't refetch.
- `vault.ts` (194 lines) — fullscreen tab entry, now a thin
routing + state shell after the Phase 4 split. Registers itself as
the StateHost so all `popup/components/*` renderers run unchanged,
maintains its own `selectedItem` cache so hash navigation between
already-loaded items doesn't refetch, and delegates DOM scaffolding,
navigation, list/drawer/form rendering, and route dispatch to the
sibling modules below. The hash-route set is
`#detail/<id>`, `#add/<type>`, `#trash`, `#devices`, `#settings`,
`#settings-vault`, `#history`, `#history/<id>`, `#backup`, `#import`.
- `vault-context.ts` — the `VaultController` contract plus the shared
types and pure helpers the split modules depend on. Added so the
split is acyclic: the rendering modules import the controller
interface from here rather than from `vault.ts`.
- `vault-router.ts` — hash routing + pane dispatch + data loading,
extracted to keep `vault.ts` ≤250 LOC. Owns `parseHash`; legacy
`#field-history/<id>` URLs are normalized to `#history/<id>` here, but
the internal view value stays `'field-history'` so the per-item pane
renders unchanged.
- `vault-shell.ts` — DOM scaffolding, color-scheme apply, and the
`onMessage` wiring for the tab.
- `vault-sidebar.ts` — sidebar categories nav, 80ms-debounced search
(`SEARCH_DEBOUNCE_MS`), and the bottom-nav
(`+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock`).
Also owns the footer: a `#vault-status-slot` plus a manual `↻` refresh
button (`GLYPH_REFRESH`). `wireSidebar` calls `refreshStatus()` once on
mount and again on the button's click — sending `get_vault_status` via
`ctx.sendMessage` and rendering the result into the slot through
`vault-status.ts`. There is **no timer polling**: the indicator only
refreshes on mount + explicit button press, matching the spec's
no-network-without-user-intent discipline (sync is user-initiated).
- `vault-status.ts` — sidebar-footer sync indicator renderer.
`renderStatusIndicator(el, status)` is pure DOM: it renders, by
priority, `N pending` / `N ahead` / `N behind`, falling back to
`in sync`, plus a `last sync <relativeTime>` / `never synced` line.
Reuses `shared/glyphs.ts` (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`)
and `shared/relative-time.ts`. `VaultStatus` is an alias of
`GetVaultStatusResponse['data']`, so the renderer's input shape is
single-sourced from the message contract and can't drift from the SW
handler.
- `vault-list.ts` — the list pane and its row rendering.
- `vault-drawer.ts` — drawer open/close/render plus
`ensureDrawerClosedForRoute`, which closes the drawer on any
non-list navigation.
- `vault-form-wrapper.ts``renderFormWrapped` plus the sticky bar and
header that wrap form panes.
- `vault.html` / `vault.css` — sidebar + pane layout.
### `src/vault/components/`
@@ -211,12 +245,19 @@ exports `render…(app)` and a `teardown()`, same convention as
### `src/setup/`
- `setup.ts` (1137 lines) — the wizard state machine. Six steps
(0..5): mode picker (new vault / attach this device), host type
(Gitea/GitHub), host config + connection test + repo probe, the
forking step 3 (create-vault vs attach-this-device), device name,
finish. Loads WASM directly. State-coupled `updateStrengthUi` stays
here because it walks the live wizard state.
- `setup.ts` (58 lines) — a thin UI-only shell after the Phase 3
split: the render loop + progress track + boot + re-exports. No longer
imports `relicario-wasm`; the wizard now drives vault creation/attach
through the SW. Binds `clearWizardState` to
`window.addEventListener('beforeunload', clearWizardState)`
(`setup.ts:53`) and also calls it on `goto('mode')` (`setup.ts:44`).
- `setup-steps.ts` (extracted in Phase 3) — the setup step registry +
wizard state + `clearWizardState` + `finishSetup`. One-directional
import (`setup.ts``setup-steps.ts`, no cycle). Crypto orchestration
no longer lives in the wizard: the device step (where `deviceName`
exists) fires `create_vault` and `attach_vault` SW messages instead of
calling WASM directly. State-coupled `updateStrengthUi` stays here
because it walks the live wizard state.
- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
@@ -273,7 +314,23 @@ exports `render…(app)` and a `teardown()`, same convention as
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
with its own captured-tab verification (see Key flows). New in
commit `a7dbf35`: `register_this_device`.
commit `a7dbf35`: `register_this_device`. Phase 3 added
`create_vault` and `attach_vault` (full SW-side vault
creation/attach: embed/unlock, encrypt+push, `register_device` +
`addDevice`, persist config+image, `session.setCurrent`; the failure
path locks and frees the handle). The `lock` handler now also nulls
`state.gitHost` (symmetric with session-expiry) so the status cache
can't go stale across a lock→unlock. Phase 6 added `get_vault_status`
(popup-only, read-only) — returns the cached sync summary
`{ ahead, behind, lastSyncAt, pendingItems }` with **no network
call**. `ahead`/`behind`/`lastSyncAt` are read straight off
`state.gitHost` (populated by the `sync` handler, which records
`lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after
a successful manifest fetch). `pendingItems` is a live count of active
(non-trashed) manifest entries via `vault.listItems(manifest).length`.
`ahead`/`behind` are structurally always `0` in the extension (it
writes straight to the host via the Contents REST API; there is no
local commit graph) and exist for parity with `relicario status`.
- `router/content-callable.ts` — handler match arms for every
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
`sender.tab.url`, never from message fields. `capture_save_login`
@@ -287,7 +344,13 @@ exports `render…(app)` and a `teardown()`, same convention as
no www-stripping, no public-suffix), trash helpers
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`,
with manifest summary sync).
with manifest summary sync). Now also includes the
`create_vault`/`attach_vault` orchestration handlers (Phase 3) and
`handleGetVaultStatus(state)` (Phase 6) — synchronous, no network;
returns the cached `{ ahead, behind, lastSyncAt, pendingItems }`. Its
`Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks
the `PopupState` import cycle and structurally forbids it from making
a network call.
- `session.ts` — single module-scope `SessionHandle | null`. α assumes
one vault per install. Multi-vault would replace this with a `Map`
keyed by vault id.
@@ -301,6 +364,15 @@ exports `render…(app)` and a `teardown()`, same convention as
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
attachment writes switch from the Contents API to the Git Data API.
The `GitHost` interface also carries cached sync metadata —
`lastSyncAt: number | null` (unix seconds), `ahead: number`,
`behind: number` — initialized to `null`/`0`/`0` in both `GiteaHost`
and `GitHubHost`. The cache rides the gitHost lifecycle: created on
unlock and cleared whenever `state.gitHost` is nulled — on
session-timer expiry (`index.ts`) **and** on the explicit `lock`
message handler (`popup-only.ts`), which now nulls `state.gitHost`
symmetrically so a lock→unlock cycle can't surface a stale
`lastSyncAt`.
- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use
the host's Contents API for files under threshold, and Git Data API
(blobs + tree + commit) for large attachment uploads. Auth differs
@@ -322,7 +394,9 @@ exports `render…(app)` and a `teardown()`, same convention as
- `state.ts``StateHost` interface + module-scope singleton. Both
`popup.ts` and `vault.ts` register themselves on boot. All
`popup/components/*` import from here, never from popup.ts directly,
so the same render code runs in both bundles.
so the same render code runs in both bundles. Its `sendMessage`
wrapper intercepts `vault_locked` responses (lifted out of `vault.ts`
in Phase 4, so the intercept now applies uniformly to both bundles).
- `types.ts` — TypeScript mirrors of the Rust core's serde shapes:
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Relicario",
"version": "0.5.0",
"version": "0.7.0",
"description": "Two-factor encrypted password manager",
"icons": {
"16": "icons/icon-16.png",

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Relicario",
"version": "0.5.0",
"version": "0.7.0",
"description": "Two-factor encrypted password manager",
"icons": {
"16": "icons/icon-16.png",

View File

@@ -1,6 +1,6 @@
{
"name": "relicario-extension",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"scripts": {
"build": "webpack --mode production",

View File

@@ -93,9 +93,21 @@ setupFillListener();
scan();
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
const observer = new MutationObserver(() => {
scan();
});
// Plan C Phase 5: SPA churn fires the MutationObserver many times per
// second. Trailing-edge debounce coalesces bursts so we run the full
// scan() at most once per quiet 200ms window.
const SCAN_DEBOUNCE_MS = 200;
let scanTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleScan(): void {
if (scanTimer !== undefined) clearTimeout(scanTimer);
scanTimer = setTimeout(() => {
scanTimer = undefined;
scan();
}, SCAN_DEBOUNCE_MS);
}
const observer = new MutationObserver(scheduleScan);
observer.observe(document.body, {
childList: true,

View File

@@ -95,6 +95,32 @@ describe('devices view', () => {
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
});
// Plan C Phase 5 — defensive Promise.allSettled:
// a rejected secondary feed (list_revoked) should not kill the whole render.
it('renders devices when revoked list fails (load-error slot shown)', async () => {
(sendMessage as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
.mockRejectedValueOnce(new Error('boom'));
await renderDevices(app);
// Primary list still rendered.
expect(app.innerHTML).toContain('CLI');
// Inline fallback slot present.
expect(app.innerHTML).toContain("Couldn't load revoked devices");
});
it('renders devices when revoked list returns {ok:false}', async () => {
(sendMessage as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
.mockResolvedValueOnce({ ok: false, error: 'list_revoked_failed' });
await renderDevices(app);
expect(app.innerHTML).toContain('CLI');
expect(app.innerHTML).toContain("Couldn't load revoked devices");
});
it('confirming register sends register_this_device with the entered name', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
// Initial render: list_devices + list_revoked.

View File

@@ -31,35 +31,64 @@ export function teardown(): void {
// No cleanup needed
}
/**
* DEV-C P2: defensive per-slot rendering. The active list is the primary
* feed — if it fails entirely, we still surface an error page. The
* revoked list is secondary — its failure renders an inline "couldn't
* load" slot but doesn't kill the page.
*/
function revokedLoadErrorHtml(): string {
return `
<details class="revoked-section">
<summary class="muted">▸ revoked devices</summary>
<div class="revoked-section__body">
<p class="muted">Couldn't load revoked devices.</p>
</div>
</details>
`;
}
export async function renderDevices(app: HTMLElement): Promise<void> {
// Get current device name from local storage
const stored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
// Fetch active device list and revoked list in parallel
const [devicesResp, revokedResp] = await Promise.all([
// Fetch active device list and revoked list in parallel. allSettled so a
// rejected secondary feed doesn't kill the whole render.
const [devicesSettled, revokedSettled] = await Promise.allSettled([
sendMessage({ type: 'list_devices' }),
sendMessage({ type: 'list_revoked' }),
]);
if (!devicesResp.ok) {
if (devicesSettled.status === 'rejected' || !devicesSettled.value.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
return;
}
const devices = (devicesResp.data as { devices: Device[] }).devices;
const revokedDevices: RevokedEntry[] = revokedResp.ok
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
// devicesSettled.value.ok is true here (guarded above), so .data is present.
const devicesData = (devicesSettled.value as { ok: true; data: unknown }).data;
const devices = (devicesData as { devices: Device[] }).devices;
const revokedOk = revokedSettled.status === 'fulfilled' && revokedSettled.value.ok;
const revokedDevices: RevokedEntry[] = revokedOk
? ((revokedSettled.value as { ok: true; data: unknown }).data as { revoked: RevokedEntry[] }).revoked
: [];
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
// Precompute fingerprints for all active devices
// Precompute fingerprints for all active devices. allSettled so one bad
// public key doesn't kill the whole list — fall back to '(unknown)'.
const fingerprints = new Map<string, string>();
await Promise.all(devices.map(async (d) => {
const fp = await sshFingerprint(d.public_key);
fingerprints.set(d.name, fp ?? '(unknown)');
}));
const fpResults = await Promise.allSettled(
devices.map((d) => sshFingerprint(d.public_key).then((fp) => [d.name, fp] as const)),
);
for (let i = 0; i < devices.length; i += 1) {
const r = fpResults[i];
if (r.status === 'fulfilled' && r.value[1]) {
fingerprints.set(r.value[0], r.value[1]);
} else {
fingerprints.set(devices[i].name, '(unknown)');
}
}
const activeDevicesHtml = devices.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
@@ -82,7 +111,9 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
`;
}).join('');
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
const revokedSectionHtml = !revokedOk
? revokedLoadErrorHtml()
: revokedDevices.length === 0 ? '' : `
<details class="revoked-section">
<summary class="muted">▸ show ${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}</summary>
<div class="revoked-section__body">
@@ -117,7 +148,7 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
` : ''}
${devices.length > 0 ? `<div class="section-header">ACTIVE · ${devices.length}</div>` : ''}
${activeDevicesHtml}
${revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : ''}
${!revokedOk ? `<div class="section-header">REVOKED · ?</div>` : (revokedDevices.length > 0 ? `<div class="section-header">REVOKED · ${revokedDevices.length}</div>` : '')}
${revokedSectionHtml}
</div>
`;

View File

@@ -9,6 +9,7 @@ import type {
import type { SessionTimeoutConfig } from '../../shared/messages';
import { relativeTime } from '../../shared/relative-time';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { teardownSettingsCommon } from './settings';
import { GLYPH_NEXT } from '../../shared/glyphs';
let pendingSettings: VaultSettings | null = null;
@@ -17,11 +18,7 @@ let pendingSession: SessionTimeoutConfig | null = null;
let baseSession: SessionTimeoutConfig | null = null;
export function teardown(): void {
closeGeneratorPanel();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
}
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
pendingSettings = null;
pendingSession = null;
baseSession = null;

View File

@@ -53,13 +53,29 @@ export async function renderSettings(container: HTMLElement): Promise<void> {
await renderSection(activeSection);
}
export function teardownSettings(): void {
/**
* Common cleanup invoked by both the device-settings teardown
* (settings.ts) and the vault-settings teardown (settings-vault.ts).
* Centralized to avoid the "regression class with known prior leaks"
* DEV-C P2 flagged.
*
* Closes the generator popover and detaches the supplied keydown
* handler from the document if present. Returns the new handler value
* (always null), so the caller can do `handler = teardownSettingsCommon(handler)`.
*/
export function teardownSettingsCommon(
keyHandler: ((e: KeyboardEvent) => void) | null,
): null {
closeGeneratorPanel();
teardownSecuritySection();
if (activeKeyHandler) {
document.removeEventListener('keydown', activeKeyHandler);
activeKeyHandler = null;
if (keyHandler) {
document.removeEventListener('keydown', keyHandler);
}
return null;
}
export function teardownSettings(): void {
activeKeyHandler = teardownSettingsCommon(activeKeyHandler);
teardownSecuritySection();
pendingVaultSettings = null;
sessionHandle = null;
}

View File

@@ -67,29 +67,7 @@ function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
// --- State ---
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
export interface PopupState {
view: View;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
error: string | null;
loading: boolean;
// Captured tab snapshot taken at popup-open. Used by fill_credentials
// to guard against TOCTOU navigation — the SW re-checks this URL's
// hostname against the tab's live URL before forwarding fill_credentials
// to the content script. See router/popup-only.ts#handleFillCredentials.
capturedTabId: number | null;
capturedUrl: string;
newType: import('../shared/types').ItemType | null;
vaultSettings: import('../shared/types').VaultSettings | null;
generatorDefaults: import('../shared/types').GeneratorRequest | null;
historyItemId: import('../shared/types').ItemId | null;
}
import type { View, PopupState } from '../shared/popup-state';
let currentState: PopupState = {
view: 'locked',

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import * as timer from '../session-timer';
import { READ_ONLY_CONTENT_CALLABLE } from '../session-timer';
describe('session-timer', () => {
beforeEach(() => {
@@ -97,3 +98,29 @@ describe('session-timer', () => {
expect(cb).not.toHaveBeenCalled();
});
});
describe('READ_ONLY_CONTENT_CALLABLE — inversion exclusion set', () => {
// The SW handler invokes resetTimer() on every message whose type is NOT
// in this set. These cases encode the documented inversion contract from
// Plan C Phase 5: popup-only messages reset, content-callable writes
// reset, only passive content reads (currently just get_autofill_candidates)
// do NOT reset.
it('popup-only message would reset the timer (not in exclusion set)', () => {
// e.g. list_items — popup interaction is unambiguously active use
expect(READ_ONLY_CONTENT_CALLABLE.has('list_items')).toBe(false);
});
it('content-callable get_autofill_candidates does NOT reset (in exclusion set)', () => {
expect(READ_ONLY_CONTENT_CALLABLE.has('get_autofill_candidates')).toBe(true);
});
it('content-callable capture_save_login DOES reset (write op = active use)', () => {
expect(READ_ONLY_CONTENT_CALLABLE.has('capture_save_login')).toBe(false);
});
it('content-callable check_credential DOES reset', () => {
// Asking "is this credential already saved" is user-initiated.
expect(READ_ONLY_CONTENT_CALLABLE.has('check_credential')).toBe(false);
});
});

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist }
from '../storage';
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: vi.fn((keys: string | string[]) => {
const arr = Array.isArray(keys) ? keys : [keys];
const out: Record<string, unknown> = {};
for (const k of arr) if (k in store) out[k] = store[k];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => {
Object.assign(store, kv);
return Promise.resolve();
}),
},
},
} as never;
return store;
}
describe('service-worker/storage', () => {
beforeEach(() => { mockChromeStorage(); });
it('loadDeviceSettings returns default when storage is empty', async () => {
const s = await loadDeviceSettings();
expect(s.captureEnabled).toBe(false);
expect(s.captureStyle).toBe('bar');
});
it('loadDeviceSettings returns stored value', async () => {
mockChromeStorage({ relicarioSettings: { captureEnabled: true, captureStyle: 'toast' } });
const s = await loadDeviceSettings();
expect(s.captureEnabled).toBe(true);
expect(s.captureStyle).toBe('toast');
});
it('saveDeviceSettings persists', async () => {
const store = mockChromeStorage();
await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' });
expect(store.relicarioSettings).toEqual({ captureEnabled: true, captureStyle: 'bar' });
});
it('loadBlacklist returns empty array by default', async () => {
expect(await loadBlacklist()).toEqual([]);
});
it('saveBlacklist / loadBlacklist round-trips', async () => {
await saveBlacklist(['example.com', 'evil.test']);
expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']);
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { handleGetVaultStatus } from '../vault';
import type { Manifest, ManifestEntry } from '../../shared/types';
// The handler only reads gitHost's three cache fields, so the test feeds a
// minimal object — the handler's Pick-typed param makes full GitHost mocking
// unnecessary.
const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) =>
({ lastSyncAt, ahead, behind });
function manifestWith(activeCount: number, trashedCount = 0): Manifest {
const items: Record<string, ManifestEntry> = {};
for (let i = 0; i < activeCount; i++) {
items[`a${i}`] = { trashed_at: undefined } as ManifestEntry;
}
for (let i = 0; i < trashedCount; i++) {
items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry;
}
return { items } as Manifest;
}
describe('handleGetVaultStatus', () => {
it('returns zeros when never synced and no manifest', () => {
const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: null });
expect(resp).toEqual({
ok: true,
data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 },
});
});
it('reflects cached sync state + active (non-trashed) item count', () => {
const resp = handleGetVaultStatus({
gitHost: cache(1234567890, 3, 1),
manifest: manifestWith(5, 2),
});
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.data).toEqual({
ahead: 3, behind: 1, lastSyncAt: 1234567890, pendingItems: 5,
});
}
});
it('returns vault_locked error when gitHost is null', () => {
expect(handleGetVaultStatus({ gitHost: null, manifest: null }))
.toEqual({ ok: false, error: 'vault_locked' });
});
it('is synchronous — no network round-trip', () => {
const resp = handleGetVaultStatus({ gitHost: cache(0), manifest: null });
expect(resp).not.toBeInstanceOf(Promise);
});
});

View File

@@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as vault from '../vault';
import * as session from '../session';
import type { PopupState } from '../router/popup-only';
import type { GitHost } from '../git-host';
import * as gitHostMod from '../git-host';
// --- Mock git-host module ---
// createGitHost is called internally by handleCreateVault / handleAttachVault;
// we need to intercept it and return a fake GitHost. uint8ArrayToBase64 must
// still work — vault.ts calls it for the imageBase64 storage value.
// Shared factory used both inside vi.mock and in beforeEach re-wire.
function makeHostMock(): GitHost & { _calls: Record<string, unknown[][]> } {
const calls: Record<string, unknown[][]> = {
writeFileCreateOnly: [],
writeFile: [],
readFile: [],
};
return {
_calls: calls,
readFile: vi.fn().mockImplementation(async (path: string) => {
// Serve the vault-meta files needed by fetchVaultMeta + attach flow.
if (path === '.relicario/salt') return new Uint8Array(32);
if (path === '.relicario/params.json') {
return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}');
}
if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]);
// .relicario/devices.json throws so readDevices falls back to [].
throw new Error(`404: ${path}`);
}),
writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => {
calls.writeFile.push(args);
}),
writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => {
calls.writeFileCreateOnly.push(args);
}),
deleteFile: vi.fn(),
listDir: vi.fn().mockResolvedValue([]),
lastCommit: vi.fn().mockResolvedValue(null),
putBlob: vi.fn(),
getBlob: vi.fn(),
deleteBlob: vi.fn(),
};
}
vi.mock('../git-host', async () => {
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
// Expose a handle so tests can grab the last-created fake host.
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = null;
return {
...actual,
createGitHost: vi.fn().mockImplementation(() => {
const h = makeHostMock();
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
return h;
}),
};
});
// --- Chrome storage mock ---
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: vi.fn((keys: string | string[]) => {
const arr = Array.isArray(keys) ? keys : [keys];
const out: Record<string, unknown> = {};
for (const k of arr) if (k in store) out[k] = store[k];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => {
Object.assign(store, kv);
return Promise.resolve();
}),
},
},
} as never;
return store;
}
// --- Helpers ---
function makeFakeHandle() {
return { free: vi.fn() };
}
function makeWasm(overrides: Record<string, unknown> = {}) {
const fakeHandle = makeFakeHandle();
return {
_handle: fakeHandle,
embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])),
unlock: vi.fn(() => fakeHandle),
manifest_encrypt: vi.fn(() => new Uint8Array([9])),
manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })),
default_vault_settings_json: vi.fn(() => '{}'),
settings_encrypt: vi.fn(() => new Uint8Array([8])),
register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })),
lock: vi.fn(),
...overrides,
};
}
function makeState(wasm: ReturnType<typeof makeWasm>): PopupState {
return {
manifest: null,
gitHost: null,
wasm,
};
}
const BASE_MSG = {
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
passphrase: 'pw',
carrierImageBytes: new Uint8Array([0, 0, 0]).buffer,
deviceName: 'Dev',
};
// --- Tests ---
describe('handleCreateVault', () => {
let setCurrent: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockChromeStorage();
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('Test 1 (happy path): returns ok:true with expected data and correct side effects', async () => {
const wasm = makeWasm();
const state = makeState(wasm);
const resp = await vault.handleCreateVault(BASE_MSG, state);
expect(resp.ok).toBe(true);
if (!resp.ok) throw new Error('expected ok:true');
// Response shape
expect(resp.data.referenceImageBytes).toBeInstanceOf(Uint8Array);
expect(resp.data.deviceName).toBe('Dev');
expect(resp.data.recoveryQrAvailable).toBe(true);
// Fake GitHost captures the four writeFileCreateOnly calls
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
expect(fakeHost).not.toBeNull();
const wfco = fakeHost!.writeFileCreateOnly as ReturnType<typeof vi.fn>;
const paths = wfco.mock.calls.map((c: unknown[]) => c[0]);
expect(paths).toContain('.relicario/salt');
expect(paths).toContain('.relicario/params.json');
expect(paths).toContain('manifest.enc');
expect(paths).toContain('settings.enc');
// register_device called with the device name
expect(wasm.register_device).toHaveBeenCalledWith('Dev');
// chrome.storage.local.set called with vaultConfig + imageBase64 + device_name
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
.chrome.storage.local.set.mock.calls;
const merged: Record<string, unknown> = {};
for (const [kv] of chromeSets) Object.assign(merged, kv);
expect(merged).toHaveProperty('vaultConfig');
expect(merged).toHaveProperty('imageBase64');
expect(merged).toHaveProperty('device_name', 'Dev');
// session.setCurrent was called (ownership transferred — handle NOT freed)
expect(setCurrent).toHaveBeenCalled();
expect(wasm._handle.free).not.toHaveBeenCalled();
});
it('Test 2 (failure path — early throw): ok:false, no writeFileCreateOnly calls', async () => {
const wasm = makeWasm({
embed_image_secret: vi.fn(() => { throw new Error('embed failed'); }),
});
const state = makeState(wasm);
const resp = await vault.handleCreateVault(BASE_MSG, state);
expect(resp.ok).toBe(false);
if (resp.ok) throw new Error('expected ok:false');
expect(resp.error).toBeTruthy();
expect(resp.error.length).toBeGreaterThan(0);
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
// No GitHost was created at all (failed before createGitHost call), OR
// if somehow created, no writeFileCreateOnly calls happened.
if (fakeHost) {
expect((fakeHost.writeFileCreateOnly as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
}
});
it('Test 3 (handle cleanup on mid-flight failure): lock + free called, ok:false', async () => {
const wasm = makeWasm({
manifest_encrypt: vi.fn(() => { throw new Error('encrypt failed'); }),
});
const state = makeState(wasm);
const resp = await vault.handleCreateVault(BASE_MSG, state);
expect(resp.ok).toBe(false);
// unlock succeeded (handle was acquired), manifest_encrypt failed after that.
// Finally block must: lock(handle) then handle.free().
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
expect(wasm._handle.free).toHaveBeenCalled();
// Ownership was NOT transferred — setCurrent must NOT have been called.
expect(setCurrent).not.toHaveBeenCalled();
});
});
// --- attach_vault ---
const ATTACH_MSG = {
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
passphrase: 'pw',
referenceImageBytes: new Uint8Array([1, 2, 3]).buffer,
deviceName: 'Dev2',
};
describe('handleAttachVault', () => {
let setCurrent: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockChromeStorage();
// Re-wire createGitHost: vi.restoreAllMocks() in the create-vault afterEach
// strips the mockImplementation from the vi.fn(), leaving it returning undefined.
// We re-establish it here so each attach test starts with a fresh fake host.
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
const h = makeHostMock();
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
return h;
});
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('Test 1 (happy path): returns ok:true, state populated, handle ownership transferred', async () => {
const wasm = makeWasm();
const state = makeState(wasm);
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
expect(resp.ok).toBe(true);
if (!resp.ok) throw new Error('expected ok:true');
expect(resp.data.deviceName).toBe('Dev2');
// WASM calls in order: unlock → manifest_decrypt (verification) → register_device
expect(wasm.unlock).toHaveBeenCalled();
expect(wasm.manifest_decrypt).toHaveBeenCalled();
expect(wasm.register_device).toHaveBeenCalledWith('Dev2');
// chrome.storage.local.set received vaultConfig + imageBase64 + device_name
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
.chrome.storage.local.set.mock.calls;
const merged: Record<string, unknown> = {};
for (const [kv] of chromeSets) Object.assign(merged, kv);
expect(merged).toHaveProperty('vaultConfig');
expect(merged).toHaveProperty('imageBase64');
expect(merged).toHaveProperty('device_name', 'Dev2');
// session.setCurrent called — ownership transferred; handle NOT freed
expect(setCurrent).toHaveBeenCalled();
expect(wasm._handle.free).not.toHaveBeenCalled();
// State wired up
expect(state.manifest).not.toBeNull();
expect(state.gitHost).not.toBeNull();
});
it('Test 2 (wrong credentials — manifest_decrypt throws): ok:false, handle locked+freed, no side effects', async () => {
const wasm = makeWasm({
manifest_decrypt: vi.fn(() => { throw new Error('AEAD verification failed'); }),
});
const state = makeState(wasm);
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
expect(resp.ok).toBe(false);
if (resp.ok) throw new Error('expected ok:false');
expect(resp.error).toBeTruthy();
expect(resp.error.length).toBeGreaterThan(0);
// register_device must NOT be called (we failed before it)
expect(wasm.register_device).not.toHaveBeenCalled();
// Finally block must lock then free the handle we own
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
expect(wasm._handle.free).toHaveBeenCalled();
// Session must NOT have been updated
expect(setCurrent).not.toHaveBeenCalled();
});
});

View File

@@ -41,6 +41,15 @@ export interface GitHost {
/// Delete a blob from the repo. Currently identical to deleteFile;
/// kept distinct for symmetry with putBlob.
deleteBlob(path: string, message: string): Promise<void>;
/// Cached sync metadata, populated by the `sync` handler — get_vault_status
/// reads these without any network call. lastSyncAt is unix SECONDS (or null
/// until the first sync). ahead/behind exist for parity with `relicario
/// status`; the extension writes straight to the host (no local commit
/// graph), so in practice they stay 0.
lastSyncAt: number | null;
ahead: number;
behind: number;
}
/// Pre-base64 byte size at which putBlob switches from Contents API to

View File

@@ -20,6 +20,9 @@ export class GiteaHost implements GitHost {
private keysUrl: string;
private branch: string = 'main';
private headers: Record<string, string>;
lastSyncAt: number | null = null;
ahead = 0;
behind = 0;
constructor(hostUrl: string, repoPath: string, apiToken: string) {
// Remove trailing slash from hostUrl

View File

@@ -17,6 +17,9 @@ export class GitHubHost implements GitHost {
private commitsUrl: string;
private branch: string = 'main';
private headers: Record<string, string>;
lastSyncAt: number | null = null;
ahead = 0;
behind = 0;
constructor(repoPath: string, apiToken: string) {
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;

View File

@@ -2,12 +2,12 @@
/// forwards every message into router/index.route().
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
import { CONTENT_CALLABLE_TYPES } from '../shared/messages';
import type { RouterState } from './router/index';
import { route } from './router/index';
import * as vault from './vault';
import { clearCurrent } from './session';
import * as sessionTimer from './session-timer';
import { READ_ONLY_CONTENT_CALLABLE } from './session-timer';
// @ts-ignore TS2307 — resolved by webpack alias / copy
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
@@ -53,6 +53,9 @@ sessionTimer.onExpired(() => {
console.log('[relicario sw] session expired — locking vault');
clearCurrent();
state.manifest = null;
// Plan C Phase 5: don't leak the cached git-host client across a lock.
// The initializer rebuilds gitHost on demand, so clearing here is safe.
state.gitHost = null;
// Best-effort broadcast — receiver may not exist yet.
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
});
@@ -73,7 +76,10 @@ chrome.commands.onCommand.addListener((command) => {
chrome.runtime.onMessage.addListener(
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
(async () => {
if (!CONTENT_CALLABLE_TYPES.has(request.type as never)) {
// Plan C Phase 5: invert the reset rule. Reset on every message
// except a documented passive-read exclusion set, so an active
// autofiller / content-driven flow keeps the vault alive.
if (!READ_ONLY_CONTENT_CALLABLE.has(request.type)) {
sessionTimer.resetTimer();
}
if (!state.wasm) {

View File

@@ -8,7 +8,9 @@ import type { ContentMessage, Response } from '../../shared/messages';
import type { Item, Manifest } from '../../shared/types';
import type { GitHost } from '../git-host';
import * as vault from '../vault';
import { itemToManifestEntry } from '../vault';
import * as session from '../session';
import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
export interface ContentState {
manifest: Manifest | null;
@@ -164,41 +166,6 @@ export async function handle(
}
}
// --- Manifest entry derivation (duplicated from popup-only for self-containment) ---
function itemToManifestEntry(item: Item) {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
?? { captureEnabled: false, captureStyle: 'bar' };
}
async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}

View File

@@ -4,15 +4,16 @@
/// via sender.url === popup.html (or setup.html for save_setup).
import type { PopupMessage, Response } from '../../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types';
import { base32Decode } from '../../shared/base32';
import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host';
import * as vault from '../vault';
import { itemToManifestEntry } from '../vault';
import * as session from '../session';
import * as devices from '../devices';
import * as sessionTimer from '../session-timer';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
// --- Shared ambient state owned by the SW module ---
//
@@ -58,6 +59,9 @@ export async function handle(
case 'lock':
session.clearCurrent();
state.manifest = null;
// Don't leak the cached git-host (incl. lastSyncAt) across a lock —
// symmetric with the session-expiry path (index.ts); unlock rebuilds it.
state.gitHost = null;
return { ok: true };
case 'list_items': {
@@ -129,6 +133,8 @@ export async function handle(
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
// Record sync time (unix SECONDS) for the get_vault_status indicator.
state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000);
return { ok: true };
}
@@ -626,6 +632,18 @@ export async function handle(
return { ok: false, error: (e as Error).message };
}
}
case 'create_vault':
return vault.handleCreateVault(msg, state);
case 'attach_vault':
return vault.handleAttachVault(msg, state);
case 'get_vault_status':
return vault.handleGetVaultStatus(state);
default:
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
}
}
@@ -684,44 +702,6 @@ async function loadSetupState(): Promise<SetupState> {
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
}
async function loadDeviceSettings(): Promise<DeviceSettings> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: s });
}
async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
function itemToManifestEntry(item: Item) {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}

View File

@@ -48,3 +48,17 @@ export function stopTimer(): void {
timerId = null;
}
}
/**
* Content-callable message types that should NOT reset the inactivity timer.
*
* Rationale: a content script reading available autofill candidates is a
* passive query — it shouldn't keep the vault alive indefinitely while the
* user isn't actually interacting with it.
*
* Today this is the only known passive read; if a future content message
* is also passive, add it here with a one-line justification.
*/
export const READ_ONLY_CONTENT_CALLABLE: ReadonlySet<string> = new Set([
'get_autofill_candidates',
]);

View File

@@ -0,0 +1,25 @@
/// Single home for chrome.storage.local reads/writes done by the service
/// worker. Both router files (popup-only.ts and content-callable.ts) import
/// from here — the duplicated definitions in those files were lifted out as
/// part of Plan C Phase 2 (P1.9).
import type { DeviceSettings } from '../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
export async function loadDeviceSettings(): Promise<DeviceSettings> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
export async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: s });
}
export async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
export async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}

View File

@@ -3,8 +3,12 @@
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import { uint8ArrayToBase64 } from './git-host';
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
import { createGitHost, uint8ArrayToBase64 } from './git-host';
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
import * as session from './session';
import * as devices from './devices';
import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages';
import type { PopupState } from './router/popup-only';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
@@ -17,6 +21,125 @@ function requireWasm(): any {
return wasm;
}
const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
/// Register this device on the remote (devices.json) and persist the vault
/// config + reference image locally so future unlocks work. Shared by the
/// create and attach flows — both finish with this identical tail.
async function registerDeviceAndPersistConfig(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
w: any,
git: GitHost,
config: VaultConfig,
referenceImageBytes: Uint8Array,
deviceName: string,
): Promise<void> {
const keys = w.register_device(deviceName) as { signing_public_key: string };
await devices.addDevice(git, {
name: deviceName,
public_key: keys.signing_public_key,
added_at: Math.floor(Date.now() / 1000),
});
await chrome.storage.local.set({
vaultConfig: config,
imageBase64: uint8ArrayToBase64(referenceImageBytes),
device_name: deviceName,
});
}
export async function handleCreateVault(
msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string },
state: PopupState,
): Promise<CreateVaultResponse | { ok: false; error: string }> {
const w = state.wasm;
let handle: SessionHandle | null = null;
try {
const carrierBytes = new Uint8Array(msg.carrierImageBytes);
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
// Capture the unlock result in a non-null binding for the in-scope ops;
// `handle` stays the ownership tracker the finally block cleans up.
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON);
handle = h;
const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));
const { config } = msg;
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters');
await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
// SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
session.setCurrent(h);
state.gitHost = git;
state.manifest = { schema_version: 2, items: {} } as Manifest;
handle = null; // ownership transferred — do NOT lock-and-free in finally
return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
} finally {
// Plan A .free() policy (docs/...extension-restructure-design.md Risks): lock THEN free,
// and only if we still own the handle (success path transfers ownership to session.setCurrent).
if (handle) {
try { w.lock(handle); } catch { /* lock may already have happened */ }
handle.free();
}
}
}
export async function handleAttachVault(
msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
state: PopupState,
): Promise<AttachVaultResponse | { ok: false; error: string }> {
const w = state.wasm;
let handle: SessionHandle | null = null;
try {
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
const { config } = msg;
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
// The vault metadata and manifest are independent read-only GETs — fan out.
const [meta, encryptedManifest] = await Promise.all([
fetchVaultMeta(git),
git.readFile('manifest.enc'),
]);
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
handle = h;
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
// SW now owns the unlocked session — transfer ownership to the session.
session.setCurrent(h);
state.gitHost = git;
state.manifest = manifest;
handle = null; // ownership transferred — do NOT lock-and-free in finally
return { ok: true, data: { deviceName: msg.deviceName } };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
} finally {
// Same .free() policy as handleCreateVault: lock THEN free, only if we still
// own the handle (success path transfers ownership to session.setCurrent).
if (handle) {
try { w.lock(handle); } catch { /* lock may already have happened */ }
handle.free();
}
}
}
export interface VaultMeta {
salt: Uint8Array;
paramsJson: string;
@@ -395,3 +518,62 @@ export async function removeAttachmentsFromItem(
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
return removed;
}
// --- Manifest entry derivation ---
/**
* Project a decrypted Item into its ManifestEntry shape for browse-without-
* decrypt views. Both router files use this; defined here (the SW's
* vault-orchestration home) instead of duplicated in each router. Moved out
* of popup-only.ts / content-callable.ts as part of Plan C Phase 2 (P1.9).
*/
export function itemToManifestEntry(item: Item): ManifestEntry {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}
// --- Vault status (Plan C Phase 6) ---
/**
* Return the cached vault status for the sidebar indicator. Reads cached sync
* metadata off the GitHost (populated by the `sync` handler) plus a live count
* of active (non-trashed) items from the in-memory manifest. Does NOT touch
* the network — sync is user-initiated (spec 2026-05-04, Phase 6). The
* Pick-typed gitHost param both avoids a circular import of the router's
* PopupState and structurally forbids a network call from here.
*/
export function handleGetVaultStatus(
state: {
gitHost: Pick<GitHost, 'lastSyncAt' | 'ahead' | 'behind'> | null;
manifest: Manifest | null;
},
): GetVaultStatusResponse | { ok: false; error: string } {
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const pendingItems = state.manifest ? listItems(state.manifest).length : 0;
return {
ok: true,
data: {
ahead: state.gitHost.ahead,
behind: state.gitHost.behind,
lastSyncAt: state.gitHost.lastSyncAt,
pendingItems,
},
};
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { finishSetup } from '../setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { finishSetup, STEPS } from '../setup';
import { state, clearWizardState } from '../setup-steps';
describe('finishSetup', () => {
beforeEach(() => {
@@ -35,3 +36,47 @@ describe('finishSetup', () => {
expect(chrome.tabs.create).toHaveBeenCalled();
});
});
describe('setup step registry', () => {
it('has the six steps in canonical order', () => {
expect(STEPS.map((s) => s.id)).toEqual(['mode', 'host', 'connection', 'vault', 'device', 'done']);
});
it('each step renders non-empty HTML and attach returns a teardown', () => {
const ctx = { state: {} as never, rerender: vi.fn(), goto: vi.fn() };
for (const step of STEPS) {
const html = step.render(ctx as never);
expect(typeof html).toBe('string');
expect(html.length).toBeGreaterThan(0);
// render output must be in the DOM before attach (attach wires getElementById listeners)
document.body.innerHTML = `<div id="app">${html}</div>`;
const teardown = step.attach(document.body, ctx as never);
expect(typeof teardown).toBe('function');
teardown(); // must not throw
}
});
});
describe('clearWizardState', () => {
afterEach(() => {
clearWizardState();
});
it('zero-fills the reachable Uint8Array fields and resets state', () => {
const carrier = new Uint8Array([1, 2, 3, 4]);
const ref = new Uint8Array([5, 6, 7, 8]);
state.carrierImageBytes = carrier;
state.referenceImageBytes = ref;
state.passphrase = 'secret';
state.mode = 'new';
clearWizardState();
expect(Array.from(carrier)).toEqual([0, 0, 0, 0]); // fill(0) ran on the captured ref
expect(Array.from(ref)).toEqual([0, 0, 0, 0]);
expect(state.carrierImageBytes).toBeNull(); // field reset
expect(state.referenceImageBytes).toBeNull();
expect(state.passphrase).toBe('');
expect(state.mode).toBeNull();
});
});

View File

@@ -0,0 +1,805 @@
import { createGitHost } from '../service-worker/git-host';
import { probeVault } from './probe';
import type { VaultProbe } from './probe';
import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers';
import { GLYPH_NEXT } from '../shared/glyphs';
import type { VaultConfig } from '../shared/types';
import type { Request, Response } from '../shared/messages';
// --- SW messaging ---
export function swSend(msg: Request): Promise<Response> {
return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r)));
}
// --- Step registry types ---
export type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done';
export interface StepContext {
state: WizardState;
rerender: () => void;
goto: (id: StepId) => void;
}
export interface SetupStep {
id: StepId;
render: (ctx: StepContext) => string;
attach: (root: HTMLElement, ctx: StepContext) => () => void;
}
// --- State ---
export interface WizardState {
stepId: StepId;
mode: 'new' | 'attach' | null;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
connectionTested: boolean;
vaultProbe: VaultProbe | null;
carrierImageBytes: Uint8Array | null;
referenceImageBytesAttach: Uint8Array | null;
passphrase: string;
passphraseConfirm: string;
passphraseScore: number;
passphraseGuessesLog10: number;
passphraseVisible: boolean;
confirmVisible: boolean;
referenceImageBytes: Uint8Array | null;
creating: boolean;
attaching: boolean;
error: string | null;
deviceName: string;
}
export const state: WizardState = {
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null,
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
creating: false, attaching: false, error: null, deviceName: '',
};
// --- State-coupled helpers ---
function updateStrengthUi(): void {
const bar = document.getElementById('strength-bar');
const label = document.getElementById('strength-label');
const entropy = document.getElementById('entropy-line');
const counter = document.getElementById('passphrase-counter');
const matchInd = document.getElementById('match-indicator');
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
const score = state.passphraseScore;
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
if (label) {
if (score < 0) { label.className = 'strength-label'; label.innerHTML = '&nbsp;'; }
else {
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
label.className = `strength-label ${meta.cls}`;
label.textContent = meta.text;
}
}
if (entropy) {
const txt = entropyText(state.passphraseGuessesLog10);
entropy.textContent = txt;
entropy.style.visibility = txt ? 'visible' : 'hidden';
}
if (counter) {
const n = state.passphrase.length;
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
}
if (matchInd) {
const p = state.passphrase, c = state.passphraseConfirm;
if (!p || !c) { matchInd.className = 'match-indicator'; matchInd.textContent = ''; }
else if (p === c) { matchInd.className = 'match-indicator ok'; matchInd.textContent = '✓'; }
else { matchInd.className = 'match-indicator bad'; matchInd.textContent = '✗'; }
}
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
if (create) {
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
create.disabled = disabled;
create.title = disabled
? (score < 3 ? 'passphrase must score "good" or better'
: !state.passphraseConfirm ? 'confirm your passphrase'
: !matchOk ? 'passphrases do not match' : '')
: '';
}
}
function vaultConfig(): VaultConfig {
return {
hostType: state.hostType,
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
};
}
// --- mode ---
const modeStep: SetupStep = {
id: 'mode',
render() {
const isNew = state.mode === 'new';
const isAttach = state.mode === 'attach';
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>set up Relicario</h3>
<p class="muted" style="margin-bottom:16px;">How are you using Relicario on this device?</p>
<div class="mode-cards">
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<span class="mode-card__icon" style="font-size:28px;">◈</span>
<div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb">I'm setting up Relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository.</p>
</button>
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
<div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb">I already have a vault on another device. Connect this browser to it using my passphrase and reference image.</p>
</button>
</div>
<div class="form-actions" style="margin-top:24px;">
<button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.querySelectorAll('.mode-card').forEach((btn) => {
btn.addEventListener('click', () => {
state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach';
ctx.rerender();
});
});
document.getElementById('next-btn')?.addEventListener('click', () => {
if (state.mode) ctx.goto('host');
});
return () => {};
},
};
// --- host ---
const GITEA_INSTRUCTIONS = `
<div class="step-instructions"><ol>
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
<li>Go to <strong>Settings &rarr; Applications</strong></li>
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
<li>Copy the token &mdash; you will need it in the next step</li>
</ol></div>`;
const GITHUB_INSTRUCTIONS = `
<div class="step-instructions"><ol>
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
<li>Go to <strong>Settings &rarr; Developer settings &rarr; Personal access tokens &rarr; Fine-grained tokens</strong></li>
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
<li>Copy the token &mdash; you will need it in the next step</li>
</ol></div>`;
const hostStep: SetupStep = {
id: 'host',
render() {
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>choose host</h3>
<div class="form-group">
<label class="label">host type</label>
<div class="toggle-group">
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
</div>
</div>
${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS}
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('mode'));
document.querySelectorAll('.toggle-group button').forEach((btn) => {
btn.addEventListener('click', () => {
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
state.connectionTested = false;
ctx.rerender();
});
});
document.getElementById('next-btn')?.addEventListener('click', () => ctx.goto('connection'));
return () => {};
},
};
// --- connection ---
function renderProbeBanner(): string {
const probe = state.vaultProbe;
if (!state.connectionTested || !probe) return '';
const meta = probe.lastCommit
? `Last commit: <code>${escapeHtml(probe.lastCommit.sha)}</code> by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.`
: '';
if (state.mode === 'new' && probe.exists) {
return `
<div class="banner banner-warn">
<strong>⚠ This repository already contains a Relicario vault.</strong>
<p>${meta}</p>
<p>Creating a new vault here would overwrite the existing one and <strong>destroy all data inside</strong>. To use this vault on this device, switch to <em>attach</em> mode instead. If you really mean to start over, delete the repository via your git host's web UI and come back here.</p>
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="attach">switch to attach mode</button></div>
</div>`;
}
if (state.mode === 'attach' && !probe.exists) {
return `
<div class="banner banner-warn">
<strong>No vault found in this repo.</strong>
<p>Did you mean to create a new vault?</p>
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="new">switch to new-vault mode</button></div>
</div>`;
}
if (state.mode === 'attach' && probe.exists) {
return `
<div class="banner banner-ok">
<strong>✓ Existing vault found.</strong>
<p>${meta}</p>
<p>Continue to attach this device.</p>
</div>`;
}
// mode = new, !exists
return `<div class="banner banner-ok"><strong>✓ Repo is empty — ready to create a new vault.</strong></div>`;
}
const connectionStep: SetupStep = {
id: 'connection',
render() {
const probe = state.vaultProbe;
const modeMismatch =
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
const nextDisabled = !state.connectionTested || !probe || modeMismatch;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>configure connection</h3>
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
<label class="label" for="host-url">host url</label>
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
</div>
<div class="form-group">
<label class="label" for="repo-path">repository path</label>
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
</div>
<div class="form-group">
<label class="label" for="api-token">api token</label>
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
</div>
<div class="form-actions">
<button class="btn" id="test-btn">test connection</button>
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
</div>
${renderProbeBanner()}
<div class="form-actions" style="margin-top:12px;">
<button class="btn" id="back-btn">back</button>
<button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.getElementById('test-btn')?.addEventListener('click', async () => {
state.connectionTested = false;
state.vaultProbe = null;
const hostUrl = state.hostType === 'github'
? 'https://api.github.com'
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
if (!repoPath || !apiToken) {
state.error = 'Repository path and API token are required';
ctx.rerender();
return;
}
if (state.hostType === 'gitea' && !hostUrl) {
state.error = 'Host URL is required for Gitea';
ctx.rerender();
return;
}
state.hostUrl = hostUrl;
state.repoPath = repoPath;
state.apiToken = apiToken;
try {
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
await host.listDir('');
state.connectionTested = true;
state.error = null;
try {
state.vaultProbe = await probeVault(host);
} catch (probeErr) {
state.vaultProbe = null;
state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`;
}
} catch (err: unknown) {
state.connectionTested = false;
state.vaultProbe = null;
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
}
ctx.rerender();
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('host'));
document.getElementById('next-btn')?.addEventListener('click', () => {
if (state.connectionTested) ctx.goto('vault');
});
document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => {
state.mode = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach';
state.error = null;
ctx.rerender();
});
return () => {};
},
};
// --- vault ---
function renderVaultAttach(): string {
const p = state.passphrase;
const pType = state.passphraseVisible ? 'text' : 'password';
const pToggle = state.passphraseVisible ? 'hide' : 'show';
const hasImage = !!state.referenceImageBytesAttach;
const gateDisabled = !p || !hasImage;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;">Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.</p>
<div class="form-group">
<label class="label">reference image (JPEG)</label>
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
${hasImage ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
</div>
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter your passphrase" autocomplete="current-password">
<button type="button" class="eye-btn" id="eye-btn">${pToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="attach-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
</div>
</div>`;
}
function renderVaultNew(): string {
const score = state.passphraseScore;
const hasScore = score >= 0;
const meterClass = hasScore ? `s${score}` : '';
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
const labelClass = labelMeta?.cls ?? '';
const labelText = labelMeta?.text ?? '&nbsp;';
const entropy = entropyText(state.passphraseGuessesLog10);
const p = state.passphrase, c = state.passphraseConfirm;
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
const pType = state.passphraseVisible ? 'text' : 'password';
const cType = state.confirmVisible ? 'text' : 'password';
const pToggle = state.passphraseVisible ? 'hide' : 'show';
const cToggle = state.confirmVisible ? 'hide' : 'show';
const matchOk = !c || p === c;
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
const nChars = p.length;
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>create vault</h3>
<div class="form-group">
<label class="label">carrier image (JPEG)</label>
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
${state.carrierImageBytes ? '<p class="secondary">image loaded</p>' : '<p class="secondary">click to select a JPEG photo</p>'}
</div>
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
</div>
<div class="pass-help">A long phrase of unrelated words is stronger than a short complex password. Your vault needs <strong>good</strong> (score&nbsp;≥&nbsp;3) to continue.</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
</div>
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
<div class="seg i0"></div><div class="seg i1"></div><div class="seg i2"></div><div class="seg i3"></div><div class="seg i4"></div>
</div>
<div class="strength-row">
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
</div>
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
</div>
<div class="form-group">
<label class="label" for="passphrase-confirm">confirm passphrase</label>
<div class="passphrase-field">
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
</div>
</div>`;
}
const vaultStep: SetupStep = {
id: 'vault',
render(ctx) {
return ctx.state.mode === 'attach' ? renderVaultAttach() : renderVaultNew();
},
attach(_root, ctx) {
return state.mode === 'attach' ? attachVaultAttach(ctx) : attachVaultNew(ctx);
},
};
function attachVaultAttach(ctx: StepContext): () => void {
const refDrop = document.getElementById('ref-drop')!;
const refInput = document.getElementById('ref-input') as HTMLInputElement;
refDrop.addEventListener('click', () => refInput.click());
refInput.addEventListener('change', () => {
const file = refInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
passInput?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach;
});
document.getElementById('eye-btn')?.addEventListener('click', () => {
state.passphraseVisible = !state.passphraseVisible;
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
const btn = document.getElementById('eye-btn');
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
passInput?.focus();
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
document.getElementById('attach-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytesAttach) {
state.error = 'Please select your reference JPEG image';
ctx.rerender();
return;
}
if (!state.passphrase) {
state.error = 'Passphrase is required';
ctx.rerender();
return;
}
ctx.goto('device');
});
return () => {};
}
function attachVaultNew(ctx: StepContext): () => void {
const fileDrop = document.getElementById('file-drop')!;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
fileDrop.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
// Track passphrase changes inline (no full re-render) so the input keeps focus.
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
passInput?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
updateStrengthUi();
scheduleRate(state.passphrase, (s) => {
state.passphraseScore = s.score;
state.passphraseGuessesLog10 = s.guessesLog10;
updateStrengthUi();
});
});
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
confirmInput?.addEventListener('input', (e) => {
state.passphraseConfirm = (e.target as HTMLInputElement).value;
updateStrengthUi();
});
// Eye toggles — flip the input type and label without a full re-render so
// focus + cursor position survive the click.
document.getElementById('eye-btn')?.addEventListener('click', () => {
state.passphraseVisible = !state.passphraseVisible;
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
const btn = document.getElementById('eye-btn');
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
passInput?.focus();
});
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
state.confirmVisible = !state.confirmVisible;
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
const btn = document.getElementById('confirm-eye-btn');
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
confirmInput?.focus();
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
document.getElementById('create-btn')?.addEventListener('click', async () => {
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
if (!state.carrierImageBytes) {
state.error = 'Please select a carrier JPEG image';
ctx.rerender();
return;
}
if (!state.passphrase) {
state.error = 'Passphrase is required';
ctx.rerender();
return;
}
// Re-rate synchronously in case the button was clicked before the debounced
// rater fired. Defence in depth — the button is already disabled when score < 3.
const strength = await ratePassphrase(state.passphrase);
state.passphraseScore = strength.score;
state.passphraseGuessesLog10 = strength.guessesLog10;
if (state.passphraseScore < 3) {
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
ctx.rerender();
return;
}
if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match';
ctx.rerender();
return;
}
ctx.goto('device');
});
return () => {};
}
// --- device ---
const deviceStep: SetupStep = {
id: 'device',
render() {
const busy = state.creating || state.attaching;
const platform = navigator.platform.toLowerCase();
const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent);
const isFirefox = /firefox/i.test(navigator.userAgent);
const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser';
const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux';
const defaultName = state.deviceName || `${browser} on ${os}`;
const busyLabel = state.attaching ? 'attaching…' : 'creating…';
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>name this device</h3>
<p class="muted" style="margin-bottom:12px;">This helps you identify which devices have access to your vault.</p>
<div class="form-group">
<label class="label" for="device-name">device name</label>
<input id="device-name" type="text" value="${escapeHtml(defaultName)}" placeholder="e.g. Chrome on Linux" ${busy ? 'disabled' : ''}>
</div>
<div class="form-actions">
<button class="btn" id="back-btn" ${busy ? 'disabled' : ''}>back</button>
<button class="btn-primary" id="next-btn" ${busy ? 'disabled' : ''}>${busy ? `<span class="spinner"></span> ${busyLabel}` : `continue ${GLYPH_NEXT}`}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.getElementById('back-btn')?.addEventListener('click', () => {
if (!state.creating && !state.attaching) ctx.goto('vault');
});
document.getElementById('next-btn')?.addEventListener('click', async () => {
if (state.creating || state.attaching) return;
const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
if (!name) {
state.error = 'Device name is required';
ctx.rerender();
return;
}
state.deviceName = name;
state.error = null;
if (state.mode === 'attach') {
state.attaching = true;
ctx.rerender();
const resp = await swSend({
type: 'attach_vault',
config: vaultConfig(),
passphrase: state.passphrase,
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.attaching = false;
if (resp.ok) ctx.goto('done');
else { state.error = resp.error; ctx.rerender(); }
} else {
state.creating = true;
ctx.rerender();
const resp = await swSend({
type: 'create_vault',
config: vaultConfig(),
passphrase: state.passphrase,
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.creating = false;
if (resp.ok) {
const data = resp.data as { referenceImageBytes: Uint8Array };
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
ctx.goto('done');
} else { state.error = resp.error; ctx.rerender(); }
}
});
return () => {};
},
};
// --- done ---
const doneStep: SetupStep = {
id: 'done',
render() {
const isAttach = state.mode === 'attach';
const qrBannerHtml = isAttach ? '' : `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>`;
const refSection = isAttach ? '' : `
<div class="form-group">
<label class="label">reference image</label>
<p class="muted" style="margin-bottom:8px;">Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.</p>
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
</div>`;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box">
<h3>${isAttach ? 'device attached' : 'vault created'}</h3>
<p class="secondary">${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}</p>
</div>
${qrBannerHtml}
${refSection}
<div class="form-group" style="margin-top:16px;">
<label class="label">extension configuration</label>
<p class="muted" style="margin-bottom:8px;">
Copy this JSON to configure Relicario on another setup, or save it for later.
</p>
<div class="config-blob" id="config-blob">${escapeHtml(JSON.stringify(vaultConfig(), null, 2))}</div>
<button class="btn" id="copy-config-btn">copy to clipboard</button>
</div>
<div class="form-actions" style="margin-top:16px;">
<button class="btn btn-primary" id="open-vault-btn">open vault</button>
</div>
</div>`;
},
attach(_root, _ctx) {
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
try {
const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase });
if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error);
const svg = (resp.data as { svg: string }).svg;
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
const banner = document.getElementById('recovery-qr-banner');
if (banner) {
banner.innerHTML = `
<div style="text-align:center;">${svg}</div>
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">◉ Recovery QR generated — save or print this now.</p>
<div style="margin-top:8px;"><button class="btn" id="setup-qr-done">Done</button></div>`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.style.display = 'none';
});
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reference.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
const blob = document.getElementById('config-blob');
if (!blob) return;
try {
await navigator.clipboard.writeText(blob.textContent ?? '');
const btn = document.getElementById('copy-config-btn')!;
btn.textContent = 'copied!';
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
} catch {
const range = document.createRange();
range.selectNodeContents(blob);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
});
document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup());
return () => {};
},
};
// --- Registry ---
export const STEPS: ReadonlyArray<SetupStep> = [
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
];
// --- Sensitive-state cleanup ---
export function clearWizardState(): void {
// Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays.
state.carrierImageBytes?.fill(0);
state.referenceImageBytes?.fill(0);
state.referenceImageBytesAttach?.fill(0);
state.mode = null;
state.hostType = 'gitea';
state.hostUrl = '';
state.repoPath = '';
state.apiToken = '';
state.connectionTested = false;
state.vaultProbe = null;
state.carrierImageBytes = null;
state.referenceImageBytesAttach = null;
state.passphrase = '';
state.passphraseConfirm = '';
state.passphraseScore = -1;
state.passphraseGuessesLog10 = -1;
state.passphraseVisible = false;
state.confirmVisible = false;
state.referenceImageBytes = null;
state.creating = false;
state.attaching = false;
state.error = null;
state.deviceName = '';
}
// --- Completion handoff ---
/// Open the fullscreen vault tab and best-effort close the setup tab.
export async function finishSetup(): Promise<void> {
const vaultUrl = chrome.runtime.getURL('vault.html');
await chrome.tabs.create({ url: vaultUrl });
try {
const current = await chrome.tabs.getCurrent();
if (current?.id !== undefined) {
await chrome.tabs.remove(current.id);
}
} catch {
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
// The vault tab is open — that's the user-visible success.
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { registerHost, __resetHostForTests, sendMessage } from '../state';
import type { StateHost } from '../state';
import type { Response } from '../messages';
function makeHost(response: { ok: boolean; error?: string }): StateHost {
return {
getState: () => ({ view: 'list' } as never),
setState: vi.fn(),
navigate: vi.fn(),
sendMessage: vi.fn().mockResolvedValue(response as Response),
escapeHtml: (s) => s,
popOutToTab: vi.fn(),
isInTab: () => false,
openVaultTab: vi.fn(),
};
}
describe('shared/state sendMessage vault_locked intercept', () => {
beforeEach(() => __resetHostForTests());
it('navigates to the lock screen on a vault_locked response', async () => {
const host = makeHost({ ok: false, error: 'vault_locked' });
registerHost(host);
await sendMessage({ type: 'list_items' });
expect(host.navigate).toHaveBeenCalledWith(
'locked',
expect.objectContaining({ error: expect.any(String) }),
);
});
it('does NOT intercept the unlock request itself', async () => {
const host = makeHost({ ok: false, error: 'vault_locked' });
registerHost(host);
await sendMessage({ type: 'unlock', passphrase: 'x' });
expect(host.navigate).not.toHaveBeenCalled();
});
it('does NOT intercept is_unlocked', async () => {
const host = makeHost({ ok: false, error: 'vault_locked' });
registerHost(host);
await sendMessage({ type: 'is_unlocked' });
expect(host.navigate).not.toHaveBeenCalled();
});
it('does not intercept a successful response', async () => {
const host = makeHost({ ok: true });
registerHost(host);
await sendMessage({ type: 'list_items' });
expect(host.navigate).not.toHaveBeenCalled();
});
it('does not intercept a non-vault_locked error', async () => {
const host = makeHost({ ok: false, error: 'something_else' });
registerHost(host);
await sendMessage({ type: 'list_items' });
expect(host.navigate).not.toHaveBeenCalled();
});
it('returns the response unchanged', async () => {
const host = makeHost({ ok: false, error: 'vault_locked' });
registerHost(host);
const resp = await sendMessage({ type: 'list_items' });
expect(resp).toEqual({ ok: false, error: 'vault_locked' });
});
});

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
registerHost,
__resetHostForTests,
getState,
setState,
navigate,
sendMessage,
} from '../state';
import type { StateHost } from '../state';
import type { PopupState } from '../popup-state';
function makeHost(initial?: Partial<PopupState>): StateHost {
let state: PopupState = {
view: 'list',
entries: [],
selectedId: null,
selectedItem: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
error: null,
loading: false,
capturedTabId: null,
capturedUrl: '',
newType: null,
vaultSettings: null,
generatorDefaults: null,
historyItemId: null,
...initial,
};
return {
getState: () => state,
setState: (partial) => { state = { ...state, ...partial }; },
navigate: vi.fn(),
sendMessage: vi.fn().mockResolvedValue({ ok: true }),
escapeHtml: (s) => s,
popOutToTab: vi.fn(),
isInTab: () => false,
openVaultTab: vi.fn(),
};
}
describe('shared/state', () => {
beforeEach(() => {
__resetHostForTests();
});
it('register-then-getState round-trips', () => {
const host = makeHost({ view: 'detail' });
registerHost(host);
expect(getState().view).toBe('detail');
});
it('double-register throws', () => {
registerHost(makeHost());
expect(() => registerHost(makeHost())).toThrow(/already registered/);
});
it('__resetHostForTests clears the singleton', () => {
registerHost(makeHost());
__resetHostForTests();
expect(() => getState()).toThrow(/No state host/);
});
it('getState without host throws', () => {
expect(() => getState()).toThrow(/No state host/);
});
it('setState merges partial state', () => {
const host = makeHost();
registerHost(host);
setState({ loading: true });
expect(getState().loading).toBe(true);
});
it('navigate delegates to host', () => {
const host = makeHost();
registerHost(host);
navigate('settings');
expect(host.navigate).toHaveBeenCalledWith('settings', undefined);
});
it('sendMessage delegates to host', async () => {
const host = makeHost();
registerHost(host);
const resp = await sendMessage({ type: 'is_unlocked' });
expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' });
expect(resp).toEqual({ ok: true });
});
});

View File

@@ -19,6 +19,11 @@ export const GLYPH_LOCK = '⏻'; // sidebar lock nav
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
export const GLYPH_COPY = '⎘'; // copy to clipboard
export const GLYPH_SYNC = '⇅'; // sync / upload
export const GLYPH_REFRESH = '↻'; // manual refresh (vault status indicator); shares ↻ with GENERATE, distinct semantic
export const GLYPH_SYNCED = '✓'; // vault status: in sync (no pending/ahead/behind)
export const GLYPH_AHEAD = '↑'; // vault status: local commits ahead of remote
export const GLYPH_BEHIND = '↓'; // vault status: remote commits not yet pulled
export const GLYPH_PENDING = '◌'; // vault status: items changed but not yet synced
export const GLYPH_PREVIEW = '⊕'; // preview / expand
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab

View File

@@ -63,7 +63,12 @@ export type PopupMessage =
| { type: 'import_lastpass_commit'; items: Item[] }
| { type: 'preview_totp_from_secret'; secret_b32: string }
| { type: 'generate_recovery_qr'; passphrase: string }
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string }
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
carrierImageBytes: ArrayBuffer; deviceName: string }
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
referenceImageBytes: ArrayBuffer; deviceName: string }
| { type: 'get_vault_status' };
// --- Messages a content script may send ---
@@ -176,6 +181,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'parse_lastpass_csv', 'import_lastpass_commit',
'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
'create_vault', 'attach_vault', 'get_vault_status',
] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
@@ -201,6 +207,20 @@ export interface ImportLastPassCommitResponse extends Extract<Response, { ok: tr
};
}
export interface CreateVaultResponse extends Extract<Response, { ok: true }> {
data: { referenceImageBytes: Uint8Array; deviceName: string;
recoveryQrAvailable: true };
}
export interface AttachVaultResponse extends Extract<Response, { ok: true }> {
data: { deviceName: string };
}
export interface GetVaultStatusResponse extends Extract<Response, { ok: true }> {
data: { ahead: number; behind: number; lastSyncAt: number | null;
pendingItems: number };
}
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
'capture_save_login',

View File

@@ -0,0 +1,57 @@
// State shared between popup and vault surfaces. Kept here (not in popup/) so
// shared/state.ts can import without creating a popup→shared circular dep.
import type {
Item,
ItemId,
ItemType,
ManifestEntry,
GeneratorRequest,
VaultSettings,
} from './types';
export type View =
| 'locked'
| 'list'
| 'detail'
| 'add'
| 'edit'
| 'settings'
| 'settings-vault'
| 'trash'
| 'devices'
| 'field-history'
// Vault-tab-only views; popup never navigates to these. Kept in the union so
// a single typed StateHost contract serves both surfaces (popup + vault).
| 'history'
| 'backup'
| 'import';
export interface PopupState {
view: View;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
error: string | null;
loading: boolean;
// Captured tab snapshot taken at popup-open. Used by fill_credentials
// to guard against TOCTOU navigation — the SW re-checks this URL's
// hostname against the tab's live URL before forwarding fill_credentials
// to the content script. See router/popup-only.ts#handleFillCredentials.
capturedTabId: number | null;
capturedUrl: string;
newType: ItemType | null;
vaultSettings: VaultSettings | null;
generatorDefaults: GeneratorRequest | null;
historyItemId: ItemId | null;
// Vault-tab-only fields. The popup surface leaves these at their defaults
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
// Kept on the shared shape so VaultState satisfies StateHost.getState()
// without a cast.
unlocked?: boolean;
drawerOpen?: boolean;
typePanelOpen?: boolean;
}

View File

@@ -1,15 +1,21 @@
/// Service-locator for cross-bundle state access.
///
/// Both popup.ts and vault.ts register themselves as the "host".
/// All popup components import from here instead of from popup.ts,
/// so the same component code works in either bundle.
// extension/src/shared/state.ts
//
// Single channel for popup and vault-tab UI to read/write app state and
// dispatch messages to the service worker. Two registered hosts (popup,
// vault tab) implement StateHost; each surface calls registerHost(this) at
// boot.
//
// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts
// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature;
// the body is a thin pass-through until Phase 4.
import type { Request, Response } from './messages';
import type { PopupState, View } from './popup-state';
export interface StateHost {
getState(): any;
setState(partial: any): void;
navigate(view: string, extras?: any): void;
getState(): PopupState;
setState(partial: Partial<PopupState>): void;
navigate(view: View, extras?: Partial<PopupState>): void;
sendMessage(request: Request): Promise<Response>;
escapeHtml(s: string): string;
popOutToTab(): void;
@@ -19,26 +25,58 @@ export interface StateHost {
let host: StateHost | null = null;
export function registerHost(h: StateHost): void { host = h; }
export function registerHost(h: StateHost): void {
if (host) throw new Error('state host already registered');
host = h;
}
export function getState(): any {
/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */
export function __resetHostForTests(): void {
host = null;
}
export function getState(): PopupState {
if (!host) throw new Error('No state host registered');
return host.getState();
}
export function setState(partial: any): void {
export function setState(partial: Partial<PopupState>): void {
if (!host) throw new Error('No state host registered');
host.setState(partial);
}
export function navigate(view: string, extras?: any): void {
export function navigate(view: View, extras?: Partial<PopupState>): void {
if (!host) throw new Error('No state host registered');
host.navigate(view, extras);
}
export function sendMessage(request: Request): Promise<Response> {
// Requests that must NOT trigger the lock screen on a vault_locked response:
// they run during cold start / unlock, before a session exists, so a
// vault_locked here is expected rather than a lost session.
const VAULT_LOCKED_BYPASS: ReadonlySet<Request['type']> = new Set([
'unlock', 'is_unlocked',
]);
/**
* Dispatches a request to the service worker and intercepts the `vault_locked`
* response. MV3 evicts the service worker after ~30s idle, wiping the in-memory
* session/manifest; the next RPC comes back `vault_locked`. Any surface (popup
* or vault tab) that gets that on a non-bypassed request treats it as "session
* lost" and navigates to the lock screen so the user can re-enter their
* passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4
* so both surfaces share one channel.
*/
export async function sendMessage(request: Request): Promise<Response> {
if (!host) throw new Error('No state host registered');
return host.sendMessage(request);
const response = await host.sendMessage(request);
if (
!response.ok &&
response.error === 'vault_locked' &&
!VAULT_LOCKED_BYPASS.has(request.type)
) {
host.navigate('locked', { error: 'Session expired — please unlock again.' });
}
return response;
}
export function escapeHtml(s: string): string {
@@ -52,7 +90,7 @@ export function popOutToTab(): void {
}
export function isInTab(): boolean {
if (!host) return false;
if (!host) throw new Error('No state host registered');
return host.isInTab();
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { ensureDrawerClosedForRoute } from '../vault-drawer';
describe('ensureDrawerClosedForRoute', () => {
it('closes the drawer when navigating to trash', () => {
const state = { drawerOpen: true };
ensureDrawerClosedForRoute(state, { view: 'trash' });
expect(state.drawerOpen).toBe(false);
});
it('leaves the drawer open when navigating to detail', () => {
const state = { drawerOpen: true };
ensureDrawerClosedForRoute(state, { view: 'detail' });
expect(state.drawerOpen).toBe(true);
});
it('leaves the drawer open in list view', () => {
const state = { drawerOpen: true };
ensureDrawerClosedForRoute(state, { view: 'list' });
expect(state.drawerOpen).toBe(true);
});
it('does nothing when the drawer is already closed', () => {
const state = { drawerOpen: false };
ensureDrawerClosedForRoute(state, { view: 'devices' });
expect(state.drawerOpen).toBe(false);
});
});

View File

@@ -4,7 +4,7 @@ import * as path from 'path';
describe('fullscreen form dirty subtitle', () => {
const vaultSrc = fs.readFileSync(
path.resolve(__dirname, '../vault.ts'),
path.resolve(__dirname, '../vault-form-wrapper.ts'),
'utf-8',
);

View File

@@ -4,7 +4,7 @@ import * as path from 'path';
describe('vault sidebar glyphs', () => {
const vaultSrc = fs.readFileSync(
path.resolve(__dirname, '../vault.ts'),
path.resolve(__dirname, '../vault-sidebar.ts'),
'utf-8',
);

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { renderStatusIndicator } from '../vault-status';
describe('vault status indicator', () => {
it('renders "in sync" when ahead/behind/pending all zero', () => {
const el = document.createElement('div');
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 });
expect(el.textContent).toMatch(/in sync/i);
});
it('renders "N ahead" when ahead is non-zero', () => {
const el = document.createElement('div');
renderStatusIndicator(el, { ahead: 3, behind: 0, lastSyncAt: 1700000000, pendingItems: 0 });
expect(el.textContent).toMatch(/3 ahead/i);
});
it('renders "N behind" when behind is non-zero', () => {
const el = document.createElement('div');
renderStatusIndicator(el, { ahead: 0, behind: 2, lastSyncAt: 1700000000, pendingItems: 0 });
expect(el.textContent).toMatch(/2 behind/i);
});
it('renders "N pending" when pendingItems is non-zero', () => {
const el = document.createElement('div');
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 5 });
expect(el.textContent).toMatch(/5 pending/i);
});
it('renders "never synced" when lastSyncAt is null', () => {
const el = document.createElement('div');
renderStatusIndicator(el, { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 });
expect(el.textContent).toMatch(/never synced/i);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { handleGetVaultStatus } from '../../service-worker/vault';
import { renderStatusIndicator } from '../vault-status';
import type { Manifest, ManifestEntry } from '../../shared/types';
// Integration seam: the get_vault_status SW handler (Phase 6 Task 6.1) produces
// the exact data shape the sidebar renderer (Task 6.2) consumes. This pins the
// contract between the two so a future change to either side can't silently
// drift the keys apart. It does NOT touch vault-sidebar.ts — the wiring layer
// (Task 6.3) is Dev-B's boundary and lands separately.
const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) =>
({ lastSyncAt, ahead, behind });
function manifestWith(activeCount: number, trashedCount = 0): Manifest {
const items: Record<string, ManifestEntry> = {};
for (let i = 0; i < activeCount; i++) {
items[`a${i}`] = { trashed_at: undefined } as ManifestEntry;
}
for (let i = 0; i < trashedCount; i++) {
items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry;
}
return { items } as Manifest;
}
describe('vault status: handler → renderer integration', () => {
it('renders "in sync" from a freshly-synced, no-pending handler response', () => {
const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(0) });
expect(resp.ok).toBe(true);
if (!resp.ok) return;
const el = document.createElement('div');
renderStatusIndicator(el, resp.data);
expect(el.textContent).toMatch(/in sync/i);
expect(el.textContent).toMatch(/last sync/i);
});
it('surfaces the handler\'s active-item count as "N pending" in the DOM', () => {
const resp = handleGetVaultStatus({ gitHost: cache(1700000000), manifest: manifestWith(7, 3) });
expect(resp.ok).toBe(true);
if (!resp.ok) return;
expect(resp.data.pendingItems).toBe(7);
const el = document.createElement('div');
renderStatusIndicator(el, resp.data);
expect(el.textContent).toMatch(/7 pending/i);
});
it('surfaces cached ahead/behind from the handler in the DOM', () => {
const resp = handleGetVaultStatus({ gitHost: cache(1700000000, 2, 1), manifest: manifestWith(0) });
expect(resp.ok).toBe(true);
if (!resp.ok) return;
const el = document.createElement('div');
renderStatusIndicator(el, resp.data);
expect(el.textContent).toMatch(/2 ahead/i);
expect(el.textContent).toMatch(/1 behind/i);
});
it('renders "never synced" when the handler reports a null lastSyncAt', () => {
const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: manifestWith(0) });
expect(resp.ok).toBe(true);
if (!resp.ok) return;
const el = document.createElement('div');
renderStatusIndicator(el, resp.data);
expect(el.textContent).toMatch(/never synced/i);
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderSidebarShell, wireSidebar } from '../vault-sidebar';
import type { VaultController } from '../vault-context';
const STATUS = { ahead: 0, behind: 0, lastSyncAt: 1700000000, pendingItems: 4 };
function makeCtx() {
return {
state: { searchQuery: '' },
sendMessage: vi.fn(async (req: { type: string }) =>
req.type === 'get_vault_status'
? { ok: true, data: STATUS }
: { ok: true }),
render: vi.fn(),
renderPane: vi.fn(),
renderListPane: vi.fn(),
closeDrawer: vi.fn(),
openTypePanel: vi.fn(),
setHash: vi.fn(),
applyShellViewClass: vi.fn(),
} as unknown as VaultController;
}
function statusCalls(ctx: VaultController): number {
return (ctx.sendMessage as ReturnType<typeof vi.fn>).mock.calls
.filter((c) => (c[0] as { type: string }).type === 'get_vault_status').length;
}
describe('vault-sidebar status wiring', () => {
beforeEach(() => { document.body.innerHTML = renderSidebarShell(); });
afterEach(() => { document.body.innerHTML = ''; });
it('fetches + renders the indicator on mount', async () => {
const ctx = makeCtx();
wireSidebar(ctx);
await vi.waitFor(() => {
expect(document.getElementById('vault-status-slot')?.textContent).toMatch(/4 pending/i);
});
expect(statusCalls(ctx)).toBe(1);
});
it('re-fetches on the manual refresh button', async () => {
const ctx = makeCtx();
wireSidebar(ctx);
await vi.waitFor(() => expect(statusCalls(ctx)).toBe(1));
document.getElementById('status-refresh-btn')?.dispatchEvent(new Event('click'));
await vi.waitFor(() => expect(statusCalls(ctx)).toBe(2));
});
it('does NOT poll on a timer', async () => {
vi.useFakeTimers();
try {
const ctx = makeCtx();
wireSidebar(ctx);
await vi.advanceTimersByTimeAsync(60_000);
// Only the single mount fetch — no interval re-fetches.
expect(statusCalls(ctx)).toBe(1);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,122 @@
// Shared contract for the vault-tab modules. vault.ts owns the state
// singleton and assembles the VaultController; each vault-* module receives
// it as `ctx`. This module sits at the bottom of the dependency graph —
// it imports only from shared/, never from vault.ts or its sibling modules.
import type { Request, Response } from '../shared/messages';
import type {
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
} from '../shared/types';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../shared/glyphs';
export type VaultView =
| 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings'
| 'settings-vault' | 'field-history' | 'history' | 'backup' | 'import';
export interface HashRoute {
view: VaultView;
id?: string;
type?: string;
}
export interface VaultState {
unlocked: boolean;
view: VaultView;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
drawerOpen: boolean;
typePanelOpen: boolean;
vaultSettings: VaultSettings | null;
generatorDefaults: GeneratorRequest | null;
error: string | null;
loading: boolean;
newType: ItemType | null;
capturedTabId: number | null;
capturedUrl: string;
historyItemId: ItemId | null;
}
// The controller passed to every vault-* module. vault.ts builds one instance
// and wires each hook to the function that currently lives in vault.ts (later
// Phase-4 tasks repoint individual hooks at the extracted module functions).
export interface VaultController {
readonly state: VaultState;
sendMessage(request: Request): Promise<Response>;
render(): void;
renderPane(): void;
renderListPane(): void;
renderSidebarCategories(): void;
renderDrawer(item: Item): void;
applyShellViewClass(): void;
setHash(view: VaultView, param?: string): void;
openDrawer(): void;
closeDrawer(): void;
selectItemForDrawer(id: string): Promise<void>;
openTypePanel(): void;
closeTypePanel(): void;
wireSidebar(): void;
loadManifest(): Promise<void>;
}
// --- pure helpers (no state, no DOM dependencies beyond the args) ---
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function typeIcon(t: ItemType): string {
switch (t) {
case 'login': return GLYPH_TYPE_LOGIN;
case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
case 'identity': return GLYPH_TYPE_IDENTITY;
case 'card': return GLYPH_TYPE_CARD;
case 'key': return GLYPH_TYPE_KEY;
case 'document': return GLYPH_TYPE_DOCUMENT;
case 'totp': return GLYPH_TYPE_TOTP;
}
}
export function typeLabel(t: ItemType): string {
const labels: Record<ItemType, string> = {
login: 'Login',
secure_note: 'Secure Note',
identity: 'Identity',
card: 'Card',
key: 'SSH / API Key',
document: 'Document',
totp: 'TOTP',
};
return labels[t];
}
export function getFilteredEntries(
state: VaultState,
): Array<[ItemId, ManifestEntry]> {
let filtered = state.entries.filter(
([, e]) => e.trashed_at === undefined || e.trashed_at === null,
);
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
filtered = filtered.filter(([, e]) => {
if (e.title.toLowerCase().includes(q)) return true;
if (e.icon_hint?.toLowerCase().includes(q)) return true;
if (e.group?.toLowerCase().includes(q)) return true;
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
return false;
});
}
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
return filtered;
}

View File

@@ -0,0 +1,138 @@
// Vault-tab drawer: the right-hand overlay that previews a selected item
// (open/close/render + item selection). Receives the VaultController (`ctx`)
// and reaches sibling concerns through it; pure helpers come from
// vault-context. Imports only from shared/ and vault-context.
import type { Item } from '../shared/types';
import {
type VaultController, type VaultState, type HashRoute, escapeHtml,
} from './vault-context';
export function openDrawer(): void {
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
}
export function closeDrawer(ctx: VaultController): void {
ctx.state.drawerOpen = false;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
}
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
const core = item.core as unknown as Record<string, unknown>;
if (!core) return [];
const fields: Array<[string, string, boolean]> = [];
switch (item.type) {
case 'login':
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
if ('password' in core) fields.push(['password', '••••••••', false]);
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
break;
case 'card': {
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
if ('expiry' in core && core.expiry) {
const exp = core.expiry as { month: number; year: number };
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
}
if ('cvv' in core) fields.push(['cvv', '•••', false]);
if ('pin' in core) fields.push(['pin', '••••', false]);
break;
}
case 'identity':
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
break;
case 'key':
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
break;
case 'secure_note':
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
break;
case 'totp':
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
break;
case 'document':
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
break;
}
if (item.notes) fields.push(['notes', item.notes, true]);
return fields;
}
export function renderDrawer(ctx: VaultController, item: Item): void {
const drawer = document.getElementById('vault-drawer');
if (!drawer) return;
const coreFields = getDrawerCoreFields(item);
drawer.innerHTML = `
<div class="vault-drawer__header">
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
<div class="vault-drawer__actions">
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
</div>
</div>
<div class="vault-drawer__body">
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
${item.type === 'login' && (item.core as { url?: string }).url
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
: ''}
<div class="vault-drawer__field-grid">
${coreFields.map(([label, value, full]) => `
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
closeDrawer(ctx);
ctx.renderListPane();
});
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
if (ctx.state.selectedId) {
ctx.setHash('edit', ctx.state.selectedId);
ctx.renderPane();
}
});
}
export async function selectItemForDrawer(ctx: VaultController, id: string): Promise<void> {
const resp = await ctx.sendMessage({ type: 'get_item', id });
if (!resp.ok) return;
const data = resp.data as { item: Item };
ctx.state.selectedId = id;
ctx.state.selectedItem = data.item;
ctx.state.drawerOpen = true;
ctx.renderSidebarCategories();
ctx.renderListPane();
renderDrawer(ctx, data.item);
openDrawer();
}
// Drawer is an overlay only meaningful on the list/detail surfaces; any
// other route must clear it so it doesn't leak across navigation (P2 fix).
const DRAWER_KEEPING_VIEWS: ReadonlySet<string> = new Set(['list', 'detail']);
export function ensureDrawerClosedForRoute(
state: Pick<VaultState, 'drawerOpen'>,
route: Pick<HashRoute, 'view'>,
): void {
if (!DRAWER_KEEPING_VIEWS.has(route.view)) state.drawerOpen = false;
}

View File

@@ -0,0 +1,72 @@
// Fullscreen form wrapper for the vault tab: sticky save bar + scrollable
// content + header with a live dirty-state subtitle. Receives the
// VaultController (`ctx`) for the item-type read; imports only from shared/,
// the popup item-form component, and vault-context.
import { renderItemForm } from '../popup/components/item-form';
import { type VaultController } from './vault-context';
// ---------------------------------------------------------------------------
// Platform-aware save hint
// ---------------------------------------------------------------------------
const isMac = navigator.platform.toLowerCase().includes('mac');
const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
// ---------------------------------------------------------------------------
// Fullscreen form wrapper — sticky save bar + scrollable content + header
// ---------------------------------------------------------------------------
export function renderFormWrapped(ctx: VaultController, app: HTMLElement, mode: 'add' | 'edit'): void {
const itemType = ctx.state.selectedItem?.type ?? ctx.state.newType ?? 'login';
const typeLabelText = itemType.replace('_', ' ');
const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
const wrapper = document.createElement('div');
wrapper.className = 'form-pane';
wrapper.innerHTML = `
<div class="fullscreen-form-header">
<div>
<div class="title">${titleText}</div>
<div class="sub" id="form-dirty-sub">no changes</div>
</div>
<div class="hint">${SAVE_HINT}</div>
</div>
<div class="form-scroll" id="form-scroll"></div>
<div class="sticky-save-bar">
<button class="btn-secondary" id="form-cancel">cancel</button>
<button class="btn-primary" id="form-save">save</button>
</div>
`;
// Remove pane padding so form-pane can fill height cleanly
app.style.padding = '0';
app.style.overflow = 'hidden';
app.replaceChildren(wrapper);
const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement;
renderItemForm(scrollEl, mode);
const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement;
let isDirty = false;
const markDirty = () => {
if (isDirty) return;
isDirty = true;
subEl.textContent = 'unsaved · esc to cancel';
};
const markClean = () => {
isDirty = false;
subEl.textContent = 'no changes';
};
scrollEl.addEventListener('input', markDirty, true);
scrollEl.addEventListener('change', markDirty, true);
wrapper.querySelector('#form-cancel')?.addEventListener('click', () => {
markClean();
(scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click();
});
wrapper.querySelector('#form-save')?.addEventListener('click', () => {
markClean();
(scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click();
});
}
export const __test__ = { renderFormWrapped };

View File

@@ -0,0 +1,52 @@
// Vault-tab list column: renders the middle list pane (row markup, empty
// state, and the row-click → drawer selection). Receives the VaultController
// (`ctx`) and reaches sibling concerns through it; pure helpers come from
// vault-context. Imports only from shared/ and vault-context.
import type { ItemId, ManifestEntry, ItemType } from '../shared/types';
import { relativeTime } from '../shared/relative-time';
import {
type VaultController, escapeHtml, typeIcon, getFilteredEntries,
} from './vault-context';
export function renderListPane(ctx: VaultController): void {
const pane = document.getElementById('vault-list-pane');
if (!pane) return;
const group = ctx.state.activeGroup as ItemType | null;
let items = getFilteredEntries(ctx.state);
if (group) items = items.filter(([, e]) => e.type === group);
if (items.length === 0) {
pane.innerHTML = `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">${ctx.state.searchQuery ? '⊘' : '◈'}</span>
<div class="empty-state__title">${ctx.state.searchQuery ? `No results for "${escapeHtml(ctx.state.searchQuery)}"` : 'No items yet'}</div>
<div class="empty-state__hint">${ctx.state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
</div>
`;
return;
}
pane.innerHTML = items.map(([id, e]: [ItemId, ManifestEntry]) => {
const sel = id === ctx.state.selectedId ? ' vault-list-row--selected' : '';
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
return `
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
<div class="vault-list-row__text">
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
</div>
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
</div>
`;
}).join('');
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
row.addEventListener('click', async () => {
await ctx.selectItemForDrawer(row.dataset.id!);
});
});
}

View File

@@ -0,0 +1,205 @@
// Vault-tab routing core: hash parsing/serialization, pane dispatch (delegating
// to the shared popup components), and data loading. Receives the
// VaultController (`ctx`) and reaches sibling concerns through it. Imports only
// from shared/, the popup components, vault-context, vault-drawer, and
// vault-form-wrapper — never from vault.ts or the shell/sidebar/list modules.
import type {
ItemId, ItemType, ManifestEntry, Item, VaultSettings,
} from '../shared/types';
import { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form';
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
import { renderSettings, teardownSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
import {
type VaultController, type VaultView, type HashRoute,
} from './vault-context';
import { ensureDrawerClosedForRoute } from './vault-drawer';
import { renderFormWrapped } from './vault-form-wrapper';
// ---------------------------------------------------------------------------
// Hash routing
// ---------------------------------------------------------------------------
export function parseHash(): HashRoute {
let raw = window.location.hash.replace(/^#\/?/, '');
if (!raw) return { view: 'list' };
// Normalize legacy bookmarks: #field-history/<id> → #history/<id>
if (raw.startsWith('field-history/')) {
raw = 'history/' + raw.slice('field-history/'.length);
window.location.hash = raw;
}
const parts = raw.split('/');
const view = parts[0] as VaultView;
switch (view) {
case 'detail':
case 'edit':
return { view, id: parts[1] };
case 'add':
return { view, type: parts[1] };
case 'history':
return parts[1]
? { view: 'field-history', id: parts[1] }
: { view: 'history' };
case 'trash':
case 'devices':
case 'settings':
case 'settings-vault':
case 'field-history':
case 'backup':
case 'import':
return { view };
default:
return { view: 'list' };
}
}
export function setHash(view: VaultView, param?: string): void {
const fragment = param ? `${view}/${param}` : view;
window.location.hash = fragment === 'list' ? '' : fragment;
}
// ---------------------------------------------------------------------------
// Pane rendering — delegates to shared popup components
// ---------------------------------------------------------------------------
function teardownPaneComponents(): void {
teardownTrash();
teardownDevices();
teardownSettings();
teardownFieldHistory();
teardownHistoryIndex();
teardownBackup();
teardownImport();
}
export function renderPane(ctx: VaultController): void {
const pane = document.getElementById('vault-pane');
if (!pane) return;
teardownPaneComponents();
const route = parseHash();
ensureDrawerClosedForRoute(ctx.state, route);
// Keep state.view in sync with hash for components that read it
ctx.state.view = route.view;
ctx.applyShellViewClass();
pane.className = 'vault-pane';
switch (route.view) {
case 'detail':
if (ctx.state.selectedItem) {
renderItemDetail(pane);
} else {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
break;
case 'add':
// Prefer hash type for deep-links; otherwise keep the in-memory value
// set by the type-selection click handler (which calls setState →
// renderPane before the URL hash has been updated to include the type).
ctx.state.newType = (route.type as ItemType) ?? ctx.state.newType ?? null;
// Use the form wrapper (sticky bar + header) when a type is already chosen.
// Without a type the type-selection screen renders — no sticky bar needed.
if (ctx.state.newType) {
renderFormWrapped(ctx, pane, 'add');
} else {
renderItemForm(pane, 'add');
}
break;
case 'edit':
renderFormWrapped(ctx, pane, 'edit');
break;
case 'trash':
renderTrash(pane);
break;
case 'devices':
renderDevices(pane);
break;
case 'settings':
void renderSettings(pane);
break;
case 'settings-vault':
renderVaultSettingsView(pane);
break;
case 'field-history':
renderFieldHistory(pane);
break;
case 'history':
renderItemHistoryIndex(pane);
break;
case 'backup':
renderBackupPanel(pane);
break;
case 'import':
renderImportPanel(pane);
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
break;
}
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
export async function loadManifest(ctx: VaultController): Promise<void> {
const listResp = await ctx.sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
ctx.state.entries = data.items;
}
const vsResp = await ctx.sendMessage({ type: 'get_vault_settings' });
if (vsResp.ok) {
const data = vsResp.data as { settings: VaultSettings };
ctx.state.vaultSettings = data.settings;
ctx.state.generatorDefaults = data.settings.generator_defaults;
}
// Handle deep link from hash
const route = parseHash();
if (route.view === 'detail' && route.id) {
const itemResp = await ctx.sendMessage({ type: 'get_item', id: route.id });
if (itemResp.ok) {
const data = itemResp.data as { item: Item };
ctx.state.selectedId = route.id;
ctx.state.selectedItem = data.item;
}
}
}
// ---------------------------------------------------------------------------
// Legacy selectItem — used by hash-change deep linking
// ---------------------------------------------------------------------------
export async function selectItem(ctx: VaultController, id: ItemId): Promise<void> {
ctx.state.loading = true;
const resp = await ctx.sendMessage({ type: 'get_item', id });
if (resp.ok) {
const data = resp.data as { item: Item };
ctx.state.selectedId = id;
ctx.state.selectedItem = data.item;
ctx.state.loading = false;
setHash('detail', id);
ctx.renderSidebarCategories();
ctx.renderListPane();
renderPane(ctx);
} else {
ctx.state.loading = false;
ctx.state.error = (resp as { error: string }).error;
}
}

View File

@@ -0,0 +1,236 @@
// Vault-tab shell: render entry point, lock screen, 3-column shell
// scaffolding, the right-side type-picker panel, color-scheme apply, and the
// session_expired listener. Each function receives the VaultController (`ctx`)
// and reaches sibling concerns through it; pure helpers come from
// vault-context. vault.ts owns the state singleton and assembles the ctx.
import type { ItemType } from '../shared/types';
import { lookupErrorCopy } from '../shared/error-copy';
import { applyColorScheme } from '../shared/color-scheme';
import {
type VaultController, escapeHtml, typeIcon,
} from './vault-context';
import { renderSidebarShell } from './vault-sidebar';
// ---------------------------------------------------------------------------
// Type picker (right side panel)
// ---------------------------------------------------------------------------
const PICKER_TYPES: Array<{ type: ItemType; label: string }> = [
{ type: 'login', label: 'Login' },
{ type: 'secure_note', label: 'Secure Note' },
{ type: 'totp', label: 'TOTP' },
{ type: 'card', label: 'Card' },
{ type: 'identity', label: 'Identity' },
{ type: 'key', label: 'SSH / API Key' },
{ type: 'document', label: 'Document' },
];
// ---------------------------------------------------------------------------
// Render entry point
// ---------------------------------------------------------------------------
export function render(ctx: VaultController): void {
const app = document.getElementById('vault-app');
if (!app) return;
if (!ctx.state.unlocked) {
renderLockScreen(ctx, app);
} else {
renderShell(ctx, app);
}
}
// ---------------------------------------------------------------------------
// Lock screen
// ---------------------------------------------------------------------------
function renderErrorBlock(code: string | null | undefined): string {
if (!code) return '';
const copy = lookupErrorCopy(code);
const ctaHtml = copy.cta
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
: '';
return `
<div class="error error-block">
<div class="error-title">${escapeHtml(copy.title)}</div>
<div class="error-body">${escapeHtml(copy.body)}</div>
${ctaHtml}
</div>
`;
}
export function renderLockScreen(ctx: VaultController, app: HTMLElement): void {
app.innerHTML = `
<div class="vault-lock-screen">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<span class="brand">Relicario</span>
<div class="vault-lock-screen__form">
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
${renderErrorBlock(ctx.state.error)}
</div>
</div>
`;
const input = document.getElementById('vault-passphrase') as HTMLInputElement;
const btn = document.getElementById('vault-unlock-btn')!;
const doUnlock = async () => {
const passphrase = input.value;
if (!passphrase) return;
btn.textContent = 'unlocking...';
btn.setAttribute('disabled', 'true');
const resp = await ctx.sendMessage({ type: 'unlock', passphrase });
if (resp.ok) {
ctx.state.unlocked = true;
ctx.state.error = null;
await ctx.loadManifest();
render(ctx);
} else {
ctx.state.error = resp.error ?? 'unlock failed';
render(ctx);
}
};
btn.addEventListener('click', doUnlock);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doUnlock();
});
input.focus();
}
// ---------------------------------------------------------------------------
// Shell (3-column: sidebar + list pane + drawer)
// ---------------------------------------------------------------------------
export function renderShell(ctx: VaultController, app: HTMLElement): void {
if (!app.querySelector('.vault-shell')) {
app.innerHTML = `
<div class="vault-shell">
${renderSidebarShell()}
<div class="vault-list-pane" id="vault-list-pane"></div>
<div class="vault-pane" id="vault-pane"></div>
<div class="vault-drawer" id="vault-drawer"></div>
<div class="vault-type-panel-scrim" id="vault-type-scrim"></div>
<aside class="vault-type-panel" id="vault-type-panel" aria-label="Choose item type"></aside>
</div>
`;
ctx.wireSidebar();
wireTypePanel(ctx);
}
applyShellViewClass(ctx);
ctx.renderSidebarCategories();
if (ctx.state.view === 'list') {
ctx.renderListPane();
if (ctx.state.drawerOpen && ctx.state.selectedItem) {
ctx.renderDrawer(ctx.state.selectedItem);
}
} else {
ctx.renderPane();
}
}
// Toggle which middle column is visible based on the current view.
// list view → list-pane (+ optional drawer); other views → vault-pane.
export function applyShellViewClass(ctx: VaultController): void {
const shell = document.querySelector('.vault-shell');
if (!shell) return;
shell.classList.toggle('vault-shell--list', ctx.state.view === 'list');
shell.classList.toggle('vault-shell--pane', ctx.state.view !== 'list');
}
// ---------------------------------------------------------------------------
// Right-side type picker panel
// ---------------------------------------------------------------------------
export function wireTypePanel(ctx: VaultController): void {
document.getElementById('vault-type-scrim')?.addEventListener('click', () => closeTypePanel(ctx));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && ctx.state.typePanelOpen) closeTypePanel(ctx);
});
}
export function openTypePanel(ctx: VaultController): void {
const panel = document.getElementById('vault-type-panel');
const scrim = document.getElementById('vault-type-scrim');
if (!panel || !scrim) return;
panel.innerHTML = `
<div class="vault-type-panel__head">
<div class="vault-type-panel__title">New item</div>
<button class="vault-type-panel__close" id="vault-type-close" title="Close (Esc)" aria-label="Close">✕</button>
</div>
<div class="vault-type-panel__hint">Choose a type</div>
<div class="vault-type-list" role="menu">
${PICKER_TYPES.map((t) => `
<button class="vault-type-item" data-type="${t.type}" role="menuitem">
<span class="vault-type-item__icon" aria-hidden="true">${typeIcon(t.type)}</span>
<span class="vault-type-item__name">${escapeHtml(t.label)}</span>
</button>
`).join('')}
</div>
`;
panel.classList.add('vault-type-panel--open');
scrim.classList.add('vault-type-panel-scrim--visible');
ctx.state.typePanelOpen = true;
panel.querySelector('#vault-type-close')?.addEventListener('click', () => closeTypePanel(ctx));
panel.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
btn.addEventListener('click', () => {
const type = btn.dataset.type as ItemType;
closeTypePanel(ctx);
// Use the host's navigate hook so view + hash + visibility all update
// together. This was the bug: bare setHash + renderPane left the
// shell stuck in list view with #vault-pane hidden.
ctx.state.newType = type;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.drawerOpen = false;
ctx.state.view = 'add';
ctx.setHash('add', type);
applyShellViewClass(ctx);
ctx.renderSidebarCategories();
ctx.renderPane();
});
});
// Focus first item for keyboard users
(panel.querySelector('.vault-type-item') as HTMLElement | null)?.focus();
}
export function closeTypePanel(ctx: VaultController): void {
document.getElementById('vault-type-panel')?.classList.remove('vault-type-panel--open');
document.getElementById('vault-type-scrim')?.classList.remove('vault-type-panel-scrim--visible');
ctx.state.typePanelOpen = false;
}
// ---------------------------------------------------------------------------
// Color scheme + session-expired wiring (bootstrap helpers)
// ---------------------------------------------------------------------------
export async function applyVaultColorScheme(): Promise<void> {
await applyColorScheme();
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && 'password_display_scheme' in changes) {
void applyColorScheme();
}
});
}
export function wireSessionExpiredListener(ctx: VaultController): void {
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'session_expired') {
ctx.state.unlocked = false;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.entries = [];
ctx.state.error = null;
render(ctx);
}
});
}

View File

@@ -0,0 +1,190 @@
// Vault-tab sidebar column: its static markup, the category nav rendering,
// nav-button wiring, and the (now debounced) search input. Each function
// receives the VaultController (`ctx`) and reaches sibling concerns through it;
// pure helpers come from vault-context. Imports only from shared/ and
// vault-context, plus the leaf renderer vault-status — never from vault-shell
// or vault.ts.
import type { ItemType } from '../shared/types';
import {
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK, GLYPH_REFRESH,
} from '../shared/glyphs';
import { renderStatusIndicator, type VaultStatus } from './vault-status';
import {
type VaultController, typeIcon, typeLabel, getFilteredEntries,
} from './vault-context';
const SEARCH_DEBOUNCE_MS = 80;
// ---------------------------------------------------------------------------
// Sidebar markup
// ---------------------------------------------------------------------------
export function renderSidebarShell(): string {
return `
<div class="vault-sidebar">
<div class="vault-sidebar__header">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<span class="brand">Relicario</span>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search…" />
</div>
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
</div>
<div class="vault-sidebar__footer">
<div id="vault-status-slot"></div>
<button class="vault-status-refresh" id="status-refresh-btn" type="button" title="Refresh status" aria-label="Refresh status">${GLYPH_REFRESH}</button>
</div>
</div>`;
}
// ---------------------------------------------------------------------------
// Sidebar wiring
// ---------------------------------------------------------------------------
export function wireSidebar(ctx: VaultController): void {
// Search (debounced — trailing edge)
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
let searchTimer: number | undefined;
searchInput?.addEventListener('input', () => {
if (searchTimer !== undefined) clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
ctx.state.searchQuery = searchInput.value;
renderSidebarCategories(ctx);
ctx.renderListPane();
}, SEARCH_DEBOUNCE_MS);
});
// Nav buttons
document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => {
btn.addEventListener('click', async () => {
const nav = (btn as HTMLElement).dataset.nav;
if (nav === 'lock') {
await ctx.sendMessage({ type: 'lock' });
ctx.state.unlocked = false;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.entries = [];
ctx.render();
return;
}
if (nav === 'add') {
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.newType = null;
ctx.state.drawerOpen = false;
ctx.closeDrawer();
ctx.openTypePanel();
return;
}
if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') {
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.newType = null;
ctx.state.drawerOpen = false;
ctx.state.view = nav;
ctx.setHash(nav);
ctx.applyShellViewClass();
ctx.renderPane();
return;
}
});
});
// Global "/" shortcut to focus search; Esc to close drawer
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !isEditableTarget(e.target)) {
e.preventDefault();
searchInput?.focus();
return;
}
if (e.key === 'Escape' && ctx.state.drawerOpen) {
ctx.closeDrawer();
ctx.renderListPane();
}
});
// Vault status indicator — refresh on mount + on the manual button only.
// No timer polling: get_vault_status returns cached state and sync is
// user-initiated (spec 2026-05-04, Phase 6).
const refreshStatus = async (): Promise<void> => {
const resp = await ctx.sendMessage({ type: 'get_vault_status' });
if (!resp.ok) return;
const slot = document.getElementById('vault-status-slot');
if (slot) renderStatusIndicator(slot, resp.data as VaultStatus);
};
void refreshStatus();
document.getElementById('status-refresh-btn')?.addEventListener('click', () => {
void refreshStatus();
});
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (target.isContentEditable) return true;
return false;
}
// ---------------------------------------------------------------------------
// Sidebar category nav
// ---------------------------------------------------------------------------
export function renderSidebarCategories(ctx: VaultController): void {
const container = document.getElementById('vault-categories');
if (!container) return;
const filtered = getFilteredEntries(ctx.state);
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
const allCount = filtered.length;
const isAllActive = !ctx.state.activeGroup && ctx.state.view === 'list';
let html = `
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
<span class="vault-category-row__icon">◈</span>
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
</button>
`;
for (const t of typeOrder) {
const count = filtered.filter(([, e]) => e.type === t).length;
// Always show Login (staple type); hide other types when empty.
if (count === 0 && t !== 'login') continue;
const isActive = ctx.state.activeGroup === t;
html += `
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
<span class="vault-category-row__icon">${typeIcon(t)}</span>
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
</button>
`;
}
container.innerHTML = html;
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
btn.addEventListener('click', () => {
ctx.state.activeGroup = btn.dataset.group || null;
ctx.state.drawerOpen = false;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.view = 'list';
ctx.setHash('list');
ctx.applyShellViewClass();
renderSidebarCategories(ctx);
ctx.renderListPane();
ctx.closeDrawer();
});
});
}

View File

@@ -0,0 +1,33 @@
import {
GLYPH_SYNCED,
GLYPH_AHEAD,
GLYPH_BEHIND,
GLYPH_PENDING,
} from '../shared/glyphs';
import { relativeTime } from '../shared/relative-time';
import type { GetVaultStatusResponse } from '../shared/messages';
// The indicator consumes exactly the get_vault_status response payload; alias
// it (rather than re-declaring the four fields) so the shape stays single-
// sourced and can't drift from the SW handler. lastSyncAt is a unix timestamp
// in SECONDS, or null when the vault has never synced.
export type VaultStatus = GetVaultStatusResponse['data'];
export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void {
const ts = status.lastSyncAt !== null
? `last sync ${relativeTime(status.lastSyncAt)}`
: 'never synced';
const parts: string[] = [];
if (status.pendingItems > 0) parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`);
if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`);
if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`);
if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`);
el.innerHTML = `
<div class="vault-status">
<div class="vault-status__state">${parts.join(' · ')}</div>
<div class="vault-status__ts">${ts}</div>
</div>
`;
}

View File

@@ -2113,3 +2113,39 @@ textarea {
.history-index-row__info { flex: 1; display: flex; flex-direction: column; }
.history-index-row__title { color: var(--text); }
.history-index-row__meta { font-size: 11px; }
/* Sidebar-footer vault status indicator (Plan C Phase 6, vault-status.ts +
vault-sidebar.ts). Indicator renders into #vault-status-slot; the ↻ button
triggers a manual refresh (no timer polling). */
.vault-sidebar__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--border-subtle);
}
#vault-status-slot { flex: 1; min-width: 0; }
.vault-status {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
line-height: 1.4;
}
.vault-status__state { color: var(--text-dim); }
.vault-status__ts { color: var(--text-muted); }
.vault-status-refresh {
flex: none;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
font-size: 13px;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
}
.vault-status-refresh:hover { color: var(--text); background: var(--bg-input); }
.vault-status-refresh:focus-visible { outline: none; box-shadow: var(--focus-ring); }

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,10 @@ describe("RelayQueue", () => {
assert.ok(isRole("dev-a"));
assert.ok(isRole("dev-b"));
assert.ok(isRole("dev-c"));
assert.ok(!isRole("dev-d"));
assert.ok(isRole("dev-d"));
assert.ok(isRole("dev-e"));
assert.ok(isRole("dev-f"));
assert.ok(!isRole("dev-g"));
assert.ok(!isRole(""));
assert.ok(!isRole("PM"));
});

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c";
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c" | "dev-d" | "dev-e" | "dev-f";
export type MessageKind = "status" | "question" | "directive" | "free";
export interface RelayMessage {
@@ -12,7 +12,7 @@ export interface RelayMessage {
ts: string;
}
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c"]);
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"]);
export function isRole(s: string): s is Role {
return KNOWN_ROLES.has(s);
@@ -24,6 +24,9 @@ export class RelayQueue {
["dev-a", []],
["dev-b", []],
["dev-c", []],
["dev-d", []],
["dev-e", []],
["dev-f", []],
]);
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {

View File

@@ -20,12 +20,12 @@ const TOOLS = [
properties: {
from: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
description: "Your role name",
},
to: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
description: "Recipient role name",
},
kind: {
@@ -50,7 +50,7 @@ const TOOLS = [
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
description: "Your role name",
},
},
@@ -66,7 +66,7 @@ const TOOLS = [
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b", "dev-c"],
enum: ["pm", "dev-a", "dev-b", "dev-c", "dev-d", "dev-e", "dev-f"],
description: "Your role name",
},
},