212 Commits

Author SHA1 Message Date
adlee-was-taken
108965ec84 tools(relay): durable JSONL message log + pm wrapper + 120-char preview
- queue.ts: append every posted message to relay-log.jsonl (full body,
  survives the consume-once drain + restarts). gitignored.
- server.ts: bump the stdout preview from 60 to 120 chars.
- tools/relay/pm: absolute-path bash wrapper (read|pending|send) so relay
  ops work from any cwd without cd or hand-built JSON escaping.
- Fold in Dev-C's Phase 6 ARCHITECTURE.md slice as a coordination artifact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:51:02 -04:00
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
adlee-was-taken
d2d11a4c9f chore: release v0.6.0
Rolls up four weeks of post-v0.5.0 work into one tag:

- Phase 2B polish foundation + form layout (2026-05-02, 5da1e52)
- v0.5.1 Stream A — 3-column vault layout + toast + bottom sheet (2026-05-03, c16adc4)
- v0.5.1 Stream B — left-nav settings (2026-05-03, bd6a301)
- v0.5.1 Stream C — Recovery QR + setup wizard Style C (2026-05-03, 934dfe0)
- 1C-γ — Document item type + attachments + device registration + trash + history
- Plan B refactor (Cycles 1+2) — commands/ split, prompt_or_flag, core/WASM seam
- Vault-tab management surfaces revamp (2026-05-24..30) — settings split, devices fingerprint, trash countdown, history polish
- Doc-structure redesign (2026-05-30) — DESIGN/CRYPTO/docs/FORMATS rename + scope headers + Next: footers
- Lock-screen logo for parity with popup unlock
- 17 stale tests updated to match post-Stream-B / post-revamp components

Versions: relicario-{core,cli,wasm} → 0.6.0; extension/package.json → 0.6.0.
relicario-server stays at 0.1.0 (separate cadence).

Suite status at tag time: 371/371 extension + 281 Rust tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:17:54 -04:00
adlee-was-taken
361f3b4368 fix(ext/tests): router register_this_device test references current API
The router migrated from generate_device_keypair → register_device
(returns signing_public_key + deploy_public_key with private keys
staying internal to WASM). Test still mocked the old function under
the old return shape (public_key_hex / private_key_base64), so the
router's state.wasm.register_device() call failed with
"is not a function".

Updates the mock function name, response shape, and assertion to the
current contract. Test intent (treat the WASM return as a JS object,
not a JSON string) is preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:16:14 -04:00
adlee-was-taken
c9802ef392 fix(ext/tests): update settings.test.ts for left-nav settings + revamp
Tests were written against the pre-Stream B flat settings page. After
the left-nav restructure (bd6a301) and the management-surfaces revamp,
the Display section's IDs are only in the DOM once the user navigates
there, and renderSettings makes additional sendMessage calls (is_unlocked,
per-section data) that the original mocks didn't cover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:14:36 -04:00
adlee-was-taken
797709b441 fix(ext/tests): update devices tests for revamp (fingerprint + two-step revoke)
Tests predated the 2026-05-24 management-surfaces revamp (047df6e): popup
devices pane now shows SHA-256 fingerprint + added-by + inline two-step
revoke confirm, and the SW revokeDevice signature may have shifted to
match. Mocks + assertions updated accordingly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:27:51 -04:00
adlee-was-taken
0bde0935c2 docs: STATUS/ROADMAP — close out post-audit cleanup iteration
Three commits landed since the prior sync (72a59c6) that should be
reflected here:
- cccb7d7  rule #4 + doc-structure plan ticks
- 39ae629  vault lock-screen logo
- (this commit)

Moves the doc-structure redesign from "in progress" to "complete"
(Task 5 verified clean), drops the lock-screen logo from in-flight,
and trims Up next to the four genuinely-outstanding items: tag cut,
CLI restructure, extension restructure, security polish.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:12:41 -04:00
adlee-was-taken
39ae629894 feat(ext/vault): add brand logo to lock screen
Mirrors the logo-lockup treatment already used in the popup unlock view
(Phase 2B) and the setup wizard. Lock-screen rendering now shows the
relicario-logo.svg above the wordmark instead of just the wordmark.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:11:46 -04:00
adlee-was-taken
cccb7d7ff3 docs: add CLAUDE.md rule #4 (plan-state hygiene) + tick doc-structure plan
Rule #4 codifies the discipline that prevents the kind of drift the
2026-05-30 status-audit found: Phase 2B, v0.5.1 Streams A/B/C, and 1C-γ
all stealth-shipped 2-3 weeks earlier with their plan checkboxes never
ticked and STATUS.md still listing them as "Up next".

Two halves to the rule:
- Ship side: ticking the boxes is part of shipping. A commit that lands
  plan work also ticks that plan's boxes (or an immediately-following
  docs commit does).
- Execute side: before starting an unchecked plan, spot-check git log
  for distinctive symbols/files — re-executing already-merged work is
  the worst failure mode of the drift.

Also applies the rule retroactively to the doc-structure redesign plan:
all 37 sub-step checkboxes flipped to [x]. Tasks 1-4 (rename, scope
headers + Next: footers, link fixes, CLAUDE.md table) shipped in
36a59cd..bae3f7c. Task 5's six verification steps all pass (Step 3's
grep matches are false positives — they're correct new-path sibling
links from inside docs/ to docs/, not stale old-path uses).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:11:33 -04:00
adlee-was-taken
72a59c666d docs: sync STATUS / ROADMAP with three weeks of stealth-shipped work
The 2026-05-30 sync commit (fa659eb) only covered the vault-tab
management surfaces revamp. It missed three earlier merges that landed
2026-05-02..05-03 and have been on main since:

- Phase 2B polish foundation + form layout (5da1e52, 2026-05-02)
- v0.5.1 Stream A — 3-column vault layout + bottom sheet + toast +
  GLYPH_VAULT_TAB + emoji sweep (c16adc4, 2026-05-03)
- v0.5.1 Stream B — left-nav settings (Autofill / Display / Security /
  Generator / Retention / Backup / Import) (bd6a301, 2026-05-03)
- v0.5.1 Stream C — Recovery QR end-to-end (core + WASM + CLI +
  settings-security.ts + setup wizard banner) + setup wizard Style C
  redesign (934dfe0, 2026-05-03)

Also missing: 1C-γ (attachments + Document type + device registration
+ trash + history), Plan B multi-stream refactor (Cycles 1+2), and
the in-flight doc-structure redesign Tasks 1-4 (commits 36a59cd..bae3f7c
since spec 3209bfb).

STATUS now lists each train with merge SHA, spec/plan pointers, and
per-feature bullets. ROADMAP's "Up next" / "Medium-term" / "Long-term"
sections retrimmed: the only genuinely outstanding work is doc-structure
Task 5 verification, the lock-screen logo, the v0.5.x tag, and the
three 2026-05-04 architecture-review specs (CLI restructure, extension
restructure, security polish — none have plans yet).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:56:22 -04:00
adlee-was-taken
bae3f7c946 docs(CLAUDE.md): update living-docs table + add discipline rules
Table row labels now reference DESIGN.md / docs/CRYPTO.md /
docs/FORMATS.md. Adds three new discipline rules attacking the
structural causes of the 2026-05-30 drift audit findings:

  1. Scope-boundary check — content goes in the doc whose scope
     header claims it; if it doesn't fit, move it instead of
     stretching the header.
  2. Code-constant pinning — docs that cite code constants must
     cite source file + line; constant changes update doc and
     code in the same commit.
  3. New-doc rule — adding a tour doc also requires updating
     DESIGN's code-map, the Next: footer chain, and this table.

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 18:11:06 -04:00
adlee-was-taken
01377e7b59 docs: fix incoming links to renamed/moved doc paths
Rewrites every markdown reference to the old paths:
- ARCHITECTURE.md → DESIGN.md
- docs/ARCHITECTURE.md → docs/CRYPTO.md
- FORMATS.md → docs/FORMATS.md

Touches CLAUDE.md (living-docs table + planning-references list),
per-crate ARCHITECTURE.md cross-refs, and any specs in
docs/superpowers/specs/ that referenced the old paths. Audit
history and test-run logs intentionally left untouched.

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 18:09:30 -04:00
adlee-was-taken
5e7023fcc1 docs: add scope headers + Next: footers to all tour docs
Each of the eight tour docs (README, DESIGN, docs/CRYPTO,
docs/FORMATS, docs/SECURITY, crates/relicario-core/ARCHITECTURE,
crates/relicario-cli/ARCHITECTURE, extension/ARCHITECTURE) now
declares its scope in a blockquote under its H1 and ends with a
single-line "Next:" pointer to the next doc in the canonical
reading order: README → DESIGN → CRYPTO → FORMATS → SECURITY →
core → cli → extension.

Also trimmed README's mid-section "Architecture" stub to a one-
paragraph pointer at DESIGN.md (was duplicating cross-codebase
content and referencing a non-existent docs/architecture/ tree).

Renamed docs/CRYPTO.md's H1 from "Relicario — Architecture" to
"Relicario — Crypto Pipeline" to match the file's renamed scope.

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 15:36:46 -04:00
adlee-was-taken
36a59cd564 docs: rename for doc-structure redesign — DESIGN / CRYPTO / docs/FORMATS
Mechanical renames only; no content changes. Tracked as renames so
git blame / git log --follow survive intact.

- ARCHITECTURE.md → DESIGN.md (top-level system tour)
- docs/ARCHITECTURE.md → docs/CRYPTO.md (crypto pipeline)
- FORMATS.md → docs/FORMATS.md (wire formats; aligns with docs/ layout)

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 15:29:12 -04:00
adlee-was-taken
9ffb0f108b docs(plan): doc-structure redesign — 5-task implementation plan
Five sequential tasks, one commit each, all mechanical:
  1. git mv the three doc files
  2. add scope headers + Next: footers to the eight tour docs
     (also trim README architecture stub)
  3. fix incoming links to old paths
  4. update CLAUDE.md table + add 3 discipline rules
  5. verification gate

Spec: docs/superpowers/specs/2026-05-30-doc-structure-redesign-design.md
2026-05-30 15:27:45 -04:00
adlee-was-taken
3209bfb410 docs(spec): doc-structure redesign — tour-shaped, topic-named, scope-pinned
Proposes renaming the three overlapping ARCHITECTURE.md files into
topic-named docs (top-level → DESIGN.md, docs/ARCHITECTURE.md →
docs/CRYPTO.md), moving FORMATS.md into docs/, and adding scope
headers + "Next:" footers to every tour doc so the reading order is
canonical: README → DESIGN → CRYPTO → FORMATS → SECURITY →
per-crate ARCHITECTURE → extension/ARCHITECTURE.

Direct response to the drift audit run earlier today (the audit's
content fixes already landed in 210232d, cf7478d, fa659eb). This
spec attacks the structural causes: name collisions, no scope
boundaries, no reading-order signposts, root/docs/ asymmetry.

Migration is mechanical — 5 sequential commits, no content rewrites:
rename, headers+footers, link-fixes, CLAUDE.md update, verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:10:06 -04:00
adlee-was-taken
fa659eb390 docs: sync STATUS / ROADMAP / extension ARCHITECTURE with shipped work
Punch items from doc audit:
- STATUS: "in progress" section was carrying ghost items (vault
  container max-width, README name fix) with no matching commits or
  working-tree changes; trimmed to the one real in-flight item.
- STATUS + ROADMAP: trash/history/devices/settings management-surfaces
  revamp shipped 2026-05-24..05-30 (commits c943a06..88d7228) but was
  still listed as "up next" / medium-term; moved to shipped with
  per-commit SHAs.
- STATUS: v0.5.0 was described as the current tag, but only v0.2.0 and
  four plan-1* tags exist; rephrased as "v0.5.0 train on main, untagged".
- ROADMAP: "Vault lock screen + container polish (in progress)"
  collapsed to just the lock-screen logo (the only real in-flight item).
- extension/ARCHITECTURE: module map missing four shipped components —
  popup/components/form-header.ts, popup/components/settings-security.ts,
  vault/components/backup-panel.ts (#backup route),
  vault/components/import-panel.ts (#import route); all added with
  matching #backup / #import route entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:25:02 -04:00
adlee-was-taken
cf7478d178 docs: refresh per-crate ARCHITECTURE — missing core modules + CLI commands
Punch items from doc audit:
- relicario-core: module map missing 5 public modules (backup,
  device, import_lastpass, recovery_qr, tar_safe); added with
  1-2 sentence descriptions in the existing voice.
- relicario-core: "ed25519-dalek is a dependency placeholder" was
  stale — device.rs now consumes it for signing/verify/keypair.
- relicario-cli: Rate (zxcvbn scoring) and RecoveryQr (generate/unwrap)
  commands were absent from Key flows; added.
- relicario-cli: "Backup-passphrase-style commands (none yet)" rewritten
  — Backup (export/restore .relbak) and Import (lastpass) both shipped.
- relicario-cli: module map refreshed — handlers moved out of main.rs
  into commands/, plus prompt.rs/parse.rs/device.rs/gitea.rs surfaced.

Stale main.rs:NNNN line citations on individual flows are not fixed
here — those handlers now live in commands/*.rs and warrant a deeper
pass later.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:24:49 -04:00
adlee-was-taken
210232d156 docs: fix crypto/format drift — version byte 0x02, AttachmentId 32 hex, DCT 5-50
Punch items from doc audit:
- docs/ARCHITECTURE.md: encrypted file format diagram said version byte
  0x01; actual VERSION_BYTE is 0x02 (crypto.rs:59) and 0x01 is rejected
  with UnsupportedFormatVersion.
- docs/ARCHITECTURE.md: DCT embedding diagram said "Repeat secret 20+
  times" and "positions 4-15"; actual is MIN_COPIES (5) to 50 copies
  chosen by capacity, embedded in zig-zag positions 6-17
  (imgsecret.rs:78, 99-104, 530-537).
- FORMATS.md: AttachmentId table said 16 hex chars / 8 bytes; actual is
  32 hex chars / first 16 bytes of SHA-256 (ids.rs:59-69).
- FORMATS.md: ManifestEntry schema missing r#type field; updated to list
  all ten fields in declared order with serde decorations noted
  (manifest.rs:21-38).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:24:40 -04:00
adlee-was-taken
74a520bada docs: STATUS + extension ARCHITECTURE update for management-surfaces revamp 2026-05-30 13:00:58 -04:00
adlee-was-taken
88d7228570 feat(extension): wire history sidebar slot + #history/<id> route normalization 2026-05-30 13:00:58 -04:00
adlee-was-taken
32e1632c42 feat(extension): add item-history-index pane (lists items with field history) 2026-05-30 13:00:58 -04:00
adlee-was-taken
32e674eb40 feat(extension): field-history pane visual polish — section headers + glyph buttons 2026-05-30 13:00:58 -04:00
adlee-was-taken
ed6e21806f feat(extension): trash pane revamp — per-item purge countdown + glyph restore 2026-05-30 13:00:58 -04:00
adlee-was-taken
047df6eb72 feat(extension): devices pane revamp — fingerprint + added-by + inline two-step revoke 2026-05-30 13:00:58 -04:00
adlee-was-taken
299e7db1ab feat(extension): settings pane revamp — synced/local split + session timeout UI 2026-05-30 13:00:58 -04:00
adlee-was-taken
1edfa67a51 feat(extension): add SSH SHA256 fingerprint util (webcrypto) 2026-05-30 13:00:58 -04:00
adlee-was-taken
367adcedc6 feat(extension): add shared section-header/glyph-btn/kv-row/fingerprint CSS
Add four utility classes to both vault.css and popup styles.css for use in
settings/devices/trash/history management surfaces. These provide standardized
styling for section headers, glyph buttons, key-value rows, and fingerprints
that will be used across all revamped panes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:00:58 -04:00
adlee-was-taken
a587965528 refactor(extension): consolidate 5 relativeTime copies into shared util 2026-05-30 13:00:58 -04:00
adlee-was-taken
9da45dd478 feat(extension): add shared relative-time util with tests 2026-05-30 13:00:58 -04:00
adlee-was-taken
c943a06918 feat(extension): add history/revoke/restore glyph constants 2026-05-30 13:00:58 -04:00
adlee-was-taken
30816c2fe3 docs: implementation plan for vault-tab management surfaces revamp
12 tasks covering settings/devices/trash/history pane revamps, plus
groundwork (glyph constants, relative-time util, ssh-fingerprint util,
shared CSS classes) and routing/nav wiring. Tasks are TDD where the
work is testable (utils) and bite-sized manual-smoke where it's UI.

Spec corrections folded in:
- Devices revoke is upgrade (text+confirm → glyph+inline), not greenfield
- Fingerprint via webcrypto in extension, no SW shape change, no WASM
- Routing keeps 'field-history' as internal dispatch key; only user-facing
  hash normalizes #field-history → #history for backward compat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:01:01 -04:00
adlee-was-taken
1c9fa1e343 docs: add vault-tab management surfaces revamp spec
Brainstormed design covering UX revamp of all four in-vault admin
panes (Settings, Devices, Trash, History) to match the fullscreen
visual language. Closes functional gaps along the way: per-device
session-timeout UI, revoke button surfacing, SHA256 fingerprint +
added-by display, per-item purge countdown, and a new history
index pane.

Item history uses option A (aggregate existing field_history per
item) — no new core storage, no schema change. Ships in v0.5.x
inside the current vault.ts shell; Phase 3 shell rearchitecture and
Phase 4 command palette deferred to their own rounds.

Roadmap entry reconciled to point at the spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 13:19:46 -04:00
adlee-was-taken
2de250a41e docs: promote overview.md to root ARCHITECTURE.md + add update discipline table
Move docs/architecture/overview.md to ARCHITECTURE.md at the repo root —
it is the primary cross-codebase architecture doc (four-codebase diagram,
inter-codebase contracts, secrets map, build matrix, test strategy, where-to-look
table) and belongs at the root alongside STATUS.md, ROADMAP.md, etc.

Update relative paths inside the file (../../crates/ → crates/, etc.).
Update CHANGELOG.md's one active reference to the old path.

Add a "Living docs — update discipline" table to CLAUDE.md that maps every
ALLCAPS.md file to the area it covers and the trigger for updating it. This
closes the loop on the ALLCAPS.md documentation system.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:44:30 -04:00
adlee-was-taken
1758edd5c8 docs: add STATUS/ROADMAP/FORMATS and update CLAUDE.md planning guidance
Introduce three new ALLCAPS.md tracking files:
- STATUS.md: living doc of in-flight work and what shipped in v0.5.0
- ROADMAP.md: full roadmap extracted from CLAUDE.md + expanded with all specced work
- FORMATS.md: wire-format quick-reference (.enc blobs, params.json, devices.json, etc.)

Update CLAUDE.md to replace the single-spec "Design spec" section with a
"Planning & design specs" section that instructs checking docs/superpowers/specs/
and docs/superpowers/plans/ before any planning or implementation work.
Also add the rule to update STATUS.md after every dev iteration, and replace
the stale v0.5.0-in-progress roadmap paragraph with references to the new files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:44:30 -04:00
a30c04242f Update README.md 2026-05-09 16:36:58 +00:00
adlee-was-taken
8e81ef8b8b chore(license): switch from MIT to GPL-3.0-or-later
Adds top-level LICENSE (GPL-3.0 full text), updates README, and sets
`license = "GPL-3.0-or-later"` on all four crate manifests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 12:30:49 -04:00
adlee-was-taken
888a05146b feat(cli): alias gen for generate subcommand + add -l / -w short flags
clap alias makes both `relicario gen -l 32` and the long-form
`relicario generate -l 32` route to the same handler. The short flags
-l (length) and -w (words) were missing -- the READMEs existing example
`relicario generate -l 32` only actually worked after this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 12:22:20 -04:00
adlee-was-taken
4bf5e1dc37 docs(readme): document recovery QR + sync feature list with current code
- Quick start gains backup export and recovery-qr generate examples so
  first-time readers see those features without scrolling.
- New "Recovery: what if I lose my reference image?" section explains the
  recovery QR mitigation, domain-separation rationale (b"relicario-recovery-v1\0"
  prefix prevents wrap-key/master-key collision under passphrase reuse),
  salt+nonce freshness, and recommended offline-storage practice.
- Architecture core file list adds recovery_qr.rs and import_lastpass.rs
  (both pre-existing, both were missing from the README list).
- Roadmap marks Recovery QR as shipped, slotted next to Backup & restore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 12:16:09 -04:00
adlee-was-taken
3759f6a5f0 merge(cycle-2): land Stream B — Plan B Phases 4+5+6 (session/manifest discipline)
4 commits from feature/cli-tail-stream-b-session-manifest:
- 2e41e0b refactor(cli): single canonical ParamsFile in session.rs (Phase 5)
- 7901c27 refactor(cli): Vault::after_manifest_change wrapper (Phase 4)
- 4b657e7 refactor(cli): batched purge in cmd_purge and cmd_trash_empty (Phase 6)
- c4777cc refactor(cli): apply simplify findings (Phases 4-6 polish)

Phase 4 complete: Vault::after_manifest_change wrapper funnels NINE manifest-
mutation sites (not 7 as the spec/notes flagged -- attach.rs add+detach,
import.rs LastPass, and trash.rs cmd_trash_empty all previously SKIPPED
refresh_groups_cache; the wrapper now refreshes them as a side-effect).
save_manifest was DROPPED entirely (rather than just demoted to pub(crate)
as the spec said) -- the simplify pass found no escape hatch was needed,
so the only path to write the manifest now goes through the wrapper.
Stronger than spec.

Phase 5 complete: single pub(crate) struct ParamsFile in session.rs at
module level with Serialize+Deserialize. Constructors for_new_vault and
to_kdf_params (simplify pass changed into_kdf_params(self) to
to_kdf_params(&self) for ergonomics). commands/init.rs uses
ParamsFile::for_new_vault. On-disk JSON schema verified BYTE-STABLE via
fixture-string round-trip test (session::tests::params_file_round_trips_current_layout
+ for_new_vault_produces_expected_shape) -- same fields, same ordering,
same rename_all placement. Existing vaults read with no migration.

Phase 6 complete: purge_item renamed purge_item_filesystem, mutates only
filesystem + manifest, returns Vec<String> of paths. cmd_purge and
cmd_trash_empty both follow after_manifest_change -> git_rm -> git add ->
git commit. New helpers::git_rm extracted to DRY the pattern. Strict
invariant locked: tests/basic_flows.rs::trash_empty_batches_into_one_commit
counts commits via git rev-list --count HEAD before/after and asserts
delta == 1. A 50-item trash empty now fires 3 git invocations, not 52.

Simplify polish (c4777cc): all 5 findings legitimate, none rationale-skipped:
- Dropped redundant save_manifest_raw escape hatch
- Value-vs-self ergonomic fix (to_kdf_params(&self))
- DRY git_rm helper
- TOCTOU pre-check dropped from purge_item_filesystem
- Comment trim

3-way merge with stream-a (3dd1e1b) and stream-c (e69b347) clean: git
auto-resolved commands/add.rs (stream-a prompt_or_flag changes interleaved
with stream-b after_manifest_change call at the manifest-mutation site).
Verified semantic correctness via post-merge cargo test.

Pre-merge checklist on tip c4777cc + post-merge verification:
- cargo test --workspace standalone: 260 tests, 0 failures
- cargo test --workspace post-merge: 281 tests, 0 failures
- cargo clippy --workspace --all-targets -- -D warnings: silent
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
- Independent fresh-subagent code review: APPROVE
- grep refresh_groups_cache crates/relicario-cli/src/: zero matches
  outside session.rs/helpers.rs (per spec done-criteria)
- grep struct ParamsFile crates/relicario-cli/src/: ONE match
  (per spec done-criteria)

Plan B COMPLETE. With Phase 3 (Stream A) merged at 3dd1e1b and Phases 7+8
(Stream C) merged at e69b347, all eight Plan B phases are now on main.

One nit deferred (per subagent review): trash empty partial-failure
recovery -- if git_rm fails after after_manifest_change succeeds,
manifest.enc is rewritten in-tree and items are removed from disk but
no commit is made. Pre-existing behavior was strictly worse (per-item
interleaved partial-commit risk); current state is a net improvement.
Tree-cleanup-on-failure belongs in a follow-up plan, not this PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 12:00:37 -04:00
adlee-was-taken
c4777cc0bb refactor(cli): apply simplify findings (Plan B Phases 4-6 polish)
- session.rs: drop save_manifest_raw — its only caller was
  after_manifest_change itself; the pub(crate) advertised the exact
  bypass-the-cache-refresh footgun the wrapper exists to eliminate.
  Inline the encrypt + atomic_write pair.
- session.rs: into_kdf_params(self) → to_kdf_params(&self). Body just
  copies three u32s; the consume-self had no ownership benefit and
  forced the round-trip test to rebuild a ParamsFile field-by-field.
- helpers.rs: add git_rm(repo, paths, context) wrapper around git_run
  + the load-bearing --ignore-unmatch flag. Replaces two near-identical
  three-line "build rm_args, extend, git_run" blocks in trash.rs.
- trash.rs: purge_item_filesystem drops the if x.exists() pre-checks
  (TOCTOU + redundant stat per item per trash-empty iteration). Uses
  ErrorKind::NotFound swallow on remove_file/remove_dir_all instead.
- basic_flows.rs: trim trash_empty_batches_into_one_commit's sleep
  comment to just the WHY.
2026-05-09 11:50:42 -04:00
adlee-was-taken
e69b3479e4 merge(cycle-2): land Stream C — Plan B Phases 7+8 (core/wasm seam)
3 commits from feature/cli-tail-stream-c-core-wasm-seam:
- e5d63ab refactor(core): extract base32 module, dedupe two RFC 4648 impls
- 03f2a1b refactor(core,cli): migrate CLI parsers to relicario-core, parse.rs becomes shim
- fc9264e feat(wasm): add parse_month_year, base32_decode_lenient, guess_mime exports

Phase 7 complete: parser bodies (MonthYear::parse, mime::guess_for_extension,
TotpConfig::parse_secret) lifted into relicario-core; CLI parse.rs reduced to
19-line thin shims; callsites unchanged. base32 codec deduplicated from two
inline implementations (item.rs encoder + import_lastpass.rs decoder) into
crate::base32::{encode_rfc4648, decode_rfc4648_lenient}. Steam Guards
non-RFC-4648 alphabet stays at item_types/totp.rs:13 with a neighbour
comment cross-referencing the standard module.

Phase 8 complete: 3 #[wasm_bindgen] exports (parse_month_year,
base32_decode_lenient, guess_mime) with snake_case JS names per existing
convention. extension/src/wasm.d.ts mirror landed in the same commit
(fc9264e) per kickoff hard-rule.

Spec deviation (PM ack 02:55Z + 15:13Z): pub(crate) mod base32 promoted to
pub mod base32 because the CLI shim AND the Phase 8 WASM exports both
require external reach. Justification documented in lib.rs module-list
comment + module-level docstring on base32.rs explicitly carving Steam
Guard out as a non-user.

Two new RelicarioError variants added (additive, non-breaking):
- InvalidBase32(String)
- InvalidMonthYear(String)

3-way merge with stream-a (3dd1e1b) clean: stream-c didn't actually modify
add.rs or prompt.rs, so the diff stat showing those files was just stream-c
being behind on stream-a's changes. ort strategy auto-took mains versions.

Pre-merge checklist on tip fc9264e + post-merge verification:
- cargo test --workspace standalone: 272 tests, 0 failures
- cargo test --workspace post-merge: 277 tests, 0 failures (5 added from stream-a)
- cargo clippy --workspace --all-targets: silent (both standalone + post-merge)
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
- Extension vitest: 17 failed / 335 passed -- matches cycle-1 baseline cluster, no new regressions
- Independent fresh-subagent code review: APPROVE-WITH-NITS
  - nit 1: stale doc-comment in extension/src/shared/base32.ts:3 (Plan C concern, deferred)
  - nit 2: TotpConfig::parse_secret unused on this branch (spec-driven forward-compat for Plan C SW handlers)

Plan B Phases 7+8 complete. With Phase 3 (Stream A) already merged at 3dd1e1b,
only Phases 4+5+6 (Stream B in flight) remain to close out Plan B.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:44:08 -04:00
adlee-was-taken
4b657e71f1 refactor(cli): batched purge in cmd_purge and cmd_trash_empty (Plan B Phase 6)
Renames purge_item to purge_item_filesystem — body becomes filesystem-only
(remove item.enc, remove attachments/<id>/, manifest.remove). Returns the
relative paths it removed. cmd_purge and cmd_trash_empty accumulate the
paths and fire ONE git rm + ONE git add + ONE git commit per invocation.
A 50-item trash empty now produces 3 git subprocesses regardless of N
(was N+2). New regression test trash_empty_batches_into_one_commit asserts
the one-commit invariant via git rev-list --count.
2026-05-09 11:39:03 -04:00
adlee-was-taken
fc9264e9ae feat(wasm): add parse_month_year, base32_decode_lenient, guess_mime exports
Plan B Phase 8 — three #[wasm_bindgen] exports for the parsers migrated
in Phase 7, mirrored in extension/src/wasm.d.ts under "Pure parsers
(no session needed)". snake_case JS naming consistent with every
existing export; SessionHandle not required.

- parse_month_year(s) → { month, year } via js_value_for
- base32_decode_lenient(s) → Uint8Array
- guess_mime(filename) → string

Tests in session_tests mod cover the OK paths; error-path / JsValue
serialization can't be tested natively (JsError construction panics
off-wasm) and is covered in core (time::tests + base32::tests).

Plan C will wire SW message handlers consuming these exports in a
future round; this commit delivers only the seam.

Includes simplify-feedback fixes:
- relicario-core lib.rs module-list mentions base32 and mime
- item_types/totp.rs neighbour comment unified to ///-style block

cargo test --workspace: green
cargo clippy --workspace: silent
cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
cd extension && npm run test: 17 pre-existing failures only (baseline)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:33:40 -04:00
adlee-was-taken
7901c2758d refactor(cli): Vault::after_manifest_change wrapper (Plan B Phase 4)
Adds the canonical post-mutation funnel: save_manifest_raw + groups.cache
refresh in one method. Converts nine commands/*.rs mutation callsites from
the manual save_manifest + refresh_groups_cache pair to a single
vault.after_manifest_change(&manifest)?. save_manifest renamed to
save_manifest_raw (pub(crate)) so future commands cannot accidentally
bypass the cache refresh. Four of the nine sites (attach.rs add/detach,
import.rs LastPass, trash.rs cmd_trash_empty's per-item save) previously
skipped the cache refresh — the wrapper fixes them. refresh_groups_cache
moves from main.rs to helpers.rs so the read-side warmup callers in
get.rs/list.rs still reach it.
2026-05-09 11:29:52 -04:00
adlee-was-taken
3dd1e1bb15 merge(cycle-2): land Stream A — Plan B Phase 3 (prompt_or_flag + builder compression)
2 commits from feature/cli-tail-stream-a-prompt-helpers:
- bfec232 feat(cli): add prompt_or_flag<T> + prompt_or_flag_optional<T>
- 8e791e4 refactor(cli): compress build_*_item with prompt_or_flag

Phase 3 complete. Helper signatures match the spec literal; all 7 build_*_item
builders converted (title in each + username and url in build_login_item).
Internal refactor extracts read_required_line / read_optional_line as
generic-over-BufRead helpers so prompt and prompt_optional both delegate to
them, unblocking Cursor-driven tests for the legacy callers.

Honest scope correction (per DEV-A PR description): the spec promised ~30
percent per-type body shrinkage but the actual outcome is 1-line-for-1-line
replacement. The win is intent clarity, not LOC. Worth calibrating Plan B
compression-claim heuristics in future planning.

Subtle behavior delta in build_login_item: the prior
prompt_optional(...).ok().flatten() silently mapped I/O errors to None;
the new prompt_or_flag_optional(...)? propagates them. Ctrl-D mid-prompt now
errors clearly instead of producing a half-empty item -- strictly better.

Pre-merge checklist on tip 8e791e4:
- cargo test --workspace: 261 tests, 0 failures (254 baseline + 7 new)
- cargo clippy --workspace --all-targets: silent
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
- Independent fresh-subagent code review: APPROVE (spec-conformant, well-tested)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:29:33 -04:00
adlee-was-taken
8e791e4853 refactor(cli): compress build_*_item with prompt_or_flag
Plan B Phase 3 sub-step 2. Replaces the
title.map(Ok).unwrap_or_else(|| prompt("Title"))? chain in all
seven build_*_item functions with prompt_or_flag, and folds
login's or_else(|| prompt_optional(...).ok().flatten()) for
username and url into prompt_or_flag_optional. prompt_secret
sites and the parse-on-Some-only patterns (expiry, dob, card
kind, totp algorithm) stay as-is per spec. Removes the
#[allow(dead_code)] attributes from the four helpers in
prompt.rs now that callers exist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:12:26 -04:00
adlee-was-taken
2e41e0bae0 refactor(cli): single canonical ParamsFile in session.rs (Plan B Phase 5)
Promotes ParamsFile to a module-level pub(crate) struct with both Serialize
and Deserialize derives. for_new_vault() constructor + into_kdf_params()
inversion replace the two-definition split between commands/init.rs (write)
and session.rs read_params (read). On-disk JSON format unchanged — fixture
test asserts round-trip with the current params.json layout.
2026-05-09 11:12:24 -04:00
adlee-was-taken
03f2a1b58e refactor(core,cli): migrate CLI parsers to relicario-core, parse.rs becomes shim
Plan B Phase 7 sub-step 2 — moves the bodies of parse_month_year,
base32_decode_lenient, guess_mime from crates/relicario-cli/src/parse.rs
to relicario-core. The CLI's parse.rs becomes a 19-line shim re-exporting
the new core API.

New core surface:
- time::MonthYear::parse (Result<_, RelicarioError>)
- mime::guess_for_extension (new mime module)
- item_types::TotpConfig::parse_secret — Zeroizing<Vec<u8>> wrapper
  over base32::decode_rfc4648_lenient

base32 module promoted from pub(crate) to pub so non-core consumers
(CLI shim, future Phase 8 WASM exports) can reach it. New
RelicarioError::InvalidMonthYear(String) for the parse error path
(mirrors sub-step 1's InvalidBase32). MonthYear::new keeps its
&'static str error type — bringing it to RelicarioError is DEV-A's P3.

CLI callsites unchanged (commands/{add,edit,attach}.rs); RelicarioError
auto-converts to anyhow::Error at ? boundaries.

cargo test --workspace: green (core 143, +7 from new tests)
cargo clippy --workspace: silent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:12:05 -04:00
adlee-was-taken
bfec232f11 feat(cli): add prompt_or_flag<T> + prompt_or_flag_optional<T>
Plan B Phase 3 sub-step 1. The new helpers collapse the
Option<T>::map(Ok).unwrap_or_else(|| prompt(...))? chain that
the seven build_*_item builders repeat. Reader is injectable
via the *_with_reader variants so the unit tests can drive
both the flag-value and prompt paths from a Cursor without
needing a TTY. prompt and prompt_optional are refactored to
delegate to two private read_*_line helpers; semantics are
unchanged. dead_code is allowed on the four new helpers
until sub-step 2 wires them into commands/add.rs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 10:53:34 -04:00
adlee-was-taken
e5d63ab196 refactor(core): extract base32 module, dedupe two RFC 4648 impls
New crates/relicario-core/src/base32.rs hosts encode_rfc4648 +
decode_rfc4648_lenient (case-insensitive, optional padding, whitespace
stripped). Folds inline base32_encode (item.rs:255-275) and
decode_base32_totp (import_lastpass.rs:202-220) into the shared module;
both call sites updated.

- New RelicarioError::InvalidBase32(String) variant for the decoder
  error path
- Module is pub(crate); public API surface unchanged
- Steam alphabet (item_types/totp.rs:13) intentionally separate with
  neighbour comment pointing at crate::base32

Plan B Phase 7 sub-step 1 (DEV-A P2 base32 dedup half).
docs/superpowers/specs/2026-05-04-cli-restructure-design.md.

cargo test --workspace: green
cargo clippy --workspace: silent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 10:46:17 -04:00
adlee-was-taken
b9bd152e9d merge(cycle-1): land Stream B — Plan B Phases 1+2 (main.rs split + git_run)
18 commits from feature/arch-followup-stream-b-cli-restructure landing the
mechanical split of crates/relicario-cli/src/main.rs into commands/ +
parse.rs + prompt.rs (Plan B Phase 1) plus the git_run helper and 15-site
sweep (Plan B Phase 2). main.rs lands at 509 LOC: substance criterion
met (clap surface + dispatch + 2 shim families only); the 9-LOC overshoot
vs spec's =500 is #[arg(...)] attribute density on 9 sub-enums and was
accepted at cycle-1 review.

Phase 1 (split):
- 02e05f7 refactor(cli): add commands/, prompt.rs, parse.rs scaffold (no-op)
- 272b6a3 refactor(cli): move prompt helpers into prompt.rs
- 5240023 refactor(cli): move parse helpers into parse.rs
- 17bde16 refactor(cli): move cmd_generate + cmd_rate into commands/
- b9b07ec refactor(cli): move cmd_init into commands/init.rs (carries inline ParamsFile)
- 13c2fc2 refactor(cli): hoist commit_paths + resolve_query into commands/mod.rs
- da7d7d1 refactor(cli): move cmd_get/list/history/status/sync into commands/
- 530c479 refactor(cli): move trash family (rm/restore/purge/trash) into commands/
- c2f3c35 refactor(cli): move cmd_backup into commands/backup.rs
- 615afd7 refactor(cli): move cmd_import into commands/import.rs
- 6676d25 refactor(cli): move attach family (attach/attachments/extract/detach)
- 3811b07 refactor(cli): move cmd_recovery_qr family into commands/recovery_qr.rs
- 08bdfbc refactor(cli): move cmd_settings into commands/settings.rs
- 2d5b86b refactor(cli): move cmd_device + load_gitea_client into commands/device.rs
- 64275bc refactor(cli): move cmd_edit family into commands/edit.rs
- 2d1f092 refactor(cli): move cmd_add + 7 build_*_item helpers into commands/add.rs

Phase 2 (git_run):
- f3cdbed feat(cli): add helpers::git_run with stderr capture + context bail
- 97c8f99 refactor(cli): sweep 15 bail("git X") sites to use git_run with context labels

Pre-merge checklist on tip 97c8f99:
- cargo test --workspace: all green (helpers, attachments, basic_flows, backup,
  edit_and_history, import, settings, vault_detection)
- cargo clippy --workspace --all-targets: silent
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean

Plan B Phases 1+2 complete. Phases 3-8 partitioned across cycle-2 streams.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 10:37:59 -04:00
adlee-was-taken
89090a8f30 merge(cycle-1): land Stream A — security + docs polish
5 commits from feature/arch-followup-stream-a-security-polish:
- 1e858e1 fix(wasm): impl Drop for SessionHandle clears registry entry
- 03d0781 fix(ext): unswallow free() errors in SW session.clearCurrent + vitest
- 229e483 docs(core): bring recovery_qr.rs to the documented-zone standard
- f8296fa docs(core): drop intra-doc link to private RECOVERY_PRODUCTION_PARAMS
- 0c9387f fix(relay): start.sh opens fourth window for dev-c

Pre-merge checklist on tip 0c9387f:
- cargo test --workspace: 254 tests, 0 failures
- cargo clippy --workspace --all-targets: silent
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean

Plan A complete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 23:00:07 -04:00
adlee-was-taken
73a2579fa8 docs(coordination): add ship-it autonomy + simplify discipline to cycle-2 dev prompts
Each Dev A/B/C kickoff now declares the project's `.claude/settings.json`
auto-allow surface (write/cargo/npm/bun/python3/commit/push/PR), enumerates
the hard deny-list guardrails (no rm, no force-push, no reset --hard, no
branch -D, no worktree remove, no clean -f*, no checkout -- *, no sudo,
no chmod 777, no DB drops), and bakes in the simplify discipline required
before every REVIEW-READY: invoke superpowers:simplify on changed code,
no parallel implementations of existing helpers, no defensive checks for
impossible scenarios, no comments unless the WHY is non-obvious, no
half-finished implementations.

Why now: cycle-1 Stream B reached final-validation in roughly an hour
and a half. The bottleneck for cycle-2 is review/iteration cadence, not
typing speed — pushing devs to move at full auto-allow speed while
forcing a simplify pass shifts the cost from "PM rework after merge"
to "dev catches duplication before REVIEW-READY".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 22:39:05 -04:00
adlee-was-taken
f3d6c0a880 docs(coordination): cycle-2 CLI tail kickoff prompts (PM + Dev A/B/C)
Partitions Plan B's remaining phases (3-8) across three cycle-2 streams
once cycle-1 Stream A and Stream B's bundled Phase 1+2 PR have merged.
Stream A picks up Phase 3 (prompt_or_flag + builder compression),
Stream B owns Phases 4/5/6 (after_manifest_change, ParamsFile, batched
purge), Stream C owns Phases 7/8 (parser migration to relicario-core +
WASM exports). Plan C (extension restructure) is not in cycle 2.

Each kickoff bakes in cycle-1 lessons: prefer single-line relay body
content, avoid the f-string footgun in Python inbox-monitor scripts,
narration discipline (IN-PROGRESS updates at meaningful in-flight
moments, not just phase boundaries). The PM prompt also captures
cycle-1 outcomes (commits/PRs landed, the 17 pre-existing extension
test failures pattern, DEV-B's option-(b) git_run choice) so the new
PM picks up cold without relay history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 22:18:43 -04:00
adlee-was-taken
97c8f994e1 refactor(cli): sweep 15 bail("git X") sites to use git_run with context labels 2026-05-08 22:10:25 -04:00
adlee-was-taken
f3cdbed7b6 feat(cli): add helpers::git_run with stderr capture + context bail 2026-05-08 22:05:07 -04:00
adlee-was-taken
2d1f0926ae refactor(cli): move cmd_add + 7 build_*_item helpers into commands/add.rs 2026-05-08 21:58:49 -04:00
adlee-was-taken
0c9387fb1d fix(relay): start.sh opens fourth window for dev-c
Phase 4 of the security-polish series. The relay was expanded from 3
roles (pm + dev-a + dev-b) to 4 (adds dev-c) in dd0010d, but the
launcher script never followed — it still opened only 3 windows and
the manual-mode banner said "3 new terminals". Add DEV_C_PROMPT
discovery alongside the existing PM/Dev-A/Dev-B lines, and a fourth
window/tab/terminal in each of the three modes (manual / tmux / kitty)
plus the corresponding banner and summary-print updates.

The queue.test.ts assertion update part of P1.8 already shipped in
061facd — this commit closes the launcher half.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 4)
Refs: docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.8)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:56:46 -04:00
adlee-was-taken
f8296fa03b docs(core): drop intra-doc link to private RECOVERY_PRODUCTION_PARAMS
Phase 3 code-quality review caught that the [`RECOVERY_PRODUCTION_PARAMS`]
form in the module header introduced a new rustdoc warning (the const is
module-private, so the link only resolves under --document-private-items).
Drop the brackets so it renders as plain backticks — same visual, no
broken link, no need to widen visibility.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:53:20 -04:00
adlee-was-taken
64275bc64f refactor(cli): move cmd_edit family into commands/edit.rs 2026-05-08 21:48:35 -04:00
adlee-was-taken
2d5b86bf20 refactor(cli): move cmd_device + load_gitea_client into commands/device.rs 2026-05-08 19:32:39 -04:00
adlee-was-taken
08bdfbc7c4 refactor(cli): move cmd_settings into commands/settings.rs 2026-05-06 19:42:22 -04:00
adlee-was-taken
3811b07014 refactor(cli): move cmd_recovery_qr family into commands/recovery_qr.rs 2026-05-06 19:41:04 -04:00
adlee-was-taken
6676d2502b refactor(cli): move attach family (attach/attachments/extract/detach) into commands/ 2026-05-06 19:40:13 -04:00
adlee-was-taken
615afd7483 refactor(cli): move cmd_import into commands/import.rs 2026-05-06 19:37:35 -04:00
adlee-was-taken
229e483430 docs(core): bring recovery_qr.rs to the documented-zone standard
Phase 3 of the security-polish series. Brings recovery_qr.rs up to
the documentation density of crypto.rs / imgsecret.rs / backup.rs /
tar_safe.rs. No runtime behaviour change: just module-level //! header
explaining the format + KDF domain separation + parameter-pinning
rationale, an ASCII diagram of the 109-byte payload layout pinned by
a static assertion, doc-comments on the four public items, and named
slice-range constants for the offset arithmetic.

production_params() is replaced with a top-level const so the "pinned,
do not change once shipped" property is visible at every use site.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 3)
Refs: docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.7)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:33:40 -04:00
adlee-was-taken
c2f3c35ac9 refactor(cli): move cmd_backup into commands/backup.rs 2026-05-06 18:53:40 -04:00
adlee-was-taken
530c479f19 refactor(cli): move trash family (rm/restore/purge/trash) into commands/ 2026-05-06 18:48:00 -04:00
adlee-was-taken
da7d7d162c refactor(cli): move cmd_get/list/history/status/sync into commands/ 2026-05-06 18:43:41 -04:00
adlee-was-taken
03d0781c39 fix(ext): unswallow free() errors in SW session.clearCurrent + vitest
Phase 1 added impl Drop for SessionHandle on the Rust side so .free()
now actually removes the SESSIONS registry entry. The JS-side
try { current.free() } catch { /* already freed */ } swallow was
hiding the fact that .free() wasn't doing the cleanup at all;
post-Phase-1 it has to go so failures surface instead of being lost.

.free() callsite audit: exactly one match under extension/src/ — the
SW session.ts line this commit edits. Lifecycle audit: clearCurrent()
is reached via (a) popup lock → router popup-only.ts and (b)
session-timer expiry → service-worker/index.ts.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 2)
Refs: docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.1, DEV-C P2 service-worker)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:36:53 -04:00
adlee-was-taken
13c2fc2bd7 refactor(cli): hoist commit_paths + resolve_query into commands/mod.rs 2026-05-06 18:36:01 -04:00
adlee-was-taken
b9b07ec68d refactor(cli): move cmd_init into commands/init.rs (carries inline ParamsFile) 2026-05-06 18:32:36 -04:00
adlee-was-taken
17bde162cd refactor(cli): move cmd_generate + cmd_rate into commands/ 2026-05-06 18:27:41 -04:00
adlee-was-taken
52400230e0 refactor(cli): move parse helpers into parse.rs 2026-05-06 18:23:37 -04:00
adlee-was-taken
272b6a3845 refactor(cli): move prompt helpers into prompt.rs 2026-05-06 18:20:33 -04:00
adlee-was-taken
02e05f7a05 refactor(cli): add commands/, prompt.rs, parse.rs scaffold (no-op) 2026-05-06 17:42:38 -04:00
adlee-was-taken
1e858e1d1f fix(wasm): impl Drop for SessionHandle clears registry entry
Closes the P1.1 defense-in-depth gap: wasm-bindgen's auto-generated
.free() previously dropped the SessionHandle wrapper (a u32) without
removing the SESSIONS HashMap entry, leaving the master key and
image_secret in WASM linear memory until JS explicitly called
lock(handle). Drop now wires .free() to session::remove, and the
new native test pins the contract.

Refs: docs/superpowers/specs/2026-05-04-security-polish-design.md (Phase 1)
Refs: docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.1)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 01:52:24 -04:00
adlee-was-taken
bd3d53fddb docs(coordination): arch-followup kickoff prompts (PM + Dev A/B/C)
Generated by /multi-agent-kickoff for the three architecture-review
followup plans. PM coordinates; Dev-A owns Plan A (security & docs polish,
S, ships first); Dev-B owns Plan B (CLI restructure, M-L); Dev-C owns
Plan C (extension restructure, L).

Each dev prompt forces cd into its worktree (per project memory rule),
includes the relay tool calls + Python shim fallback, scopes hard-rules
to the planning subagents' flagged judgment calls, and ships an opinionated
PR title + body template that mirrors the plan's Done criteria.

PM prompt enforces the cross-plan boundaries: A is independent; B Phase 8
WASM exports are a seam C does not consume in this train; A owns the
.free() swallow removal and Drop impl; if both B and C touch wasm.d.ts,
B sequences first.

Launcher discovers these via `ls -t coordination/*-<role>-prompt.md | head -1`
so they take precedence over previous kickoff sets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:12:19 -04:00
adlee-was-taken
3b09adf3b2 docs(coordination): add RELAY.md — multi-agent kickoff + relay reference
TL;DR-first guide to the PM/Senior-Dev paradigm: how to invoke
/multi-agent-kickoff, how the launcher's three modes (manual/tmux/kitty)
work, the in-memory queue + per-role inbox semantics, the call.py /
call.ts fallback shims, message kinds, conventions, and troubleshooting.

Lives next to the kickoff prompts in docs/superpowers/coordination/ so
the workflow's docs and outputs share one home.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:02:48 -04:00
adlee-was-taken
4f7ab91f14 docs(specs): three architecture-review followup plans (security/CLI/extension)
Plan A (security & docs polish, S): SessionHandle impl Drop + JS .free()
audit + recovery_qr.rs documentation + relay launcher dev-c expansion.
Independent of B/C; ships first.

Plan B (CLI restructure, M-L): split cli/main.rs (2641 LOC) into commands/
folder + prompt.rs + parse.rs; helpers::git_run captures stderr; Vault::
after_manifest_change centralizes the groups-cache discipline; canonical
ParamsFile; batched purge; migrate parse_month_year/base32_decode_lenient/
guess_mime to relicario-core with WASM re-exports.

Plan C (extension restructure, L): typed StateHost (precondition); extract
service-worker/storage.ts; setup.ts SW migration via create_vault/
attach_vault messages + step-registry pattern; vault.ts split into
shell/sidebar/list/drawer/form-wrapper with vault_locked channel
unified through shared/state.ts; P2 cluster (timer reset, gitHost clear,
teardown helper, allSettled, MutationObserver debounce); get_vault_status
closes the relicario status parity gap.

Cross-boundary cites verified: Plan B Phase 8 WASM exports are the seam
Plan C consumes (deferred to a future plan); Plan A owns the .free() swallow
removal that Plan C respects without redoing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 20:02:40 -04:00
adlee-was-taken
4a726c2631 chore: gitignore local Gitea creds and scratch reviewer drafts
.gitea_env_vars is local Gitea config that shouldn't be checked in
(synthesis open decision #8). The .dev-c-content.md hidden file is a
raw subagent-output draft from the 2026-05-04 review — the canonical
notes are already in dev-c-notes.md alongside its peers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 18:30:26 -04:00
adlee-was-taken
450de33c0a docs(coordination): architecture-review kickoff prompts + followup planning
Adds the four kickoff prompts that drove the 2026-05-04 whole-codebase
architecture audit (PM + DEV-A/B/C reviewers), the planning prompt
that converts the synthesis into three implementation plans, and the
PM + DEV-A/B/C kickoff prompts for executing those plans in parallel.

Also updates the existing v0.5.1-* prompts with the relay-server
fallback section that references the new tools/relay/call.py shim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:34 -04:00
adlee-was-taken
dd0010db62 feat(relay): expand to dev-c role + python/ts MCP fallback shims
queue.ts and server.ts now know about dev-c alongside pm/dev-a/dev-b
so the four-role coordination paradigm works end-to-end. start.sh
opens a fourth window for dev-c. call.py and call.ts are HTTP shims
that agents can use when the MCP relay tools aren't registered in
their session (the kickoff prompts reference call.py by path as a
fallback).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:21 -04:00
adlee-was-taken
29146439bb fix(ext): glyph + vault polish — distinct identity glyph, sticky-bar form-actions hidden
GLYPH_TYPE_IDENTITY changed from ⌬ to ◍ so it's visually distinct from
GLYPH_DEVICES (also ⌬). Adds a CSS rule asserting [hidden] over the
.form-actions display:flex so the fullscreen sticky save bar can hide
the inner action row by attribute.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:16 -04:00
adlee-was-taken
cf66bd97b7 chore: bump crate and extension versions to 0.5.0
Catches the workspace and the extension manifests up to the v0.5.x
release line (was still showing 0.2.0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:49:09 -04:00
adlee-was-taken
061facd5a9 docs(reviews): whole-codebase architecture audit 2026-05-04
Three-reviewer architecture audit (DEV-A: core, DEV-B: cli/server/wasm,
DEV-C: extension/relay) plus PM synthesis. Lens: make the codebase
readable for a smart developer who doesn't know Rust but wants to learn
by tinkering.

Top synthesis findings (P1):
- SessionHandle has no impl Drop; .free() is a cleanup no-op (cross-cutting Rust+JS)
- cli/main.rs is a 2641-line monolith with no submodule boundaries
- setup.ts (1220 LOC) bypasses the SW and orchestrates WASM directly
- vault.ts (1027 LOC) owns shell + sidebar + list + drawer + routing
- shared/state.ts is fully any-typed
- recovery_qr.rs is undocumented vs. rest of crypto-adjacent core
- duplicated SW router helpers (storage + itemToManifestEntry)
- pure parsers (parse_month_year, base32_decode_lenient) belong in core
- 16x duplicated git invocation boilerplate with one-line errors

CLI/extension parity: 22/23 capabilities ✓; only true gap is `relicario
status` (no get_vault_status); `detach` is partial via update_item.

Also fixes tools/relay/queue.test.ts:54 to match the dev-c role
expansion already in queue.ts (was failing 1/4; now 5/5 pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:27:26 -04:00
adlee-was-taken
bd6a30155e Merge feature/v0.5.1-stream-b-settings: unified left-nav settings (Autofill/Display/Security/Generator/Retention/Backup/Import) 2026-05-03 21:51:43 -04:00
adlee-was-taken
8baef5b3cb fix(ext/settings): restore settings-security.ts from main (Stream C real implementation) 2026-05-03 21:51:08 -04:00
adlee-was-taken
ddfb95d683 fix(ext/settings): call teardownSettings in popup render to prevent listener leak 2026-05-03 21:47:46 -04:00
adlee-was-taken
7df76c692a feat(ext/settings): settings left-nav skeleton with section routing
Two-panel layout (148px nav sidebar + content area) with 7 nav items
(Autofill, Display, Security, Generator, Retention, Backup, Import),
stub section functions, and settings layout CSS classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:46:11 -04:00
adlee-was-taken
b4d253c60b chore(ext/settings): stub settings-security.ts (DEV-C replaces implementation) 2026-05-03 21:45:14 -04:00
adlee-was-taken
c16adc4335 Merge feature/v0.5.1-stream-a-layout: 3-column vault layout, toast system, glyph constants, emoji sweep 2026-05-03 21:41:57 -04:00
adlee-was-taken
9a8cdf8e4f fix: replace all remaining emoji with monochrome glyph constants
- trash.ts TYPE_ICONS map uses GLYPH_TYPE_* constants
- field-history.ts copy button uses GLYPH_COPY
- attachments-disclosure.ts thumbnail/icon uses GLYPH_TYPE_DOCUMENT
- settings.ts sync button uses GLYPH_SYNC
- document.ts thumb/sigblock/preview use GLYPH_TYPE_DOCUMENT + GLYPH_PREVIEW
- glyphs.ts adds GLYPH_COPY, GLYPH_SYNC, GLYPH_PREVIEW
- vault.ts adds GLYPH_DEVICES import + devices sidebar nav button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:37:23 -04:00
adlee-was-taken
ade44b4ea1 feat(ext/vault): wire renderSettings / teardownSettings from settings component 2026-05-03 21:37:23 -04:00
adlee-was-taken
1d4b018f9a feat(ext/vault): bottom sheet type picker for new item 2026-05-03 21:37:23 -04:00
adlee-was-taken
882a89bedd feat(ext/vault): detail drawer — open/close state + core fields display 2026-05-03 21:37:23 -04:00
adlee-was-taken
37c20b28a6 feat(ext/vault): 3-column shell — sidebar category nav + list pane 2026-05-03 21:37:23 -04:00
adlee-was-taken
3553150a53 feat(ext/vault): 3-column layout CSS — drawer, bottom sheet, list rows, responsive 2026-05-03 21:37:23 -04:00
adlee-was-taken
b50f49b597 fix(ext/vault): replace emoji typeIcon with glyph constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
1ec8965910 feat(ext): shared toast notification system 2026-05-03 21:37:23 -04:00
adlee-was-taken
ad6e4a2cd9 feat(ext/popup): polished 2-column type-picker with glyph icons 2026-05-03 21:37:23 -04:00
adlee-was-taken
b768f649a2 feat(ext/popup): empty states with glyph icons in item-list 2026-05-03 21:37:23 -04:00
adlee-was-taken
8b197a7525 fix(ext/popup): replace emoji in SETTINGS_OPTIONS with glyph constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
117716f6cf fix(ext/popup): replace emoji typeIcon with glyph constants in item-list 2026-05-03 21:37:23 -04:00
adlee-was-taken
c5e8b52e12 fix(ext/popup): replace inline &#x2934; vault-tab button with GLYPH_VAULT_TAB 2026-05-03 21:37:23 -04:00
adlee-was-taken
a1b66a9147 feat(ext/glyphs): add GLYPH_VAULT_TAB and per-type icon constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
934dfe05c2 Merge feature/v0.5.1-stream-c-recovery-qr: Recovery QR (Rust core + WASM + CLI + settings-security.ts + setup wizard) 2026-05-03 21:26:40 -04:00
adlee-was-taken
33d2a4a311 feat(ext/setup): wizard Style C progress track, glyph mode icons, recovery QR banner
- Replace dot-based progress indicator with colored horizontal segment track
  (completed=green, active=gold, pending=border) via renderProgressTrack()
- Add SETUP_STEP_NAMES constant for track segment titles
- Update Step 0 mode cards with glyph icons (◈ create, ⌥ attach)
- Add recovery QR banner in Step 5 (new-vault only, verifiedHandle present)
  with Generate now / Skip buttons wired in attachStep5()
- Add CSS for .setup-progress-track, .setup-progress-segment variants,
  and .recovery-qr-banner to styles.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:17:05 -04:00
adlee-was-taken
f17944a404 fix(core,wasm): correct QR version comment, expect msg, zeroize image_secret in closure 2026-05-03 21:09:02 -04:00
adlee-was-taken
4851857070 feat(ext/settings): settings-security.ts three-state recovery QR + devices component
- Add settings-security.ts with renderSecuritySection / teardownSecuritySection
- Three states: amber warning (no QR), green status (QR set up), modal overlay (show/print SVG)
- Device list with inline revoke; passphrase collected via prompt()
- QR payload never written to chrome.storage; only recovery_qr_generated_at timestamp stored
- Add generate_recovery_qr / unwrap_recovery_qr message types to messages.ts + POPUP_ONLY_TYPES
- Add SW handlers in popup-only.ts delegating to wasm_generate_recovery_qr / wasm_unwrap_recovery_qr
- Declare wasm_generate_recovery_qr and wasm_unwrap_recovery_qr in wasm.d.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:06:43 -04:00
adlee-was-taken
a6071b4c0c feat(cli): recovery-qr generate / unwrap subcommands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:01:29 -04:00
adlee-was-taken
ada00895d4 feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings 2026-05-03 20:57:55 -04:00
adlee-was-taken
42b746f9af feat(wasm): session stores image_secret for recovery QR generation 2026-05-03 20:56:39 -04:00
adlee-was-taken
762a008171 test(core): recovery_qr roundtrip + error cases 2026-05-03 20:53:59 -04:00
adlee-was-taken
f93bce7388 chore(core): re-export recovery_qr module 2026-05-03 20:51:36 -04:00
adlee-was-taken
8eabaf5f31 feat(core): recovery_qr generate + unwrap + SVG functions 2026-05-03 20:51:33 -04:00
adlee-was-taken
04142dc116 feat(core): add derive_master_key_raw + RecoveryQr error variant 2026-05-03 20:51:29 -04:00
adlee-was-taken
8739f1f67b chore(core): add qrcode dependency for recovery QR 2026-05-03 20:48:38 -04:00
adlee-was-taken
7d6fd76e86 feat: v0.5.1 multi-agent coordination plans (PM + DEV-A/B/C)
- coordination/v0.5.1-pm-prompt.md — PM coordinates 3 streams, enforces
  interface contracts (A-B settings signature, B-C security component),
  owns merge order and pre-tag checklist
- coordination/v0.5.1-dev-a-prompt.md — Stream A: fullscreen 3-column
  layout, sidebar category nav, detail drawer, bottom sheet, popup type-
  picker polish, per-type glyph icons, empty states, toast system (13 tasks)
- coordination/v0.5.1-dev-b-prompt.md — Stream B: settings left-nav
  redesign (Autofill, Display, Security, Generator, Retention, Backup,
  Import sections), security component stub (10 tasks)
- coordination/v0.5.1-dev-c-prompt.md — Stream C: recovery_qr.rs core,
  WASM session expansion, CLI subcommand, settings-security.ts three-state
  component, setup wizard Style C redesign + QR banner (12 tasks)
- Archive v0.5.0 coordination files to coordination/archive/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:26:19 -04:00
adlee-was-taken
4dc034d846 docs(spec): v0.5.x UX polish, settings redesign, and recovery QR design
Three-stream spec for the next release train:
- Stream A: fullscreen 3-col layout, popup type-picker polish, glyphs, toasts, empty states
- Stream B: settings UX redesign with left-nav sections (Device/Vault split)
- Stream C: recovery QR crypto (Rust/WASM), setup wizard redesign (Style C), security settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:32:43 -04:00
adlee-was-taken
3021ef9d9f feat(ext/vault): sidebar logo before "Relicario" wordmark
Renders the 16-optimized SVG (icons/relicario-logo-16.svg) inline
before the brand text in .vault-sidebar__header. Sized to 20×20 px
with flex-shrink: 0 so it survives narrow-pane wraps. The header
already had display: flex + gap: 8px, so the layout absorbed the new
element without further changes. Popup surface is untouched (this
override is scoped to .vault-sidebar__header .brand-logo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 18:53:09 -04:00
adlee-was-taken
b2749826b1 docs: CHANGELOG entry for v0.5.0
Three release trains roll into one tag — v0.3.0 backup/restore +
LastPass import, v0.4.0 device authentication, and the v0.5.0
polish + harden bundle.

Renames the existing "Unreleased" heading to v0.5.0 — 2026-05-02
and prepends the polish + harden additions:

- Security: S1 pre-receive hook fix (HIGH-severity authentication
  bypass), S2 tar-restore path-traversal hardening, S3 RELICARIO_*
  env-var audit + cfg-gate.
- Fixed: B1 strength-meter regenerate desync, B2/P4 raw error-code
  leakage in the fullscreen tab.
- Added: P1 password coloring (four reveal surfaces + settings UI),
  P2 setup → fullscreen vault tab handoff. Existing v0.3.0/v0.4.0
  Added entries (sync, register-from-popup, generator-defaults, edit
  TOTP, history, detach, status, backup/restore, vault-tab panel,
  LastPass import + popup deep link, status export age) preserved
  verbatim.
- Changed: P3 form-layout envelope, doc-audit refresh across
  overview / CLAUDE / SECURITY / ARCHITECTURE / foundational spec.
- Internal: C1 stale-branch prune, clippy cleanup, Cargo.lock
  regenerated, CLI/extension refactors preserved from prior trains.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:48:22 -04:00
adlee-was-taken
a332a9e80d Merge feature/v0.5.0-plan-a-security-cleanup: Plan A security + cleanup
v0.5.0 Plan A — Security Fixes + Repo Cleanup. 7 commits, ~800 net
insertions across the Rust workspace. Four items delivered:

- S1 (HIGH-severity authentication bypass fix): rewrite verify_commit
  in relicario-server. The previous implementation accepted any
  GOODSIG/Good signature line on stderr, ignoring whether the signing
  key was registered or revoked. The new implementation:
  * builds a temp gpg.ssh.allowedSignersFile from devices.json at the
    commit (no global git-config mutation)
  * parses the SHA-256 fingerprint from `git verify-commit --raw`
    stderr via regex
  * checks revocation FIRST (revoked entries may have been removed
    from devices.json), with the historical-commit case
    (committer_ts < revoked_at) explicitly allowed
  * uses committer date (GIT_COMMITTER_DATE / `git show -s
    --format=%ct`), not author date or wall clock
  * tightened the bootstrap guard to require BOTH devices and revoked
    to be empty (closes an empty-devices.json privilege-escalation
    route present in the original code)
  * 4 acceptance integration tests build real on-disk repos with
    SSH-signed commits and verify each scenario

- S2 (tar archive path-traversal hardening): replace
  tar::Archive::unpack with safe_unpack_git_archive. Located in
  relicario-core (per-spec, so integration tests can reach it without
  the bytes-in/bytes-out invariant breaking). Validates each entry's
  type (rejects symlinks/hardlinks), path components (rejects '..',
  RootDir, Windows drive Prefix), and declared size (rejects
  individual or cumulative > 100×compressed-or-1-GiB whichever is
  lower). The CLI's restore path adds a paranoid OS-level
  starts_with(.git/) check on the joined destination as
  defense-in-depth even after textual validation. 5 acceptance tests
  cover path traversal, symlinks, oversized headers (header claim of
  2 GiB tested without allocating disk).

- S3 (RELICARIO_* env-var audit): docs/SECURITY.md gains a
  "Configuration env vars" section enumerating each variable, its
  purpose, and trust assumption. Active-in-all-builds variables
  (RELICARIO_IMAGE, RELICARIO_GITEA_*) are documented; debug-only
  variables (RELICARIO_NO_GROUPS_CACHE, RELICARIO_TEST_*) are gated
  behind cfg(debug_assertions) so the env-var lookup is removed from
  --release binaries.

- C1 (stale feature branch prune): 5 merged feature branches and
  3 worktrees pruned interactively per dev report.

- Bonus: 4d02a50 fixes pre-existing clippy warnings across
  crates/relicario-{core,cli} (deref operators, Option::is_none_or
  vs map_or(true, ...), iter_mut().enumerate() patterns,
  div_ceil()) so the workspace builds clean under `-D warnings`.

Merge resolution: docs/SECURITY.md had a conflict where main's F11/F12
(Device Authentication paragraph naming relicario-server + simplified
"Device registration is optional" line) collided with Plan A's S3
section. Resolved by keeping both — F11/F12's wording for the
Device Authentication section, then Plan A's "Configuration env vars"
section appended below.

Cargo.lock regenerated. The previous committed lock was stale since
commit 8855078 (--totp-qr); cargo test on both devs' worktrees
produced identical regenerated locks. Plan A genuinely added regex +
tempfile to relicario-server (both already transitively present from
relicario-cli), so no new top-level deps; the Cargo.lock churn is
catch-up of crate-version bumps that have happened since the last
commit-of-record.

Tests: 248 cargo tests pass; extension tests unchanged (336/8 with 8
pre-existing device-auth scaffolding failures).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:54:12 -04:00
adlee-was-taken
d45dd10917 Merge feature/v0.5.0-plan-b-extension-ux: Plan B extension UX
v0.5.0 Plan B — Extension UX Polish + Bug Fixes. 15 commits, 22 files,
+853/-33 lines, all in extension/. Five features delivered:

- P4: ERROR_COPY centralized map; popup humanizeError now a thin shell
  over lookupErrorCopy; fullscreen tab gets friendly title/body/CTA blocks
  (closes B2). Generated test enumerates every grep'd error code so the
  registry can't drift.
- B1: applyGeneratedPassword dispatches a synthetic input event after
  the regenerate handler sets the password value, so the strength-meter
  listener re-rates the new value.
- P1: end-to-end password coloring — pure colorizePassword utility,
  chrome.storage.sync round-trip via applyColorScheme, CSS rules with
  custom properties, four reveal surfaces (popup item-detail, vault
  item-detail, field-history, generator preview), boot wiring + storage
  listener, Display section in settings with color pickers + swatch +
  reset.
- P3: .form-lower wrapper constrains lower form sections (notes,
  custom-fields, attachments, actions) to the same max-width: 960px
  envelope as .form-grid above, gated on surface === 'fullscreen' so
  the popup is unaffected.
- P2: finishSetup() opens the fullscreen vault tab and best-effort
  closes the setup tab after successful device registration. Both
  create-new and attach-existing flows funnel through it.

Implementation notes:
- vault.ts uses event delegation on the stable #vault-app root for
  .error-cta clicks (better than the plan's per-render handler attach;
  survives re-renders without leaking listeners).
- fields.ts gained a kind: 'password' | 'concealed' option on
  ConcealedRowOpts so wireFieldHandlers can apply colorizePassword
  selectively at the shared rendering layer.
- New WASM stub at src/__stubs__/relicario_wasm.stub.ts + vitest config
  alias lets unit tests import setup.ts without exploding on the
  runtime-only WASM module.

Tests: +28 (336/8 vs main's 308/8); 8 pre-existing device-auth
scaffolding failures unchanged. Builds clean: cargo wasm + Chrome
bundle + Firefox bundle.

Manual acceptance items (P3 viewport sweep at 1920/1440/1024/768,
P2 setup-flow smoke) deferred to user's pre-tag smoke walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:42:22 -04:00
adlee-was-taken
4d02a50cc8 chore(core): fix pre-existing clippy warnings (-D warnings gate)
Resolves pre-existing lint issues in imgsecret.rs, time.rs, totp.rs,
and crypto.rs that blocked the cargo clippy --workspace -D warnings
gate. No logic changes: loop-index → iterator, manual div_ceil →
.div_ceil(), manual range contains → .contains(), auto-deref cleanup.

Also fixes pre-existing warnings in relicario-cli (main.rs, session.rs,
device.rs, gitea.rs, helpers.rs, test helpers): dead_code suppression,
too_many_arguments, literal_with_empty_format_string, manual_char_cmp,
map_or → is_none_or, and repeat().take() → vec! in test helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:32:45 -04:00
adlee-was-taken
4e9d834920 feat(ext/setup): hand off completion to fullscreen vault tab (P2)
After successful device registration (state.configPushed = true), the
wizard now opens vault.html in a new tab and closes the setup tab.
Both create-new and attach-existing flows funnel through the same
finishSetup() handler. Closing the setup tab is best-effort --
chrome.tabs.remove failures don't block the vault open.

Add src/__stubs__/relicario_wasm.stub.ts + vitest.config alias so
setup.ts can be imported in unit tests without the runtime WASM file.
Exclude the stubs dir from the webpack/tsc build in tsconfig.json.
2026-05-02 19:15:35 -04:00
adlee-was-taken
631e9af470 fix(ext/login): constrain lower form sections to .form-grid envelope (P3)
Notes, custom-fields disclosure, attachments disclosure, and form-actions
in fullscreen logins now sit inside a .form-lower wrapper with the same
max-width: 960px; margin: 0 auto envelope as .form-grid above. Removes
the visual rhythm break at the 2-col -> full-width transition.

Popup keeps its current single-column behavior (gated on surface flag).
2026-05-02 19:07:33 -04:00
adlee-was-taken
b2fc56709a feat(ext/settings): Display section with color pickers + swatch + reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:03:23 -04:00
adlee-was-taken
b928ed407b feat(ext): apply color scheme on popup + vault startup
Import applyColorScheme in popup.ts and vault.ts, await it at boot,
and register a chrome.storage.onChanged listener so live color-picker
changes take effect without a reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:55:28 -04:00
adlee-was-taken
5d9a7ee8d3 docs(multi-agent): expand kickoff section with full spec→plans→launch workflow 2026-05-02 18:51:52 -04:00
adlee-was-taken
006e67c361 fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3)
The groups-cache opt-out is a developer debugging knob, not a
user-facing config. Gating the env-var lookup behind cfg!(debug_assertions)
makes release builds ignore the variable; the optimiser removes the
lookup entirely, so the variable name doesn't appear in release binary
strings output.

Doc-comments updated to reflect the new behaviour.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 18:51:15 -04:00
adlee-was-taken
95d1ff833c docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3)
Adds a "Configuration env vars" section listing every RELICARIO_*
variable read by production code, with purpose and trust boundary.
Splits user-facing vars from debug-only ones (cfg(debug_assertions))
to make the attack surface explicit for security reviewers.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 18:50:44 -04:00
adlee-was-taken
6bca0b3526 feat(ext/popup/item-detail): colorize revealed password field
Add data-field-kind attribute to renderConcealedRow so wireFieldHandlers
can distinguish password fields from other concealed rows (TOTP secrets,
CVV, PIN, private keys). Apply colorizePassword() on reveal when kind is
"password"; plain textContent otherwise. Pass kind through renderSections
for custom-section password fields.
2026-05-02 18:49:56 -04:00
adlee-was-taken
f45c275566 feat(ext/generator): colorize live password preview 2026-05-02 18:49:56 -04:00
adlee-was-taken
3e4312ca6f feat(ext/popup/field-history): colorize revealed password entries
Import colorizePassword and post-process .revealed value cells after
innerHTML render, replacing escaped-HTML text with colored spans via
the valueStore plaintext lookup.
2026-05-02 18:47:12 -04:00
adlee-was-taken
4fc1357368 docs: add multi-agent development paradigm README 2026-05-02 17:19:25 -04:00
adlee-was-taken
518b41e9cd style(ext): add password-coloring CSS rules + custom property defaults 2026-05-02 17:19:06 -04:00
adlee-was-taken
df58b0dda1 chore(relay): add relay MCP server to project Claude config 2026-05-02 17:18:47 -04:00
adlee-was-taken
ed9fcbe6ba feat(relay): start.sh launcher with --manual/--tmux/--kitty modes 2026-05-02 17:18:31 -04:00
adlee-was-taken
0172a06698 feat(relay): MCP SSE server with post_message/read_messages/list_pending 2026-05-02 17:17:41 -04:00
adlee-was-taken
1de7cda1b0 feat(ext/shared): add colorizePassword utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:17:05 -04:00
adlee-was-taken
6d5a2570d4 feat(relay): in-memory queue with consume-once semantics 2026-05-02 17:16:21 -04:00
adlee-was-taken
6a1c6d5875 fix(core,cli): harden backup-restore tar unpack against path traversal (audit S2)
cmd_backup_restore previously called tar::Archive::unpack with default
settings, allowing malicious .relbak archives to escape the target
directory via .. entries, absolute paths, or symlinks. No size cap
meant tar bombs could exhaust disk space.

Replaced with relicario_core::safe_unpack_git_archive which:
- Rejects .. (ParentDir), absolute (RootDir), and drive-prefix
  (Prefix) components with "path traversal blocked" error.
- Rejects symlinks and hardlinks outright.
- Checks declared header size before reading body; rejects entries or
  cumulative totals exceeding the caller's cap.
- Returns (relative-path, bytes) pairs; the CLI re-checks
  dest.starts_with(git_dir) after OS-level path resolution.
- CLI cap: min(100 × compressed size, 1 GiB).

Acceptance: 5 unit tests in relicario-core (traversal, absolute path,
symlink, size bomb, happy path); existing CLI backup roundtrip tests
remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:16:11 -04:00
adlee-was-taken
6d8f699fcb chore(relay): scaffold tools/relay with MCP SDK dep 2026-05-02 17:15:46 -04:00
adlee-was-taken
25c9eb52a0 feat(ext/shared): color-scheme storage + applyColorScheme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:49:03 -04:00
adlee-was-taken
2df636e454 fix(ext/login): dispatch input event after regenerate sets password (B1)
Programmatic input.value = newPassword does not fire input events, so
the strength-meter listener at shared/form-affordances/password-tools.ts:65
never re-rates the new value — meter stays stuck on the prior reading.

Extract applyGeneratedPassword(input, value) helper that sets value, type,
then dispatches new InputEvent('input', { bubbles: true }). Vitest covers
the dispatch + a sanity check that bubbling listeners fire.
2026-05-02 16:46:06 -04:00
adlee-was-taken
c0921b134d docs(plan): relay server implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:44:06 -04:00
adlee-was-taken
575343dc19 refactor(ext/vault): event delegation for error-cta + CSS variable consistency 2026-05-02 16:41:39 -04:00
adlee-was-taken
0443f6a3b4 docs(spec): add top-level README section to relay server design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:38:30 -04:00
adlee-was-taken
5e8e617a4d docs(spec): relay server design for multi-agent message bus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:37:17 -04:00
adlee-was-taken
1c641b4911 fix(ext/vault): friendly error block in fullscreen tab (closes B2)
Replaces raw escapeHtml(state.error) renders with lookupErrorCopy()-driven
title/body/CTA blocks. vault_locked specifically gets an 'Unlock vault'
CTA that refocuses the passphrase input. Other CTAs route to setup.html
or chrome.runtime.reload().

Closes B2; concludes P4.
2026-05-02 16:37:16 -04:00
adlee-was-taken
efac53d527 fix(server): real signature verification in pre-receive hook (audit S1)
verify_commit previously loaded devices.json/revoked.json and threw
both away, accepting any commit whose stderr contained "GOODSIG" or
"Good signature". This left device registration and revocation as
no-ops: unregistered keys could push, revoked keys kept working.

The fix:
- Build a temp gpg.ssh.allowedSignersFile from devices.json at the
  commit, passed via GIT_CONFIG_COUNT/KEY/VALUE env (no global git
  config mutation).
- Run git verify-commit --raw and parse SHA256 fingerprint from stderr
  regardless of exit code (SSH git outputs the "Good" line even for
  keys not in allowed-signers, with "No principal matched" + exit 1).
- Check revoked.json FIRST: reject if committer_ts >= revoked_at;
  accept historical commits (committer_ts < revoked_at).
- Reject if fingerprint is not in active devices.json.
- Bootstrap: accept only when BOTH devices.json AND revoked.json are
  empty/absent (not just devices.json alone).

Acceptance: 4 integration tests covering the matrix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:34:37 -04:00
adlee-was-taken
214e1e49f8 test(ext/shared): pin fallback title assertion in error-copy test 2026-05-02 16:30:09 -04:00
adlee-was-taken
af8626fb5f docs(audit): mark all 8 proposed findings fixed (PM follow-up)
Updates each Status: line from "Proposed; needs user decision" to
the actual fix-commit SHA. The audit doc now records the full state:
6 trivial findings fixed in the initial 900ccf1 pass; 8 deeper
findings fixed across ca059e7, 8fd9a05, 1342228, 76d092d, 9c97f9f
during v0.5.0 PM kickoff.

Pre-tag checklist: doc-audit follow-ups item is now done.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:28:08 -04:00
adlee-was-taken
9c97f9f939 docs(spec): banner foundational design spec as historical (audit F13)
The 2026-04-11 design spec lists secure notes, secure documents, TOTP,
Firefox extension, LastPass import, and device authentication as
"Post-V1 Ideas" — most of which shipped over the following weeks.
Per the doc/architecture/overview.md convention, specs are frozen
decision artifacts and shouldn't be retro-edited; instead, add a
one-line status banner pointing readers at CHANGELOG.md and the
overview doc for current state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:39 -04:00
adlee-was-taken
76d092d4f6 docs(architecture): note settings.enc + typed items in vault-creation flow (audit F10)
The Vault Creation Flow ASCII showed only manifest.enc as init's
encrypted artifact; cmd_init has been writing settings.enc in parallel
since the VaultSettings rollout. Update the encrypt step to show both
artifacts side-by-side with independent nonces.

Below the ASCII, add a short pointer noting that the per-item lifecycle
(typed-item envelope, attachment encryption, field-history) lives in
crates/relicario-core/ARCHITECTURE.md and reuses the same master_key +
XChaCha20-Poly1305 primitives. The doc-audit framing is "this top-level
doc could just point at the per-crate docs" — taking that trim path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:09 -04:00
adlee-was-taken
648dcf386e feat(ext/shared): centralize error-message copy in ERROR_COPY map
Replaces the popup's regex-chain humanizeError with a total lookup over
every error code returned by extension/src/service-worker/router/. A
generated test discovers codes via grep so the registry can't drift.
The popup keeps its small set of regex translators for Rust/serde error
phrasing that doesn't go through the router's error vocabulary.

Subsumes B2 — fullscreen consumer lands in the next commit.
2026-05-02 16:26:01 -04:00
adlee-was-taken
1342228a51 docs(security): name relicario-server in device-auth section (audit F11/F12)
- F12: Device Authentication section now names the relicario-server crate
  and its two subcommands (generate-hook, verify-commit), and notes that
  signed commits without the server-side hook provide authorship only —
  any pusher can still land an unsigned commit.
- F11: drop the "optional before v0.4.0" version line (v0.4.0 was never
  tagged; v0.5.0 is the first release with the hook) and replace with a
  one-liner: registration is optional but recommended for shared vaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:25:21 -04:00
adlee-was-taken
d539050aec chore(server): add assert_cmd/predicates/tempfile dev-deps
Needed for the upcoming verify-commit acceptance suite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:24 -04:00
adlee-was-taken
8fd9a05875 docs(claude): refresh project tree, IDs line, and roadmap (audit F2/F3/F4)
- F2: add relicario-server crate to the project-structure tree
- F3: replace stale "Next: WASM + Chrome MV3 (Plan 2)" roadmap line with
  the v0.5.0/Phase-3/1C-γ/LastPass picture
- F4: ItemIds and FieldIds are 16-char hex (64 bits) per audit M8;
  AttachmentIds are first 32 hex of SHA-256 (128 bits) per audit I2/B4

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:23:17 -04:00
adlee-was-taken
8a72b5e192 feat(core): add device::fingerprint helper for SSH SHA256 fingerprints
Wraps ssh-key's PublicKey::fingerprint(HashAlg::Sha256). Output format
matches ssh-keygen -lf and git verify-commit --raw stderr
(SHA256:<43-char base64>). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-02 16:23:10 -04:00
adlee-was-taken
ca059e7507 docs(overview): add relicario-server crate to four-codebase framing
Doc-audit Finding 1. The repo has had four Rust crates since early May
when the pre-receive hook crate landed, but docs/architecture/overview.md
still framed itself around three. Update:

- "The three codebases" → "The four codebases" (intro + heading)
- ASCII diagram fans core out to cli + server + wasm, with wasm feeding
  the extension
- Table gains a relicario-server row noting it lives on the git server
  and only sees public key material
- Build matrix adds `cargo build -p relicario-server --release`
- "Where to look next" points at server src + the device-auth design spec

Server has no user-facing surface, so the CLI/extension parity rule is
clarified to exclude it (it is server-side enforcement of an invariant
the clients already agreed to).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:20:45 -04:00
221 changed files with 34701 additions and 4942 deletions

View File

@@ -1,4 +1,10 @@
{ {
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
},
"enabledPlugins": { "enabledPlugins": {
"superpowers@claude-plugins-official": true "superpowers@claude-plugins-official": true
} }

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 }

7
.gitignore vendored
View File

@@ -7,3 +7,10 @@ extension/dist-firefox/
extension/wasm/ extension/wasm/
reference.jpg reference.jpg
ref.jpg ref.jpg
tools/relay/node_modules/
# Local Gitea credentials (do not commit)
.gitea_env_vars
# Scratch reviewer subagent output (raw drafts; canonical notes live in docs/superpowers/reviews/2026-*-dev-*-notes.md)
docs/superpowers/reviews/.dev-c-content.md

View File

@@ -1,9 +1,281 @@
# Changelog # Changelog
## Unreleased ## 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 ### 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
polish foundation, the v0.5.1 train (Streams A/B/C — 3-column vault
layout, left-nav settings, Recovery QR), the 1C-γ slice (Document
type, attachments, device registration from popup, trash & history
UI), the Plan B multi-stream refactor (Cycles 1+2), the vault-tab
management surfaces revamp, and the doc-structure redesign. The
in-flight scope outgrew the original v0.5.1 plan, so this cuts as a
minor bump.
### Added
- **Recovery QR — 1-of-2 disaster-recovery path.** `image_secret` is
encrypted under an Argon2id-derived key from the passphrase, packed
into a 109-byte binary payload (magic `RREC` + version 0x01 + salt
+ nonce + AEAD ciphertext), and rendered as a QR code that is never
written to disk. Surfaces:
- Rust core: `relicario-core/src/recovery_qr.rs``generate_recovery_qr` /
`unwrap_recovery_qr` / `recovery_qr_to_svg`. Production KDF
params (`m=64MiB, t=3, p=4`) live behind a private-fields type so
they cannot drift.
- WASM: `generate_recovery_qr` / `unwrap_recovery_qr` exported; the
session now stashes `image_secret` so the QR can be regenerated
without re-running steganography extraction.
- CLI: `relicario recovery-qr generate` (TTY render) and
`relicario recovery-qr unwrap` subcommands.
- Extension: three-state Security settings card (no QR → amber
warning; QR exists → green status + show/regenerate; explicit
view → modal with print).
- Setup wizard: skippable "generate before you go" banner on the
final step.
- **Document item type.** New typed item for storing a signed document
with a primary attachment. Form takes signature + signed-on date;
detail view renders a signature-block layout. Wired into the popup
add/view/edit dispatchers. Refuses to drop its primary attachment
(use `purge` instead).
- **Attachments end-to-end.** Service worker uploads attachments via
the GitHost putBlob path (GitHub + Gitea Git Data API with fallback);
popup attachments-disclosure component handles add/remove/download
inside all six item-type forms; `📎` indicator shows on item-list
rows that have attachments. Per-vault attachment bytes cap is
enforced both at attach-time and during backup restore.
- **Device registration from the popup.** "Register this device"
triggers an inline name input + WASM keypair generation + persisted
device entry — no setup-wizard detour.
- **Trash + field-history UI.** Trash view shows per-item purge
countdown with restore / per-item purge / empty-all actions.
Field-history view groups changes per field with reveal/copy
glyph buttons. New top-level item-history-index pane lists every
item that has captured history. `#history/<id>` route normalizes
the legacy `#field-history/<id>` URL form.
- **3-column fullscreen vault tab.** Sidebar (200px, type-category
nav) + list (flex) + detail drawer (440px, slides in on row click).
Below 720px the drawer pushes the list full-pane. Bottom sheet for
"new item" type picker uses a pane-only scrim so the sidebar stays
interactive.
- **Left-nav settings page.** Replaces the flat settings dump.
Sections grouped Device (Autofill, Display — password coloring)
vs Vault (Security — Recovery QR + trusted devices, Generator,
Retention, Backup, Import). The standalone Devices sidebar entry
is subsumed into Security.
- **Two-column login form in fullscreen.** Identity (title / URL /
group) and Credentials (username / password / TOTP) render as
side-by-side glass cards above 720px viewport; single-column at
narrow widths. Notes / custom sections / attachments stay full-width
below the grid. Sticky save bar at the bottom of the form pane;
header shows title + dirty subtitle ("unsaved · esc to cancel" or
"no changes") + platform-aware save hint (⌘+S / Ctrl+S).
- **Polish vocabulary.** Patina gold palette tokens
(`--gold-base` `#a88a4a` replacing the brighter `#d2ab43`),
`.surface-backdrop` (subtle radial top-glow + 18px grid texture)
applied to popup body / setup body / vault body, `.glass` card
class with `backdrop-filter: blur(8px)`, `.btn-primary` /
`.btn-secondary` button hierarchy, and `GLYPH_NEXT = '▸'` replacing
ASCII `→` in next/continue buttons.
- **Vault lock-screen logo.** `<img class="brand-logo">` added to the
lock-screen render for parity with the popup unlock view and the
setup wizard.
- **Setup wizard Style C.** Centered hero card + colored progress
track + glyph mode icons, replacing the prior vertical glass-card
wizard.
- **Toast notification system.** Shared `showToast(message, type,
durationMs)` at `extension/src/shared/toast.ts`. Used for sync
success/failure, copy confirmation, device registration result.
Replaces the ad-hoc `sync-status` div.
- **Empty-state treatments.** Popup item list (vault empty / search
returns nothing), vault list (section empty) — each gets a centered
glyph + headline + hint.
- **Per-type glyph icons in popup item rows.** `◉ login`, `
secure_note`, `⊡ totp`, `▭ card`, `⌬ identity`, `⊹ key`,
`≡ document`.
### Changed
- **Vault-tab management surfaces revamp (2026-05-24..05-30).**
Settings pane splits synced (cross-device via Chrome storage) from
local (per-browser) controls and gains a session-timeout UI.
Devices pane shows SHA-256 fingerprint + added-by display + inline
two-step revoke confirm via glyph button. Trash pane shows per-item
purge countdown via `daysUntilPurge`. Field-history pane gets
section headers and reveal/copy glyph buttons. New shared
utilities: `relative-time.ts` (consolidating five duplicate inline
copies), webcrypto `ssh-fingerprint.ts`, shared
section-header / glyph-btn / kv-row / fingerprint CSS.
- **Emoji sweep.** Every remaining UI emoji replaced with a
monochrome glyph constant from `shared/glyphs.ts`. The pop-out
button is now `` (U+29C9, `GLYPH_VAULT_TAB`) instead of `&#x2934;`.
- **License switched to GPL-3.0-or-later.** Was MIT for the early
prototype phase. License headers + `AUTHORS` + crate `Cargo.toml`
authors updated.
- **AttachmentId expanded to 128 bits with `is_valid` check.**
Backup restore now validates IDs (audit I2 / B4).
- **Per-vault attachment bytes cap enforced.** Both CLI attach and
backup restore (audit I3).
### Internal
- **Plan B multi-stream refactor (Cycles 1+2).** CLI `main.rs` split
into per-command modules under `crates/relicario-cli/src/commands/`
with a shared `git_run` helper. New `prompt_or_flag<T>` and
`prompt_or_flag_optional<T>` helpers compress all the `build_*_item`
helpers. `Vault::after_manifest_change` wrapper plus a single
canonical `ParamsFile` in the session avoid duplicated file-system
rebuilds. Core/WASM seam: `base32_decode_lenient`,
`parse_month_year`, `guess_mime` exported from WASM; CLI parsers
migrated to `relicario-core::parse`. Extracted `base32` module
from core, deduplicated two RFC-4648 implementations.
- **Doc-structure redesign (2026-05-30).** Renamed `ARCHITECTURE.md`
→ `DESIGN.md`, `docs/ARCHITECTURE.md` → `docs/CRYPTO.md`,
`FORMATS.md` → `docs/FORMATS.md`. Added scope headers and
"Next:" footers to all tour docs so the reading order is canonical.
`CLAUDE.md` gains a living-docs table and four discipline rules
(scope-boundary check, code-constant pinning, new-doc rule,
plan-state hygiene).
- **CLI quality-of-life.** `gen` alias for `generate`, `-l`/`-w`
short flags, batched purge in `cmd_purge` and `cmd_trash_empty`.
- **Workspace audit cycle.** Stale local branches and worktrees
pruned. Several plan files moved into `docs/superpowers/audits/`
for the record.
## v0.5.0 — 2026-05-02
Three release trains roll into one tag — backup/restore + LastPass
import (originally v0.3.0), device authentication (originally v0.4.0),
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
two confirmed bugs).
### Security
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
Earlier `relicario-server` builds accepted any commit with a
`Good signature` line on stderr regardless of which key signed it —
device-auth was a no-op. The hook now builds an `allowed_signers`
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
global git-config mutation), parses the SSH SHA-256 fingerprint out
of `git verify-commit --raw` stderr, and rejects unregistered keys or
revoked keys whose committer-date is at or after the revocation
timestamp. Bootstrap mode is preserved only when **both**
`devices.json` AND `revoked.json` are empty (closes an
empty-devices.json privilege-escalation route).
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
A new `relicario_core::safe_unpack_git_archive` validates each
entry's path components (rejects `..`, absolute paths, Windows
drive prefixes), rejects symlinks/hardlinks, and caps total
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
check after path-joining as defense-in-depth.
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
developer escape hatch, not a user knob) is now
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
the env-var lookup is removed from the binary by the optimiser.
### Fixed
- **Strength meter no longer goes stale after the regenerate button (B1).**
Programmatic `input.value = newPassword` doesn't fire `input`
events; the regenerate handler now dispatches a synthetic
`InputEvent('input', { bubbles: true })` so the meter listener
re-rates the new value.
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
used to render verbatim in the fullscreen vault tab and (in some
cases) the popup. New `extension/src/shared/error-copy.ts` central
registry maps every service-worker error code to friendly
title/body/CTA copy; the popup and fullscreen tab consume the
same map. The fullscreen lock screen's `vault_locked` block now
reads `Vault locked / Unlock your vault to continue. / [Unlock
vault]`. A generated test enumerates the live error codes via
grep so the registry can't drift.
### Added
- **Sidebar logo in the fullscreen vault tab.** The
`vault-sidebar__header` now renders the 16-optimized SVG logo
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
so it survives narrow-pane wraps). Popup unaffected.
- **Password coloring (P1).** Revealed passwords in the popup
item-detail, fullscreen item view, field-history viewer, and
generator preview render digits and symbols in distinct colors.
Defaults: blue digits, red symbols. Users can override via the
new Display section in settings (color pickers + live preview
swatch + reset). Defaults round-trip via
`chrome.storage.sync.password_display_scheme`; cross-device when
Chrome sync is enabled.
- **Setup wizard hands off to the fullscreen vault tab on completion
(P2).** Both create-new and attach-existing flows now open
`vault.html` in a new tab and best-effort close the setup tab
after device registration succeeds — replaces the prior
setup-tab-stays-open terminal screen.
- **Sync now button** in the extension settings view — surfaces the - **Sync now button** in the extension settings view — surfaces the
previously hidden `{ type: 'sync' }` SW message to users with success / previously hidden `{ type: 'sync' }` SW message to users with success /
error feedback. error feedback.
@@ -59,6 +331,30 @@
file `cmd_backup_export` writes on success). Reads "never" for file `cmd_backup_export` writes on success). Reads "never" for
fresh vaults, "4 days ago" otherwise. fresh vaults, "4 days ago" otherwise.
### Changed
- **Form layout in the fullscreen vault tab is now visually consistent
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
form-actions in fullscreen logins now sit inside a `.form-lower`
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
the `.form-grid` cards above. Removes the visual rhythm break at the
2-col → full-width transition. The popup surface is unchanged.
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
`DESIGN.md` now describes four codebases (the
`relicario-server` pre-receive hook crate is no longer invisible);
`CLAUDE.md` project tree and roadmap reflect current state;
`docs/SECURITY.md` names the server crate and its `verify-commit` /
`generate-hook` subcommands and notes the without-the-hook-it's-
advisory caveat; `docs/CRYPTO.md` shows `settings.enc` as a
parallel artifact in the vault-creation flow; the foundational
design spec gains a "historical" status banner pointing readers at
the current docs.
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
### Known limitations ### Known limitations
- **Mid-restore failure leaves the target remote in a half-written - **Mid-restore failure leaves the target remote in a half-written
@@ -74,6 +370,13 @@
### Internal ### Internal
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
builds clean under `-D warnings`.
- `Cargo.lock` regenerated and committed; was stale since the
`--totp-qr` commit.
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant - Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
now has its own `build_*_item` / `edit_*` helper. Pure mechanical now has its own `build_*_item` / `edit_*` helper. Pure mechanical
extraction; behavior unchanged. The dispatcher matches and delegates. extraction; behavior unchanged. The dispatcher matches and delegates.
@@ -83,14 +386,6 @@
`setup.ts` since it walks live wizard state. Setup.ts went from `setup.ts` since it walks live wizard state. Setup.ts went from
1205 → 1137 lines. 1205 → 1137 lines.
### Changed
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
## v0.2.0 — 2026-04-27 ## v0.2.0 — 2026-04-27
### Fixed ### Fixed

View File

@@ -48,9 +48,11 @@ crates/
│ ├── src/helpers.rs # vault_dir, git_command, iso8601 │ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing) │ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection │ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
── relicario-wasm/ # WASM bindings for the extension ── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface ├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]> └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
└── src/main.rs # verify-commit + generate-hook subcommands
``` ```
## Key design decisions ## Key design decisions
@@ -76,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever. - Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures. - Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Item IDs are random 8-char hex strings. - Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
- Git history is preserved as an audit log — no squashing. - Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency. - The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
@@ -84,10 +86,75 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git` Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
## Design spec ## Planning & design specs
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md` **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.
## Roadmap 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
- `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — typed-item data model and envelope
- `docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md` — fullscreen UX phase plan
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM). 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`.
## Living docs — update discipline
| File | What it documents | Update when... |
|---|---|---|
| `DESIGN.md` | Cross-codebase structure: four codebases, contracts, secrets map, build matrix, test strategy | Adding a codebase, changing inter-codebase contracts, new build targets |
| `docs/CRYPTO.md` | Crypto pipeline diagrams, vault creation/unlock flows, DCT embedding, encrypted file format | Changing crypto primitives, format version byte, or file format |
| `crates/relicario-core/ARCHITECTURE.md` | Module map, invariants, key flows, test architecture for `relicario-core` | Adding/changing modules, item types, or crypto invariants in core |
| `crates/relicario-cli/ARCHITECTURE.md` | Module map, invariants, key flows (init, unlock, all commands) for `relicario-cli` | Adding/changing CLI commands, helpers, or session behavior |
| `extension/ARCHITECTURE.md` | Bundle structure, SW↔popup contract, component architecture | Adding bundles, changing the SW message protocol, or major UI flows |
| `docs/SECURITY.md` | Threat model, device auth, env-var trust surface | Adding env vars, changing auth model, new security-relevant config |
| `docs/FORMATS.md` | Wire formats: `.enc` blobs, `params.json`, `devices.json`, manifest schema | Changing any serialized format, version number, or on-disk layout |
| `STATUS.md` | In-flight work, recent landings, what's next | End of every dev iteration |
| `ROADMAP.md` | Full roadmap with release targets | When milestones shift or new work is scoped |
| `CHANGELOG.md` | User-facing release history | When tagging a release |
### Discipline rules
Four rules to prevent the kind of drift the 2026-05-30 audits found:
1. **Scope-boundary check.** When editing a tour doc, verify the change fits the doc's scope header. If it doesn't, the change belongs in a different doc — move it instead of stretching the scope. Concretely: a sentence about crypto added to `DESIGN.md` belongs in `docs/CRYPTO.md`; a wire-format table added to `docs/CRYPTO.md` belongs in `docs/FORMATS.md`.
2. **Code-constant pinning.** When a tour doc cites a code constant (`VERSION_BYTE = 0x02`, `QUANT_STEP = 50.0`, `MIN_COPIES = 5`, `MANIFEST_SCHEMA_VERSION = 2`, etc.), the doc must cite the source file + line. When the underlying constant changes, grep for the citation pattern and update the docs together with the code change in the same commit. Most drift the audit found was code-constant drift — this rule attacks it at the source.
3. **New-doc rule.** When adding a tour doc, also update (a) `DESIGN.md`'s code-map, (b) the reading-order sequence (the "Next:" footer chain), and (c) the living-docs table above. A new doc that doesn't appear in all three is not done.
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.

947
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,18 @@
# Architecture overview — Relicario # Architecture overview — Relicario
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs. > **Audience:** anyone wanting to understand the system at the cross-codebase level. This doc owns the four-codebase map, inter-codebase contracts, the secrets map (what secret lives where), the build matrix, and the global code-map index. **Does NOT own:** crypto pipeline details (see [docs/CRYPTO.md](docs/CRYPTO.md)), wire formats (see [docs/FORMATS.md](docs/FORMATS.md)), threat model (see [docs/SECURITY.md](docs/SECURITY.md)), per-crate module maps (see [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md), [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md), and [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)).
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first: > If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
> >
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md) > - [crates/relicario-core/ARCHITECTURE.md](crates/relicario-core/ARCHITECTURE.md)
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md) > - [crates/relicario-cli/ARCHITECTURE.md](crates/relicario-cli/ARCHITECTURE.md)
> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) > - [extension/ARCHITECTURE.md](extension/ARCHITECTURE.md)
> >
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*. > If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
## The three codebases ## The four codebases
``` ```
┌─────────────────────┐ ┌─────────────────────┐
@@ -18,36 +20,40 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
│ (Rust, no I/O) │ │ (Rust, no I/O) │
│ crypto · items │ │ crypto · items │
│ manifest · stego │ │ manifest · stego │
└──────────┬──────────┘ │ device keys + fp │
└──┬───────────┬──────┘
│ │
┌────────────────┼───────────┴──────┬────────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
│ │ │ │ │ bindings) │
│ filesystem + │ │ pre-receive hook │ │ │
│ git + │ │ verify-commit + │ │ compiled to WASM │
│ clap UX │ │ generate-hook │ │ for the extension │
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
┌─────────────────────┼─────────────────────┐
│ │ ┌─────────────────────┐
extension
┌────────────────┐ ┌────────────────────┐ (compiled to WASM │ (TypeScript) │
│ relicario-cli relicario-wasm │ inside the ) │ popup · vault
│ (Rust binary) (#[wasm_bindgen] │ extension │ setup · content
bindings) service worker
│ filesystem + │ │ │ │
│ git + │ └────────┬───────────┘ │
│ clap UX │ │ │
└────────────────┘ ▼ │
┌─────────────────────┐ │
│ extension │ │
│ (TypeScript) │ │
│ popup · vault │ │
│ setup · content │ │
│ service worker │ │
└─────────────────────┘ └─────────────────────┘
``` ```
| Codebase | Language | Role | Key boundary | | Codebase | Language | Role | Key boundary |
|---|---|---|---| |---|---|---|---|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. | | `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. | | `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. | | `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. | | `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
## Inter-codebase contracts ## Inter-codebase contracts
@@ -151,6 +157,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
| Target | Tool | Output | When to run | | Target | Tool | Output | When to run |
|---|---|---|---| |---|---|---|---|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution | | Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
| Native test suites | `cargo test` (workspace) | — | After any Rust change | | Native test suites | `cargo test` (workspace) | — | After any Rust change |
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes | | WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution | | Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
@@ -191,11 +198,12 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
| If you're working on... | Start with | | If you're working on... | Start with |
|---|---| |---|---|
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) | | Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](crates/relicario-core/ARCHITECTURE.md) |
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) | | A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](crates/relicario-cli/ARCHITECTURE.md) |
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) | | A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](extension/ARCHITECTURE.md) |
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) | | A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](extension/ARCHITECTURE.md) |
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations | | A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` | | Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) | | Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) | | Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
@@ -205,3 +213,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
## Stale spec docs ## Stale spec docs
The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts). The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts).
---
**Next:** [docs/CRYPTO.md](docs/CRYPTO.md) — the crypto pipeline that backs this design.

232
LICENSE Normal file
View File

@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.

View File

@@ -4,6 +4,8 @@
# Relicario # Relicario
> **Audience:** users + evaluators. This doc owns the pitch, security-model summary, quick-start commands, reference-image explanation, recovery-QR overview, and roadmap teaser. Goes no deeper — for the system tour see [DESIGN.md](DESIGN.md), for crypto see [docs/CRYPTO.md](docs/CRYPTO.md).
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient. A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof. The server only ever sees opaque ciphertext. There is nothing else going on. This README is the security proof.
@@ -89,6 +91,12 @@ relicario list
# Sync with your git remote # Sync with your git remote
relicario sync relicario sync
# Pack the vault into a single encrypted backup file
relicario backup export -o vault.relbak
# Print a recovery QR for your image_secret (see "Recovery" below)
relicario recovery-qr generate
# Generate a random password # Generate a random password
relicario generate -l 32 relicario generate -l 32
``` ```
@@ -108,34 +116,30 @@ The embedding survives:
This means your reference image can live on your Instagram, your personal website, or anywhere else. It's useless without your passphrase. This means your reference image can live on your Instagram, your personal website, or anywhere else. It's useless without your passphrase.
## Recovery: what if I lose my reference image?
Without your reference image, the vault is undecryptable — that's the security model. But it also makes a lost or corrupted image a single point of failure.
The mitigation is the **recovery QR**: a printable QR code that wraps your image secret behind a separate recovery passphrase you choose. If you ever lose access to the reference JPEG, scan or transcribe the QR, provide the recovery passphrase, and recover the 256-bit image secret. Combined with your normal vault passphrase, this restores access to the vault.
```bash
# Print a recovery QR (after the vault is unlocked).
# You'll be prompted for a separate recovery passphrase.
relicario recovery-qr generate
# Recover the image_secret from a stored QR payload.
relicario recovery-qr unwrap
```
The QR payload is an XChaCha20-Poly1305 envelope keyed by Argon2id over a domain-separated input (prefixed with `b"relicario-recovery-v1\0"`), so even if you reuse your vault passphrase as your recovery passphrase, the wrap key cannot collide with a vault master key. Both salt and nonce are freshly randomized per call, so two QRs printed from the same passphrase yield different bytes — the printed copy doesn't leak whether you've printed others.
Recommended practice: print the QR, store it offline (safe, deposit box), and forget about it. The recovery passphrase is what protects the printed copy from being useful to someone who finds it.
## Architecture ## Architecture
``` A short tour of the four codebases and how they fit together lives in [DESIGN.md](DESIGN.md). Crypto pipeline diagrams are in [docs/CRYPTO.md](docs/CRYPTO.md); the wire format reference is [docs/FORMATS.md](docs/FORMATS.md); the threat model is [docs/SECURITY.md](docs/SECURITY.md).
relicario/
├── crates/
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
│ │ ├── item.rs # Item, Field, Manifest data model (serde)
│ │ ├── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
│ │ ├── attachment.rs # Encrypted attachment helpers (content-addressed)
│ │ ├── settings.rs # VaultSettings (retention, generator defaults, caps)
│ │ ├── backup.rs # `.relbak` encrypted-backup envelope
│ │ ├── device.rs # ed25519 device keys + revocation entries
│ │ └── vault.rs # Encrypt/decrypt items, manifest, settings
│ ├── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
│ ├── relicario-wasm/ # Thin wasm-bindgen wrapper for the browser extension
│ └── relicario-server/ # Pre-receive hook: device-signature verification
├── extension/ # Chrome MV3 / Firefox WebExtension (TypeScript)
└── docs/
├── ARCHITECTURE.md # System overview + flow diagrams
├── SECURITY.md # Manifest integrity model + threat notes
├── architecture/ # Cross-codebase + per-codebase architecture docs
└── superpowers/
└── specs/ # Design specifications with full threat model
```
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge). `relicario-core` is the platform-agnostic bytes-in/bytes-out heart — no filesystem, no network. The CLI binary and the browser-extension WASM bridge both consume it. See per-codebase deep-dives in `crates/*/ARCHITECTURE.md` and `extension/ARCHITECTURE.md`.
### Crypto primitives ### Crypto primitives
@@ -206,6 +210,7 @@ The binary is at `target/release/relicario`.
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP - [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
- [x] Secure document storage (encrypted file attachments) - [x] Secure document storage (encrypted file attachments)
- [x] Backup & restore (`.relbak` encrypted envelope) - [x] Backup & restore (`.relbak` encrypted envelope)
- [x] Recovery QR (paper-printable image_secret backup with separate passphrase)
- [x] LastPass CSV import - [x] LastPass CSV import
- [x] Device authentication (ed25519 commit signing + pre-receive hook) - [x] Device authentication (ed25519 commit signing + pre-receive hook)
- [ ] Import from Bitwarden / 1Password - [ ] Import from Bitwarden / 1Password
@@ -215,8 +220,12 @@ The binary is at `target/release/relicario`.
## License ## License
MIT GPL-3.0-or-later — see [LICENSE](LICENSE).
--- ---
Built by [Aaron Lee](https://adlee.work). Design spec and threat model in `docs/superpowers/specs/`. Built by [Aaron D. Lee](https://adlee.work). Design spec and threat model in `docs/superpowers/specs/`.
---
**Next:** [DESIGN.md](DESIGN.md) — the system tour.

38
ROADMAP.md Normal file
View File

@@ -0,0 +1,38 @@
# Relicario Roadmap
> Living document — update alongside `STATUS.md` when milestones shift.
> "Up next" items have specs; "Medium-term" items may have specs; "Long-term" items are direction, not committed scope.
## Shipped
| 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-α/β₁/β₂) |
See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train commit list.
## Up next
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:
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
## Medium-term
_(promote here once specced)_
## Long-term / backlog
- **Relay server** — encrypted WebSocket relay for multi-device sync without a shared git server
Spec: `docs/superpowers/specs/2026-05-02-relay-server-design.md`
Plan: `docs/superpowers/plans/2026-05-02-relay-server.md` (`c0921b1`)
Code skeleton: `crates/relicario-server/` exists but only houses the pre-receive hook today; the relay binary would either extend or replace it.
- **Mobile** — Rust core compiles to ARM; JNI wrapper for Android, Swift wrapper for iOS
## Non-goals (explicitly deferred or cancelled)
- **Reference-image rotation** — changing the image factor without re-embedding. Back-burner, not cancelled.
- **Per-entry subkeys** — no real-world benefit at family-vault scale; see design rationale in `docs/CRYPTO.md`.
- **libgit2 / gitoxide** — shell-out to `git` is intentional; see `crates/relicario-cli/ARCHITECTURE.md`.

148
STATUS.md Normal file
View File

@@ -0,0 +1,148 @@
# Relicario — Project Status
> Update this file at the end of every dev iteration. It is the single source of truth for what is done, in progress, and next.
## 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:** **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
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
Plan: `docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md`
- Patina gold palette tokens (`--gold-base` `#a88a4a`, `--gold-mid`, `--gold-shadow`, etc.) replacing the bright amber `#d2ab43`
- `.surface-backdrop` (radial top-glow + 18px grid texture) on popup body, setup body, vault body
- `.glass` card class with `backdrop-filter: blur(8px)` for unlock card, setup steps, form columns
- `.btn-primary` / `.btn-secondary` button hierarchy alongside existing `.btn`
- `GLYPH_NEXT = '▸'` (U+25B8) replacing ASCII `→` in next/continue buttons
- Unlock view restructure: logo-lockup (logo + brand + tagline) + glass card + primary "unlock vault" button + secondary open-vault/settings demoted
- Setup wizard: backdrop + glass step cards + glass mode-picker cards + ▸ on next buttons
- Two-column login form (`surface: 'popup' | 'fullscreen'` flag on `renderForm`)
- Sticky save bar in fullscreen forms with `externalActions` flag
- Form header with title + dirty-state subtitle + platform-aware save hint (⌘+S / Ctrl+S)
### v0.5.1 Stream A — fullscreen + popup layout polish (merged 2026-05-03, `c16adc4`)
- 3-column vault tab: sidebar (200px) + list (flex) + detail drawer (440px)
- Sidebar type-category nav replacing flat item list (All items + per-type counts)
- Bottom sheet for "new item" type picker (pane-only scrim, sidebar stays interactive)
- Shared toast system at `extension/src/shared/toast.ts` (`showToast(message, type, durationMs)`)
- `GLYPH_VAULT_TAB = '⧉'` (U+29C9) replacing `&#x2934;` pop-out button in popup
- Per-type glyph icons in popup item rows
- Empty-state treatments (popup list empty, popup search-empty, vault list section-empty)
- Emoji sweep — all remaining UI emoji replaced with monochrome glyph constants
### v0.5.1 Stream B — settings UX redesign (merged 2026-05-03, `bd6a301`)
- Unified left-nav settings page (Device / Vault grouping)
- Sections: Autofill (Device), Display (Device — password coloring), Security (Vault — Recovery QR + trusted devices), Generator (Vault), Retention (Vault), Backup (Vault), Import (Vault)
- `devices` standalone sidebar entry subsumed into Security section
### v0.5.1 Stream C — Recovery QR (merged 2026-05-03, `934dfe0`)
Spec: `docs/superpowers/specs/2026-05-01-recovery-qr-design.md`
Plan: `docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md`
- Rust core: `relicario-core/src/recovery_qr.rs``generate_recovery_qr` / `unwrap_recovery_qr` / `recovery_qr_to_svg` (109-byte binary payload, never written to disk)
- WASM bindings: `generate_recovery_qr` / `unwrap_recovery_qr` + session stores `image_secret` for regeneration
- CLI: `relicario recovery-qr generate` / `recovery-qr unwrap` subcommands (TTY render)
- Extension: three-state Security settings card; setup wizard "generate before you go" banner
- Setup wizard Style C redesign — centered hero card + colored progress track + glyph mode icons (replacing the prior glass-card vertical wizard)
### 1C-γ — attachments + Document type + device registration + trash + history
Specs: `docs/superpowers/specs/2026-04-24-relicario-extension-1c-gamma1-design.md`, `docs/superpowers/specs/2026-04-26-relicario-extension-1c-gamma2-design.md`
Plans: `docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md`, `docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md`
- Core: `relicario-core/src/item_types/document.rs` (DocumentCore — signature + signed-on date)
- Extension: Document type form + signature-block detail (`extension/src/popup/components/types/document.ts`)
- Attachments wired into 6 type forms via shared disclosure; 📎 indicator in item list
- Attachment cap setting (per-vault bytes cap) in vault settings; CLI enforces cap on attach
- Service worker: trash operations (listTrashed, restoreItem, purgeItem, purgeAllTrash); batched purge
- Device registration from the popup (no setup-wizard detour)
- Field history end-to-end (WASM `get_field_history`, popup viewer)
- Attachment IDs expanded to 128 bits with `is_valid` check (audit I2)
- Per-vault attachment bytes cap enforced (audit I3)
- IDs validated on backup restore (audit B4)
### Plan B multi-stream refactor (2026-05-09 → 2026-05-25)
Cycle 1:
- Stream A: security audit fixes + docs polish (`89090a8`)
- Stream B: `main.rs` split into `commands/` modules + `git_run` helper (`b9bd152`)
Cycle 2:
- Stream A: `prompt_or_flag<T>` + builder compression — compressed `build_*_item` helpers (`3dd1e1b`)
- Stream B: `Vault::after_manifest_change` wrapper, single canonical `ParamsFile` in session (`3759f6a`)
- Stream C: core/WASM seam — `base32_decode_lenient`, `parse_month_year`, `guess_mime` exported from WASM; CLI parsers migrated to `relicario-core::parse` (`e69b347`)
Misc:
- CLI: `gen` alias for `generate`, `-l`/`-w` short flags, batched purge
- `base32` module extracted from core, two duplicate RFC-4648 impls deduplicated
- License switched to GPL-3.0-or-later
### Vault-tab management surfaces revamp (2026-05-24 → 2026-05-30)
Spec: `docs/superpowers/specs/2026-05-23-vault-tab-management-surfaces-revamp-design.md`
Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md`
- Shared utilities: `relative-time.ts` consolidating 5 duplicate inline copies (`9da45dd`, `a587965`), webcrypto `ssh-fingerprint.ts` (`1edfa67`), shared section-header / glyph-btn / kv-row / fingerprint CSS (`367adce`), history/revoke/restore glyph constants (`c943a06`)
- Settings pane revamp — synced/local split + session timeout UI (`299e7db`)
- Devices pane revamp — SHA256 fingerprint + added-by display + glyph revoke with inline two-step confirm (`047df6e`)
- Trash pane revamp — per-item purge countdown via `daysUntilPurge` + glyph restore + bottom-right empty-trash (`ed6e218`)
- Field-history pane visual polish — section headers + glyph reveal/copy buttons (`32e674e`)
- 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`
Plan: `docs/superpowers/plans/2026-05-30-doc-structure-redesign.md` (all 37 sub-step boxes ticked)
- Task 1: Renamed `ARCHITECTURE.md``DESIGN.md`, `docs/ARCHITECTURE.md``docs/CRYPTO.md`, `FORMATS.md``docs/FORMATS.md` (`36a59cd`)
- Task 2: Added scope headers + "Next:" footers to all tour docs (`5e7023f`)
- Task 3: Fixed incoming links to renamed paths (`01377e7`)
- Task 4: Updated CLAUDE.md living-docs table + added three discipline rules (`bae3f7c`)
- Task 5: Final verification gate — all 6 steps pass cleanly (Step 3 grep had three false positives — correct new-path sibling links inside `docs/`, not stale references)
### Post-audit cleanup (2026-05-30)
- `STATUS.md` + `ROADMAP.md` synced with three weeks of stealth-shipped work (`72a59c6`, `0bde093`)
- CLAUDE.md gains rule #4 (plan-state hygiene) + doc-structure plan checkboxes ticked retroactively (`cccb7d7`)
- Vault lock-screen logo: `<img class="brand-logo">` added to `renderLockScreen` for parity with popup unlock view (`39ae629`)
- Extension test-debt cleared: 17 stale tests (settings + devices + router) updated to match the post-Stream-B + post-revamp components — 371/371 extension + 281 Rust tests green (`797709b`, `c9802ef`, `361f3b4`)
- v0.6.0 cut: version bumps + CHANGELOG entry covering the full v0.5.x train
## In progress (uncommitted on main)
- `.claude/settings.json` — harness config tweaks (kept aside intentionally)
- Two superseded doc-plan/spec files showing modifications — `2026-04-22-relicario-extension-1c-beta1.md` and `2026-04-11-relicario-design.md` (kept aside intentionally)
## Up next
Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review specs:
- **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.
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,5 +1,7 @@
# Architecture: relicario-cli # Architecture: relicario-cli
> **Audience:** contributors editing the CLI. This doc owns the CLI module map, the clap command surface, per-command key flows, session/unlock semantics, and helpers. **Does NOT own:** crypto, wire formats, or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/FORMATS.md](../../docs/FORMATS.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)).
## What this crate is for ## What this crate is for
The `relicario` binary is the platform layer for `relicario-core`: it adds The `relicario` binary is the platform layer for `relicario-core`: it adds
@@ -16,22 +18,46 @@ locally, and lets recovery debugging happen with familiar tooling.
## Module map ## Module map
The crate is three files of source and a `tests/` directory. Each source file `src/main.rs` is now a thin clap-surface + dispatcher; per-command logic lives
has one job. under `src/commands/`. Each source file has one job.
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command - **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
handler. Internal structure: a top-level `Cli` / `Commands` enum Owns the top-level `Cli` / `Commands` enum and every subcommand enum
(`main.rs:13-275`), a flat dispatcher `match` in `main()` (`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of `DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted three test-only env-var hooks (`test_passphrase_override`,
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions, `test_item_secret_override`, `test_backup_passphrase_override`) — each is
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and stripped from release builds via `#[cfg(debug_assertions)]`.
can be tested through the same integration paths. Owns all clap argument
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`, - **`src/commands/`** — one module per top-level command. `mod.rs` re-exports
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared the public surface and hosts the shared `commit_paths` helper (the single
`commit_paths` helper that is the single chokepoint for git commits during chokepoint for git commits during vault mutations) plus other cross-command
vault mutations. glue. Per-command modules: `init`, `add`, `get`, `list` (also hosts
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
`backup` (export / restore), `import` (lastpass), `attach` (attach /
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
builder/editor reads top-to-bottom and can be tested through the same
integration paths.
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
- **`src/device.rs`** — device-management plumbing called by
`commands::device`: ed25519 keypair generation via `relicario-core::device`,
on-disk layout under `<config_dir>/relicario/devices/<name>/`, and the
read/write of `.relicario/devices.json` / `revoked.json`.
- **`src/gitea.rs`** — minimal Gitea REST client used by `commands::device add`
/ `revoke` to register and remove deploy keys. Reads
`RELICARIO_GITEA_{URL,TOKEN,OWNER,REPO}` env vars (overridable via CLI flags).
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds - **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
@@ -306,13 +332,65 @@ rewrite `devices.json`, commit `device: revoke <name>`. Note that device
keys are kept entirely separate from the KDF (passphrase × image stays keys are kept entirely separate from the KDF (passphrase × image stays
unchanged across device add/revoke), as per the design spec. unchanged across device add/revoke), as per the design spec.
### Backup-passphrase-style commands (none yet) ### Backup (`commands::backup`, `commands/backup.rs`)
The import / export / `import-lastpass` commands described in Two subcommands, both keyed by a *backup* passphrase that is independent of
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are the vault master passphrase.
not yet implemented. When they land they'll fit in the dispatcher
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here - **`backup export <out> [--include-image] [--image PATH] [--no-history]`** —
until that work begins. reads the entire on-disk vault layout (`.relicario/{salt,params.json,
devices.json}`, `manifest.enc`, `settings.enc`, every `items/*.enc`, every
`attachments/<iid>/<aid>.enc`), optionally bundles the reference JPEG and
the `.git/` directory (as an in-memory tar), and hands the lot to
`relicario_core::backup::pack_backup` with a zxcvbn-gated backup
passphrase prompted twice. The resulting `.relbak` is written via
`tmp` + rename. A `.relicario/last_backup` marker file (ISO-8601 line) is
also written so `cmd_status` can show "last backup at …".
- **`backup restore <input> [<target>]`** — refuses to overwrite an existing
vault (`target/.relicario/` must not exist). Unpacks the `.relbak` via
`unpack_backup`, then materialises every byte into the target layout. The
bundled `.git/` tar is extracted via the hardened
`relicario_core::safe_unpack_git_archive` (path-traversal / symlink /
size-cap guards) with a cap of `min(100 × tar_size, 1 GiB)`; if no
history was bundled, the target gets a fresh `git init` + initial commit.
### Import (`commands::import`, `commands/import.rs`)
- **`import lastpass <csv>`** — reads the CSV, calls
`relicario_core::import_lastpass::parse_lastpass_csv`, then unlocks the
vault and writes every produced `Item` through `vault.save_item` + manifest
upsert. Failed rows surface as `ImportWarning`s on stderr and never abort
the import; only a missing or malformed header is fatal. Commit message:
`import: <N> items from LastPass (<csv-filename>)`. The dispatch shape
(`ImportAction` subcommand enum) is in place for future importers
(Bitwarden, 1Password, etc.) — each would add one `ImportAction` variant
and one helper.
### Rate (`commands::rate`, `commands/rate.rs`)
`rate <passphrase|->` runs `relicario_core::generators::rate_passphrase`
(zxcvbn-backed) and prints the 04 score, a human-readable label, and the
estimated guess count as `~10^N`. Reads one line from stdin when the
argument is `-`, which keeps the passphrase out of shell history. Purely
informational — does not unlock or mutate anything; the `init` command
calls `validate_passphrase_strength` directly and does not consult `rate`.
### RecoveryQr (`commands::recovery_qr`, `commands/recovery_qr.rs`)
Two subcommands wrapping `relicario_core::recovery_qr::{generate_recovery_qr,
unwrap_recovery_qr}`.
- **`recovery-qr generate`** — re-extracts the 32-byte image_secret from the
reference JPEG (via `get_image_path` + `imgsecret::extract`), prompts for
the recovery passphrase (which may be the same as the vault passphrase or
different — domain-separated by core), produces the 109-byte sealed
payload, and renders it as a Unicode-block QR (EcLevel::M) directly to
stdout. The payload is **never written to disk** — the user is expected to
print or photograph it.
- **`recovery-qr unwrap`** — reads a base64-encoded payload from stdin,
prompts for the recovery passphrase, runs `unwrap_recovery_qr`, and prints
the recovered `image_secret` as hex. Useful for recovery dry-runs and for
reconstructing a lost reference image.
## Cross-cutting concerns ## Cross-cutting concerns
@@ -537,3 +615,7 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
is why every `cmd_*` that takes a `query: String` (get, edit, is why every `cmd_*` that takes a `query: String` (get, edit,
history, rm, restore, purge, attach, attachments, extract, detach) history, rm, restore, purge, attach, attachments, extract, detach)
works the same way. works the same way.
---
**Next:** [../../extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) — the browser-side surface.

View File

@@ -1,8 +1,9 @@
[package] [package]
name = "relicario-cli" name = "relicario-cli"
version = "0.2.0" version = "0.7.0"
edition = "2021" edition = "2021"
description = "CLI for relicario password manager" description = "CLI for relicario password manager"
license = "GPL-3.0-or-later"
[[bin]] [[bin]]
name = "relicario" name = "relicario"
@@ -28,10 +29,10 @@ clap_complete = "4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rqrr = "0.7" rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] } reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" assert_cmd = "2"
predicates = "3" predicates = "3"
tempfile = "3" tempfile = "3"
qrcode = "0.14"
serde_json = "1" serde_json = "1"

View File

@@ -0,0 +1,313 @@
//! `relicario add <kind>` — create a new item of the given type.
//!
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
//! `Document` builder is the only one that needs the unlocked vault (for the
//! attachment-cap settings + writing the encrypted blob alongside the item).
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::AddKind;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
AddKind::SecureNote { title, body_prompt, group, tags } =>
build_secure_note_item(title, body_prompt, group, tags)?,
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
AddKind::Card { title, holder, expiry, kind, group, tags } =>
build_card_item(title, holder, expiry, kind, group, tags)?,
AddKind::Key { title, label, algorithm, group, tags } =>
build_key_item(title, label, algorithm, group, tags)?,
AddKind::Document { title, file, group, tags } =>
build_document_item(&vault, title, file, group, tags)?,
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
};
vault.save_item(&item)?;
manifest.upsert(&item);
vault.after_manifest_change(&manifest)?;
let mut paths: Vec<String> = vec![
format!("items/{}.enc", item.id.as_str()),
"manifest.enc".into(),
];
for att in &item.attachments {
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
}
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn build_login_item(
title: Option<String>,
username: Option<String>,
url: Option<String>,
password_prompt: bool,
password: Option<String>,
group: Option<String>,
tags: Vec<String>,
favorite: bool,
totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
})
} else {
None
};
let mut item = Item::new(title, ItemCore::Login(LoginCore {
username, password, url: parsed_url, totp,
}));
item.group = group;
item.tags = tags;
item.favorite = favorite;
Ok(item)
}
fn build_secure_note_item(
title: Option<String>,
body_prompt: bool,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::SecureNoteCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_identity_item(
title: Option<String>,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
date_of_birth: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore};
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_card_item(
title: Option<String>,
holder: Option<String>,
expiry: Option<String>,
kind: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{CardCore, CardKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_key_item(
title: Option<String>,
label: Option<String>,
algorithm: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::KeyCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label, public_key, algorithm,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_document_item(
vault: &crate::session::UnlockedVault,
title: Option<String>,
file: PathBuf,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename, mime_type,
size: bytes.len() as u64,
created: item.created,
});
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
Ok(item)
}
#[allow(clippy::too_many_arguments)]
fn build_totp_item(
title: Option<String>,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
period: u32,
digits: u8,
algorithm: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let secret_b32 = match secret {
Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer, label,
}));
item.group = group;
item.tags = tags;
Ok(item)
}

View File

@@ -0,0 +1,175 @@
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::parse::guess_mime;
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::{encrypt_attachment, AttachmentRef};
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
let settings = vault.load_settings()?;
let caps = settings.attachment_caps;
if item.attachments.len() as u32 >= caps.per_item_max_count {
anyhow::bail!("item already has {} attachments (max {})",
item.attachments.len(), caps.per_item_max_count);
}
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
// Check per-vault total attachment bytes cap (audit I3).
let current_total: u64 = manifest.items.values()
.flat_map(|e| &e.attachment_summaries)
.map(|s| s.size)
.sum();
let new_size = bytes.len() as u64;
let hard_cap = caps.per_vault_hard_cap_bytes;
let soft_cap = caps.per_vault_soft_cap_bytes;
if current_total + new_size > hard_cap {
anyhow::bail!(
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
current_total, new_size, hard_cap
);
}
if current_total + new_size > soft_cap {
eprintln!(
"warning: vault attachments will exceed soft cap ({} bytes)",
soft_cap
);
}
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let aref = AttachmentRef {
id: enc.id.clone(),
filename,
mime_type,
size: bytes.len() as u64,
created: now_unix(),
};
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item.attachments.push(aref);
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.after_manifest_change(&manifest)?;
let paths = [
format!("items/{}.enc", item.id.as_str()),
"manifest.enc".into(),
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
];
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
super::commit_paths(&vault, &format!("attach: {}{} ({})",
crate::helpers::sanitize_for_commit(&file.display().to_string()),
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()), &path_refs)?;
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
Ok(())
}
pub fn cmd_attachments(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
for a in &item.attachments {
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
}
Ok(())
}
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
use std::fs;
use relicario_core::decrypt_attachment;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
let path = vault.root().join("attachments").join(item.id.as_str())
.join(format!("{}.enc", aid));
let bytes = fs::read(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let plaintext = decrypt_attachment(&bytes, vault.key())?;
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
fs::write(&out_path, plaintext.as_slice())
.with_context(|| format!("failed to write {}", out_path.display()))?;
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
Ok(())
}
pub fn cmd_detach(query: String, aid: String) -> Result<()> {
use std::fs;
use relicario_core::ItemCore;
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
// Document items keep their primary blob in the core; refuse to orphan it.
if let ItemCore::Document(d) = &item.core {
if d.primary_attachment.as_str() == aid {
anyhow::bail!(
"cannot detach the primary attachment of a Document item; \
use `purge {}` to delete the whole item",
item.title,
);
}
}
let removed = item.attachments.remove(pos);
let blob_path = vault.root().join("attachments").join(item.id.as_str())
.join(format!("{}.enc", removed.id.as_str()));
if blob_path.exists() {
fs::remove_file(&blob_path)
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
}
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.after_manifest_change(&manifest)?;
let item_path = format!("items/{}.enc", item.id.as_str());
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
super::commit_paths(
&vault,
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&item_path, "manifest.enc", &blob_relpath],
)?;
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
Ok(())
}

View File

@@ -0,0 +1,303 @@
//! `relicario backup export` / `relicario backup restore` — pack/unpack the
//! encrypted `.relbak` envelope.
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::BackupAction;
pub fn cmd_backup(action: BackupAction) -> Result<()> {
match action {
BackupAction::Export { out, include_image, image, no_history } => {
cmd_backup_export(out, include_image, image, no_history)
}
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
}
}
pub(super) fn cmd_backup_export(
out: PathBuf,
include_image: bool,
image: Option<PathBuf>,
no_history: bool,
) -> Result<()> {
use std::fs;
use relicario_core::{backup, validate_passphrase_strength};
use zeroize::Zeroizing;
let root = crate::helpers::vault_dir()?;
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
Zeroizing::new(p)
} else {
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
};
let confirm = if crate::test_backup_passphrase_override().is_some() {
passphrase.clone()
} else {
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
};
if passphrase.as_str() != confirm.as_str() {
anyhow::bail!("passphrases do not match");
}
if let Err(e) = validate_passphrase_strength(&passphrase) {
anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
}
// Read everything from disk that the envelope needs.
let salt = fs::read(root.join(".relicario").join("salt"))
.with_context(|| "failed to read .relicario/salt")?;
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
.with_context(|| "failed to read .relicario/params.json")?;
// devices.json was removed in the B1 security audit fix; fall back to
// an empty array so backups of post-B1 vaults still pack cleanly.
// Task 12 will remove the devices field from the backup format entirely.
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
.unwrap_or_else(|_| "[]".to_string());
let manifest_enc = fs::read(root.join("manifest.enc"))
.with_context(|| "failed to read manifest.enc")?;
let settings_enc = fs::read(root.join("settings.enc"))
.with_context(|| "failed to read settings.enc")?;
// Items.
let mut item_files = Vec::new();
let items_dir = root.join("items");
if items_dir.is_dir() {
for entry in fs::read_dir(&items_dir)? {
let p = entry?.path();
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
let id = p.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
.to_string();
let bytes = fs::read(&p)?;
item_files.push((id, bytes));
}
}
// Attachments. Layout: attachments/<item_id>/<aid>.enc
let mut attach_files = Vec::new();
let attach_dir = root.join("attachments");
if attach_dir.is_dir() {
for entry in fs::read_dir(&attach_dir)? {
let item_dir = entry?.path();
if !item_dir.is_dir() { continue; }
let item_id = item_dir.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
.to_string();
for sub in fs::read_dir(&item_dir)? {
let p = sub?.path();
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
let aid = p.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
.to_string();
let bytes = fs::read(&p)?;
attach_files.push((item_id.clone(), aid, bytes));
}
}
}
// Optional reference image.
let image_bytes = if include_image {
let path = match image {
Some(p) => p,
None => crate::session::get_image_path()?,
};
Some(fs::read(&path)
.with_context(|| format!("failed to read reference image {}", path.display()))?)
} else {
None
};
// Optional .git/ tar.
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
let items_refs: Vec<backup::BackupItem> = item_files.iter()
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
.collect();
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
.map(|(iid, aid, bytes)| backup::BackupAttachment {
item_id: iid.clone(),
attachment_id: aid.clone(),
ciphertext: bytes,
})
.collect();
let input = backup::BackupInput {
salt: &salt,
params_json: &params_json,
devices_json: &devices_json,
manifest_enc: &manifest_enc,
settings_enc: &settings_enc,
items: items_refs,
attachments: attach_refs,
reference_jpg: image_bytes.as_deref(),
git_archive: git_archive.as_deref(),
};
let bytes = backup::pack_backup(input, &passphrase)?;
// atomic_write via the existing pattern: write `.tmp`, rename.
let tmp = {
let mut t = out.as_os_str().to_owned();
t.push(".tmp");
PathBuf::from(t)
};
fs::write(&tmp, &bytes)
.with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, &out)
.with_context(|| format!("failed to rename {}", out.display()))?;
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
eprintln!(
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
out.display(), mib
);
Ok(())
}
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
let mut buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut buf);
builder.append_dir_all(".", dir)
.with_context(|| format!("failed to tar {}", dir.display()))?;
builder.finish().with_context(|| "failed to finalize git tar")?;
}
Ok(buf)
}
pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::backup;
use relicario_core::{ItemId, AttachmentId};
use zeroize::Zeroizing;
let target = if target.is_absolute() {
target
} else {
std::env::current_dir()?.join(&target)
};
if target.join(".relicario").exists() {
anyhow::bail!(
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
target.display()
);
}
fs::create_dir_all(&target)
.with_context(|| format!("failed to create target {}", target.display()))?;
// Read input file.
let bytes = fs::read(&input)
.with_context(|| format!("failed to read backup file {}", input.display()))?;
// Backup passphrase prompt.
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
Zeroizing::new(p)
} else {
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
};
let unpacked = backup::unpack_backup(&bytes, &passphrase)
.map_err(|e| match e {
relicario_core::RelicarioError::Decrypt =>
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
other => anyhow::anyhow!(other),
})?;
// Write vault layout.
let relicario_dir = target.join(".relicario");
fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(target.join("items"))?;
fs::create_dir_all(target.join("attachments"))?;
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
for item in &unpacked.items {
let item_id = ItemId(item.id.clone());
if !item_id.is_valid() {
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
}
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
}
for a in &unpacked.attachments {
let item_id = ItemId(a.item_id.clone());
let att_id = AttachmentId(a.attachment_id.clone());
if !item_id.is_valid() || !att_id.is_valid() {
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
}
let dir = target.join("attachments").join(&a.item_id);
fs::create_dir_all(&dir)?;
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
}
// Reference image (if present).
if let Some(jpg) = &unpacked.reference_jpg {
let path = target.join("reference.jpg");
fs::write(&path, jpg)
.with_context(|| format!("failed to write reference image {}", path.display()))?;
}
// .git/ history.
if let Some(tar_bytes) = &unpacked.git_archive {
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
let cap = std::cmp::min(
(tar_bytes.len() as u64).saturating_mul(100),
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
);
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
.with_context(|| "failed to safely unpack .git/ archive")?;
let git_dir = target.join(".git");
for (rel_path, body) in entries {
let dest = git_dir.join(&rel_path);
// Paranoid OS-level check even after textual validation in core.
if !dest.starts_with(&git_dir) {
anyhow::bail!(
"tar entry {} resolved outside .git/ (path traversal blocked)",
rel_path.display()
);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent {}", parent.display())
})?;
}
fs::write(&dest, &body).with_context(|| {
format!("write {}", dest.display())
})?;
}
} else {
// No history bundled — start a fresh git repo.
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
// .gitignore — exclude reference image if present.
if target.join("reference.jpg").exists() {
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
}
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
let msg = format!("restore from backup {now_iso}");
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
}
eprintln!(
"Restored vault to {}. Unlock with your passphrase + reference image.",
target.display()
);
Ok(())
}

View File

@@ -0,0 +1,255 @@
//! `relicario device {add, revoke, list}` — device key management.
//!
//! Note: command bodies live here as `crate::commands::device`. Local key
//! storage and git-signing config live separately in `crate::device`.
use anyhow::Result;
use crate::DeviceAction;
/// Build a `GiteaClient` from flags or environment variables.
fn load_gitea_client(
gitea_url: Option<String>,
gitea_token: Option<String>,
owner: Option<String>,
repo: Option<String>,
) -> Result<crate::gitea::GiteaClient> {
let url = gitea_url
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
))?;
let token = gitea_token
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
))?;
let owner = owner
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
))?;
let repo = repo
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
))?;
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
}
pub fn cmd_device(action: DeviceAction) -> Result<()> {
use std::fs;
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
let root = crate::helpers::vault_dir()?;
let relicario_dir = root.join(".relicario");
let devices_path = relicario_dir.join("devices.json");
match action {
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
// Guard: don't overwrite an already-registered device name.
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
if existing.iter().any(|d| d.name == name) {
anyhow::bail!("a device named '{}' is already registered", name);
}
eprintln!("Generating signing keypair...");
let (signing_priv, signing_pub) = generate_keypair()
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
eprintln!("Generating deploy keypair...");
let (deploy_priv, deploy_pub) = generate_keypair()
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
// Optionally register deploy key with Gitea.
let gitea_key_id: u64 = if no_gitea {
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
0
} else {
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
let key_title = format!("relicario-{}", name);
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
client.create_deploy_key(&key_title, &deploy_pub)?
};
// Store keys locally with proper permissions.
crate::device::store_device_keys(
&name,
&signing_priv,
&signing_pub,
&deploy_priv,
&deploy_pub,
gitea_key_id,
)?;
// Mark as current device.
crate::device::set_current_device(&name)?;
// Configure git signing + SSH deploy key in the vault repo.
crate::device::configure_git_signing(&root, &name)?;
// Update devices.json.
let current_name = name.clone();
let mut devices = existing;
devices.push(DeviceEntry {
name: name.clone(),
public_key: signing_pub.clone(),
added_at: relicario_core::now_unix(),
added_by: current_name,
});
fs::create_dir_all(&relicario_dir)?;
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Commit the update.
crate::helpers::git_run(
&root,
&["add", ".relicario/devices.json"],
&format!("device register \"{name}\": git add .relicario/devices.json"),
)?;
let msg = format!("device: register {}", name);
crate::helpers::git_run(
&root,
&["commit", "-m", &msg],
&format!("device register \"{name}\": git commit"),
)?;
eprintln!("Device '{}' registered.", name);
eprintln!("Signing public key:");
eprintln!(" {}", signing_pub);
if gitea_key_id != 0 {
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
}
Ok(())
}
DeviceAction::Revoke { name } => {
// Guard: refuse to revoke the currently active device (would lock
// the user out). They must add another device first.
if let Some(current) = crate::device::current_device()? {
if current == name {
anyhow::bail!(
"cannot revoke the current device '{}' — you would lose \
push access. Register another device first.",
name
);
}
}
// Load devices.json.
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let device = devices
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
.clone();
// Remove from devices.json.
devices.retain(|d| d.name != name);
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Append to revoked.json.
let revoked_path = relicario_dir.join("revoked.json");
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let revoked_by = crate::device::current_device()?
.unwrap_or_else(|| "unknown".to_string());
revoked.push(RevokedEntry {
name: name.clone(),
public_key: device.public_key.clone(),
revoked_at: relicario_core::now_unix(),
revoked_by,
});
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
// Delete deploy key from Gitea (best-effort — don't fail if it
// was already deleted or the config is missing).
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
if key_id != 0 {
// Build client from env vars only (no flags in revoke).
match load_gitea_client(None, None, None, None) {
Ok(client) => {
if let Err(e) = client.delete_deploy_key(key_id) {
eprintln!(
"warning: failed to delete Gitea deploy key {}: {}",
key_id, e
);
} else {
eprintln!("Deleted Gitea deploy key {}.", key_id);
}
}
Err(_) => {
eprintln!(
"warning: Gitea env vars not set — deploy key {} \
not deleted from Gitea.",
key_id
);
}
}
}
}
// Commit devices.json + revoked.json (always both — revoked.json
// was just written above so it is guaranteed to exist).
let add_args = [
"add",
".relicario/devices.json",
".relicario/revoked.json",
];
crate::helpers::git_run(
&root,
&add_args,
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
)?;
let msg = format!("device: revoke {}", name);
crate::helpers::git_run(
&root,
&["commit", "-m", &msg],
&format!("device revoke \"{name}\": git commit"),
)?;
eprintln!("Device '{}' revoked.", name);
eprintln!("Revoked signing key: {}", device.public_key);
Ok(())
}
DeviceAction::List => {
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let current = crate::device::current_device()?.unwrap_or_default();
if devices.is_empty() {
println!("No registered devices.");
return Ok(());
}
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
println!("{}", "-".repeat(72));
for d in &devices {
let marker = if d.name == current { " *" } else { "" };
let added = crate::helpers::iso8601(d.added_at);
// Show only the first 40 chars of the public key line for readability.
let key_prefix: String = d.public_key.chars().take(40).collect();
println!("{:<20} {:<20} {}{}",
d.name, added, key_prefix, marker);
}
if !current.is_empty() {
println!("\n* = current device");
}
Ok(())
}
}
}

View File

@@ -0,0 +1,171 @@
//! `relicario edit <query>` — interactive per-type field editing with history capture.
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
item.title, item.id.as_str());
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
}
let history = &mut item.field_history;
match &mut item.core {
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
ItemCore::Identity(i) => edit_identity(i)?,
ItemCore::Card(c) => edit_card(c, history)?,
ItemCore::Key(k) => edit_key(k, history)?,
ItemCore::Document(_) => edit_document_message(),
ItemCore::Totp(t) => edit_totp(t, history)?,
}
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.after_manifest_change(&manifest)?;
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Updated {}", item.id.as_str());
Ok(())
}
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
// that touch history-tracked fields take the item's field_history map so
// they can record the prior value alongside the change.
type FieldHistory = std::collections::HashMap<
relicario_core::FieldId,
Vec<relicario_core::item::FieldHistoryEntry>,
>;
fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
eprintln!("Enter new body; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Replace key material?")? {
eprintln!("Paste new key material; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
fn push_history(
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
use relicario_core::item::FieldHistoryEntry;
use relicario_core::time::now_unix;
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}

View File

@@ -0,0 +1,68 @@
//! `relicario generate` — emit a fresh password or BIP39 passphrase.
use anyhow::Result;
pub fn cmd_generate(
length: Option<u32>,
bip39: bool,
words: Option<u32>,
symbols: Option<String>,
separator: Option<String>,
) -> Result<()> {
use relicario_core::{
generate_passphrase, generate_password, Capitalization, CharClasses,
GeneratorRequest, SymbolCharset,
};
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
Some(vault.load_settings()?.generator_defaults)
} else {
None
};
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
// vault default is in (Random when no vault).
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
let output = if use_bip39 {
let (def_words, def_sep, def_cap) = match &vault_defaults {
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
(*word_count, separator.clone(), *capitalization)
}
_ => (5, " ".to_string(), Capitalization::Lower),
};
generate_passphrase(&GeneratorRequest::Bip39 {
word_count: words.unwrap_or(def_words),
separator: separator.unwrap_or(def_sep),
capitalization: def_cap,
})?
} else {
let (def_length, def_classes, def_charset) = match &vault_defaults {
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
(*length, *classes, symbol_charset.clone())
}
_ => (
20,
CharClasses { lower: true, upper: true, digits: true, symbols: true },
SymbolCharset::SafeOnly,
),
};
let symbol_charset = match symbols.as_deref() {
None => def_charset,
Some("safe") => SymbolCharset::SafeOnly,
Some("extended") => SymbolCharset::Extended,
Some(other) => SymbolCharset::Custom(other.to_string()),
};
generate_password(&GeneratorRequest::Random {
length: length.unwrap_or(def_length),
classes: def_classes,
symbol_charset,
})?
};
println!("{}", output.as_str());
Ok(())
}

View File

@@ -0,0 +1,106 @@
//! `relicario get` — print a single item, masking secrets unless `--show`.
use anyhow::{Context, Result};
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
println!("ID: {}", item.id.as_str());
println!("Title: {}", item.title);
println!("Type: {:?}", item.r#type);
if let Some(g) = &item.group { println!("Group: {g}"); }
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
println!("Created: {}", crate::helpers::iso8601(item.created));
println!("Modified: {}", crate::helpers::iso8601(item.modified));
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
println!();
let primary_secret: Option<Zeroizing<String>> = match &item.core {
ItemCore::Login(l) => {
if let Some(u) = &l.username { println!("Username: {u}"); }
if let Some(u) = &l.url { println!("URL: {u}"); }
if let Some(t) = &l.totp {
if show {
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
} else {
println!("TOTP: **** (use --show to reveal)");
}
}
l.password.clone()
}
ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); }
else { println!("Body: ********"); }
None
}
ItemCore::Identity(i) => {
if let Some(v) = &i.full_name { println!("Name: {v}"); }
if let Some(v) = &i.email { println!("Email: {v}"); }
if let Some(v) = &i.phone { println!("Phone: {v}"); }
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
None
}
ItemCore::Card(c) => {
if let Some(h) = &c.holder { println!("Holder: {h}"); }
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
println!("Kind: {:?}", c.kind);
c.number.clone()
}
ItemCore::Key(k) => {
if let Some(l) = &k.label { println!("Label: {l}"); }
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
Some(k.key_material.clone())
}
ItemCore::Document(d) => {
println!("Filename: {}", d.filename);
println!("MIME: {}", d.mime_type);
None
}
ItemCore::Totp(t) => {
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
if let Some(l) = &t.label { println!("Label: {l}"); }
println!("Period: {}s", t.config.period_seconds);
println!("Digits: {}", t.config.digits);
None
}
};
if let Some(secret) = primary_secret {
if show {
println!("Secret: {}", secret.as_str());
} else {
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
}
if copy {
copy_to_clipboard_then_clear(&secret)?;
eprintln!("Copied to clipboard (auto-clears in 30s).");
}
}
Ok(())
}
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
use arboard::Clipboard;
let mut cb = Clipboard::new().context("failed to access clipboard")?;
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
// and then rewrites the clipboard with empty string. Even if the user
// copies something else in the interim, we still overwrite once.
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(mut cb) = Clipboard::new() {
let _ = cb.set_text(String::new());
drop(cleared_copy); // zeroize the detached copy
}
});
Ok(())
}

View File

@@ -0,0 +1,88 @@
//! `relicario import` — currently only LastPass CSV is supported.
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use crate::ImportAction;
pub fn cmd_import(action: ImportAction) -> Result<()> {
match action {
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
}
}
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::import_lastpass::parse_lastpass_csv;
let csv_bytes = fs::read(&csv_path)
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
if items.is_empty() {
// Print all warnings so the user sees why nothing imported.
for w in &warnings {
print_warning(w);
}
bail!(
"imported 0 items from {} — see warnings above",
csv_path.display()
);
}
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let total = items.len();
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
for (idx, item) in items.iter().enumerate() {
vault.save_item(item)?;
manifest.upsert(item);
written_paths.push(format!("items/{}.enc", item.id.as_str()));
let n = idx + 1;
if n % 50 == 0 || n == total {
eprintln!("[{n}/{total}] importing...");
}
}
vault.after_manifest_change(&manifest)?;
written_paths.push("manifest.enc".into());
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
let csv_filename = csv_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("lastpass.csv");
super::commit_paths(
&vault,
&format!("import: {} items from LastPass ({})", total, csv_filename),
&path_refs,
)?;
for w in &warnings {
print_warning(w);
}
// Counts only true skips, not partial imports. Coupled by convention to
// the parser's warning message strings: skip messages end in "— skipped",
// partial-import messages say "imported without TOTP" / "imported without URL".
// If a future warning uses the word "skipped" in any other sense, this filter
// will need to switch to an enum tag (see ImportWarning::message).
eprintln!(
"Imported {}, skipped {} (see warnings above)",
total,
warnings.iter().filter(|w| w.message.contains("skipped")).count()
);
Ok(())
}
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
let prefix = match &w.title {
Some(t) => format!("row {} ({}):", w.row, t),
None => format!("row {}:", w.row),
};
eprintln!("warning: {prefix} {}", w.message);
}

View File

@@ -0,0 +1,98 @@
//! `relicario init` — bootstrap a fresh vault in the current directory.
use std::path::PathBuf;
use anyhow::{Context, Result};
pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
use std::fs;
use rand::{rngs::OsRng, RngCore};
use relicario_core::{
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
};
use zeroize::Zeroizing;
let root = std::env::current_dir()?;
let relicario_dir = root.join(".relicario");
if relicario_dir.exists() {
anyhow::bail!(".relicario/ already exists in {}", root.display());
}
// Passphrase with strength gate (audit H3).
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
// TTY prompt so integration tests can run without a real TTY.
let passphrase = if let Some(p) = crate::test_passphrase_override() {
Zeroizing::new(p)
} else {
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
};
let confirm = if crate::test_passphrase_override().is_some() {
passphrase.clone()
} else {
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
};
if passphrase.as_str() != confirm.as_str() {
anyhow::bail!("passphrases do not match");
}
if let Err(e) = validate_passphrase_strength(&passphrase) {
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
}
// Image secret: 32 random bytes, embedded in the carrier.
let image_secret = {
let mut buf = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(buf.as_mut_slice());
buf
};
let carrier = fs::read(&image)
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
let stego = imgsecret::embed(&carrier, &image_secret)?;
fs::write(&output, &stego)
.with_context(|| format!("failed to write reference image {}", output.display()))?;
// Vault salt + KDF params.
let mut salt = [0u8; 32];
OsRng.fill_bytes(&mut salt);
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
// Derive master key, then persist an empty Manifest + default VaultSettings.
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)?;
fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(root.join("items"))?;
fs::create_dir_all(root.join("attachments"))?;
fs::write(relicario_dir.join("salt"), salt)?;
fs::write(
relicario_dir.join("params.json"),
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(&params))?,
)?;
let manifest = Manifest::new();
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
let settings = VaultSettings::default();
fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;
// .gitignore excludes the reference image.
let fname = output.file_name()
.ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
.to_string_lossy();
let gitignore = format!("{fname}\n");
fs::write(root.join(".gitignore"), gitignore)?;
// git init + initial commit via hardened wrapper.
crate::helpers::git_run(&root, &["init"], "init: git init")?;
let _ = crate::helpers::git_command(&root, &[
"add", ".gitignore", ".relicario/params.json",
".relicario/salt", "manifest.enc", "settings.enc",
]).status()?;
crate::helpers::git_run(
&root,
&["commit", "-m", "init: new Relicario vault (format v2)"],
"init: git commit",
)?;
eprintln!("Vault initialized at {}", root.display());
eprintln!("Reference image: {}", output.display());
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
Ok(())
}

View File

@@ -0,0 +1,102 @@
//! `relicario list` and `relicario history` — both read-only browse paths.
use anyhow::Result;
pub fn cmd_list(
type_filter: Option<String>,
group_filter: Option<String>,
tag_filter: Option<String>,
trashed: bool,
) -> Result<()> {
use relicario_core::ItemType;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
None => None,
Some("login") => Some(ItemType::Login),
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
Some("identity") => Some(ItemType::Identity),
Some("card") => Some(ItemType::Card),
Some("key") => Some(ItemType::Key),
Some("document") => Some(ItemType::Document),
Some("totp") => Some(ItemType::Totp),
Some(other) => anyhow::bail!("unknown type filter: {other}"),
};
let mut entries: Vec<_> = manifest.items.values()
.filter(|e| {
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
})
.filter(|e| match parsed_type {
Some(t) => e.r#type == t,
None => true,
})
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
.collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
if entries.is_empty() {
eprintln!("(no items match)");
return Ok(());
}
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
for e in entries {
let fav = if e.favorite { " *" } else { "" };
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
}
Ok(())
}
pub fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
println!("History for {} ({})", item.title, item.id.as_str());
println!();
// Filter and sort the field-id keys so output is deterministic.
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
let mut printed_any = false;
for fid in keys {
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
if let Some(filter) = &field {
if display_name != filter && fid.0 != *filter { continue; }
}
let entries = &item.field_history[fid];
if entries.is_empty() { continue; }
printed_any = true;
println!("{display_name} ({} {})",
entries.len(),
if entries.len() == 1 { "entry" } else { "entries" });
for (i, e) in entries.iter().enumerate() {
let ts = crate::helpers::iso8601(e.replaced_at);
if show {
println!(" [{i}] {ts} {}", e.value.as_str());
} else {
println!(" [{i}] {ts} ********");
}
}
println!();
}
if !printed_any {
if field.is_some() {
println!("no history for the requested field");
} else {
println!("no history captured for this item");
}
} else if !show {
println!("(use --show to reveal values)");
}
Ok(())
}

View File

@@ -0,0 +1,60 @@
//! Per-command modules — one file per top-level subcommand.
//!
//! `main.rs` holds the clap surface (argument enums) and the dispatch
//! `match`; the actual command bodies live here. Helpers shared between
//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in
//! this file as `pub(crate)` so siblings can pull them in via
//! `use crate::commands::*`.
pub mod add;
pub mod attach;
pub mod backup;
pub mod device;
pub mod edit;
pub mod generate;
pub mod get;
pub mod import;
pub mod init;
pub mod list;
pub mod rate;
pub mod recovery_qr;
pub mod settings;
pub mod status;
pub mod sync;
pub mod trash;
use anyhow::Result;
pub(crate) fn commit_paths(
vault: &crate::session::UnlockedVault,
message: &str,
paths: &[&str],
) -> Result<()> {
let mut args: Vec<&str> = vec!["add"];
args.extend_from_slice(paths);
crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?;
crate::helpers::git_run(
vault.root(),
&["commit", "-m", message],
&format!("commit \"{message}\": git commit"),
)?;
Ok(())
}
pub(crate) fn resolve_query<'a>(
manifest: &'a relicario_core::Manifest,
query: &str,
) -> Result<&'a relicario_core::ManifestEntry> {
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
return Ok(entry);
}
let hits: Vec<_> = manifest.search(query);
match hits.len() {
0 => anyhow::bail!("no item matches `{query}`"),
1 => Ok(hits[0]),
_ => {
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
}
}
}

View File

@@ -0,0 +1,28 @@
//! `relicario rate` — score a passphrase via zxcvbn.
use anyhow::Result;
pub fn cmd_rate(passphrase: String) -> Result<()> {
let pw: String = if passphrase == "-" {
use std::io::BufRead;
let stdin = std::io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
line.trim_end_matches(&['\r', '\n'][..]).to_string()
} else {
passphrase
};
let est = relicario_core::generators::rate_passphrase(&pw);
let label = match est.score {
0 => "very weak",
1 => "weak",
2 => "fair",
3 => "good",
4 => "strong",
_ => "?",
};
println!("score: {}/4 ({})", est.score, label);
println!("guesses: ~10^{:.1}", est.guesses_log10);
println!("note: init requires score ≥ 3 (see `relicario init`)");
Ok(())
}

View File

@@ -0,0 +1,69 @@
//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch.
use anyhow::{Context, Result};
use crate::RecoveryQrCmd;
pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
}
}
fn cmd_recovery_qr_generate() -> Result<()> {
use relicario_core::{generate_recovery_qr, imgsecret};
use zeroize::Zeroizing;
let image_path = crate::session::get_image_path()?;
let image_bytes = std::fs::read(&image_path)
.with_context(|| format!("read reference image {}", image_path.display()))?;
let image_secret = imgsecret::extract(&image_bytes)
.context("extract image secret")?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter vault passphrase: ")
.context("read passphrase")?
);
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
.map_err(|e| anyhow::anyhow!("{e}"))?;
use qrcode::{EcLevel, QrCode, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
.expect("valid payload");
let image = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{image}");
println!("Recovery QR generated. Print or photograph this code and store it securely.");
println!("The QR has NOT been saved to disk.");
Ok(())
}
fn cmd_recovery_qr_unwrap() -> Result<()> {
use relicario_core::unwrap_recovery_qr;
use std::io::BufRead;
use zeroize::Zeroizing;
println!("Paste the base64 recovery QR payload and press Enter:");
let stdin = std::io::stdin();
let payload_b64 = stdin.lock().lines().next()
.context("no input")??;
let payload_b64 = payload_b64.trim().to_owned();
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter passphrase: ")
.context("read passphrase")?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}

View File

@@ -0,0 +1,98 @@
//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`.
use anyhow::Result;
use crate::SettingsAction;
pub fn cmd_settings(action: SettingsAction) -> Result<()> {
use relicario_core::{
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
SymbolCharset, TrashRetention,
};
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut settings = vault.load_settings()?;
match action {
SettingsAction::Show => {
println!("{}", serde_json::to_string_pretty(&settings)?);
return Ok(());
}
SettingsAction::TrashRetention { days, forever } => {
settings.trash_retention = match (days, forever) {
(Some(d), false) => TrashRetention::Days(d),
(None, true) => TrashRetention::Forever,
_ => anyhow::bail!("specify exactly one of --days or --forever"),
};
}
SettingsAction::HistoryRetention { last_n, days, forever } => {
settings.field_history_retention = match (last_n, days, forever) {
(Some(n), None, false) => HistoryRetention::LastN(n),
(None, Some(d), false) => HistoryRetention::Days(d),
(None, None, true) => HistoryRetention::Forever,
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
};
}
SettingsAction::AttachmentCap {
per_attachment_max_bytes, per_item_max_count,
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
} => {
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
}
SettingsAction::GeneratorDefaults {
random, bip39, length, words, symbols, separator,
} => {
// Decide target mode: explicit flag wins, else preserve current.
let target_bip39 = if random { false }
else if bip39 { true }
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
// Pull existing fields where compatible, else seed with sensible
// defaults (kept in sync with `GeneratorRequest::default()`).
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
GeneratorRequest::Random { length, classes, symbol_charset } => {
(*length, *classes, symbol_charset.clone())
}
_ => (
20,
CharClasses { lower: true, upper: true, digits: true, symbols: true },
SymbolCharset::SafeOnly,
),
};
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
(*word_count, separator.clone(), *capitalization)
}
_ => (5, " ".to_string(), Capitalization::Lower),
};
settings.generator_defaults = if target_bip39 {
GeneratorRequest::Bip39 {
word_count: words.unwrap_or(cur_words),
separator: separator.unwrap_or(cur_sep),
capitalization: cur_cap,
}
} else {
let charset = match symbols.as_deref() {
None => cur_charset,
Some("safe") => SymbolCharset::SafeOnly,
Some("extended") => SymbolCharset::Extended,
Some(other) => SymbolCharset::Custom(other.to_string()),
};
GeneratorRequest::Random {
length: length.unwrap_or(cur_length),
classes: cur_classes,
symbol_charset: charset,
}
};
}
}
vault.save_settings(&settings)?;
super::commit_paths(&vault, "settings: update", &["settings.enc"])?;
eprintln!("Settings updated.");
Ok(())
}

View File

@@ -0,0 +1,52 @@
//! `relicario status` — vault-level summary (counts, last commit, last backup).
use anyhow::Result;
pub fn cmd_status() -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let root = vault.root().to_path_buf();
let manifest = vault.load_manifest()?;
let total_items = manifest.items.len();
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
let active_items = total_items - trashed_items;
let (attachment_count, attachment_bytes) = manifest.items.values()
.flat_map(|e| e.attachment_summaries.iter())
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
let last_commit = crate::helpers::git_command(&root, &[
"log", "-1", "--pretty=format:%h %s",
]).output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "(no commits)".into());
// Last backup age (read from marker written by cmd_backup_export).
let last_backup_path = vault.root().join(".relicario").join("last_backup");
let last_backup_str = if last_backup_path.exists() {
let line = std::fs::read_to_string(&last_backup_path)
.unwrap_or_default()
.trim()
.to_string();
// Parse the ISO-8601 we wrote in cmd_backup_export.
match chrono::DateTime::parse_from_rfc3339(&line) {
Ok(then) => {
let now = relicario_core::now_unix();
let age = now - then.timestamp();
crate::helpers::humanize_age(age.max(0))
}
Err(_) => "unknown".to_string(),
}
} else {
"never".to_string()
};
println!("Vault: {}", root.display());
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
println!("Last commit: {last_commit}");
println!("Last export: {last_backup_str}");
Ok(())
}

View File

@@ -0,0 +1,11 @@
//! `relicario sync` — pull --rebase + push.
use anyhow::Result;
pub fn cmd_sync() -> Result<()> {
let root = crate::helpers::vault_dir()?;
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
eprintln!("Sync complete.");
Ok(())
}

View File

@@ -0,0 +1,149 @@
//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent),
//! `trash list` / `trash empty`.
use anyhow::Result;
use crate::TrashAction;
pub fn cmd_rm(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
item.soft_delete();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.after_manifest_change(&manifest)?;
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Moved to trash: {}", item.title);
Ok(())
}
pub fn cmd_restore(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
item.restore();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.after_manifest_change(&manifest)?;
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Restored: {}", item.title);
Ok(())
}
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
/// the manifest in memory. Returns the relative paths the caller must stage
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
/// batches them.
pub(super) fn purge_item_filesystem(
vault: &crate::session::UnlockedVault,
manifest: &mut relicario_core::Manifest,
id: &relicario_core::ItemId,
title: &str,
) -> Result<Vec<String>> {
use std::{fs, io::ErrorKind};
let item_rel = format!("items/{}.enc", id.as_str());
let att_rel = format!("attachments/{}", id.as_str());
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
match r {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
};
ignore_missing(fs::remove_file(vault.item_path(id)))?;
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
manifest.remove(id);
eprintln!("Purged: {title}");
Ok(vec![item_rel, att_rel])
}
pub fn cmd_purge(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let title = entry.title.clone();
let _ = entry;
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
vault.after_manifest_change(&manifest)?;
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
crate::helpers::git_run(
vault.root(),
&["add", "manifest.enc"],
&format!("{purge_ctx}: git add manifest.enc"),
)?;
crate::helpers::git_run(
vault.root(),
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
&format!("{purge_ctx}: git commit"),
)?;
Ok(())
}
pub fn cmd_trash(action: TrashAction) -> Result<()> {
match action {
TrashAction::List => super::list::cmd_list(None, None, None, true),
TrashAction::Empty => cmd_trash_empty(),
}
}
pub fn cmd_trash_empty() -> Result<()> {
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let settings = vault.load_settings()?;
let now = now_unix();
let purgeable: Vec<_> = manifest.items.values()
.filter(|e| match e.trashed_at {
Some(t) => settings.trash_retention.should_purge(t, now),
None => false,
})
.map(|e| (e.id.clone(), e.title.clone()))
.collect();
if purgeable.is_empty() {
eprintln!("nothing past retention window");
return Ok(());
}
let mut all_paths: Vec<String> = Vec::new();
let purged_count = purgeable.len();
for (id, title) in purgeable {
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
all_paths.append(&mut paths);
}
vault.after_manifest_change(&manifest)?;
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
crate::helpers::git_run(
vault.root(),
&["add", "manifest.enc"],
"trash empty: git add manifest.enc",
)?;
crate::helpers::git_run(
vault.root(),
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
"trash empty: git commit",
)?;
eprintln!("Emptied trash: {} item(s)", purged_count);
Ok(())
}

View File

@@ -91,6 +91,7 @@ pub fn store_device_keys(
} }
/// Load the signing private key for a device. /// Load the signing private key for a device.
#[allow(dead_code)]
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> { pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
let path = device_dir(name)?.join("signing.key"); let path = device_dir(name)?.join("signing.key");
let key = fs::read_to_string(&path) let key = fs::read_to_string(&path)
@@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
} }
/// Load the deploy private key for a device. /// Load the deploy private key for a device.
#[allow(dead_code)]
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> { pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
let path = device_dir(name)?.join("deploy.key"); let path = device_dir(name)?.join("deploy.key");
let key = fs::read_to_string(&path) let key = fs::read_to_string(&path)
@@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result<u64> {
} }
/// Delete the local key directory for a device. /// Delete the local key directory for a device.
#[allow(dead_code)]
pub fn delete_device_keys(name: &str) -> Result<()> { pub fn delete_device_keys(name: &str) -> Result<()> {
let dir = device_dir(name)?; let dir = device_dir(name)?;
if dir.exists() { if dir.exists() {

View File

@@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct DeployKey { pub struct DeployKey {
pub id: u64, pub id: u64,
#[allow(dead_code)]
pub title: String, pub title: String,
#[allow(dead_code)]
pub key: String, pub key: String,
} }
@@ -89,6 +91,7 @@ impl GiteaClient {
} }
/// List all deploy keys. /// List all deploy keys.
#[allow(dead_code)]
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> { pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
let url = format!( let url = format!(
"{}/repos/{}/{}/keys", "{}/repos/{}/{}/keys",

View File

@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
} }
/// Path to the `.relicario/` configuration directory within the vault. /// Path to the `.relicario/` configuration directory within the vault.
#[allow(dead_code)]
pub fn relicario_dir() -> Result<PathBuf> { pub fn relicario_dir() -> Result<PathBuf> {
Ok(vault_dir()?.join(".relicario")) Ok(vault_dir()?.join(".relicario"))
} }
@@ -54,6 +55,47 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command {
cmd cmd
} }
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
/// capturing stdout/stderr and reproducing them on failure so the caller
/// sees git's exact diagnostic instead of just a verb.
///
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
/// identifiable from the error alone.
///
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
/// (so live progress disappears during long-running fetches/pushes) but the
/// captured chunk is replayed verbatim on failure. The win is that
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
/// instead of one-line "git X failed" bails. Use `git_command` directly when
/// live streaming is required.
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
let output = git_command(repo, args)
.output()
.with_context(|| format!("{context}: failed to spawn git"))?;
if !output.status.success() {
if !output.stdout.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
bail!("{context}: git failed ({})", output.status);
}
Ok(())
}
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
/// leave the manifest entry without the corresponding `items/<id>.enc` on
/// disk, and we want the rm to succeed regardless.
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
args.extend(paths.iter().map(String::as_str));
git_run(repo, &args, context)
}
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string. /// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned /// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string. /// a numeric string.
@@ -88,19 +130,45 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
/// ///
/// **Plaintext leak:** group names land on disk in cleartext alongside the /// **Plaintext leak:** group names land on disk in cleartext alongside the
/// vault directory. This is intentional — the file feeds shell completion, /// vault directory. This is intentional — the file feeds shell completion,
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1` /// which cannot prompt for a passphrase. In debug builds, set
/// to suppress the write. /// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf { pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
vault_dir.join(".relicario").join("groups.cache") vault_dir.join(".relicario").join("groups.cache")
} }
/// Collect all non-empty group names from the manifest and write them to the
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
/// candidates without prompting for the vault passphrase.
///
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
/// not a correctness problem.
///
/// 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() {
if !g.is_empty() {
set.insert(g.clone());
}
}
}
let _ = write_groups_cache(vault_dir, &set);
}
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`, /// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set. /// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (developer debugging tool). In release builds the env
/// var is ignored.
pub fn write_groups_cache( pub fn write_groups_cache(
vault_dir: &Path, vault_dir: &Path,
groups: &std::collections::BTreeSet<String>, groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
return Ok(()); return Ok(());
} }
let path = groups_cache_path(vault_dir); let path = groups_cache_path(vault_dir);
@@ -217,6 +285,24 @@ mod tests {
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}"); assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
} }
#[test]
fn git_run_bails_with_context_on_failure() {
// Empty tempdir — `git status` will fail with "not a git repository".
let tmp = TempDir::new().unwrap();
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
}
#[test]
fn git_run_succeeds_for_a_zero_exit_command() {
// `git --version` always succeeds and is independent of cwd.
let tmp = TempDir::new().unwrap();
git_run(tmp.path(), &["--version"], "version probe")
.expect("git --version should succeed");
}
#[test] #[test]
fn humanize_age_buckets() { fn humanize_age_buckets() {
assert_eq!(humanize_age(0), "just now"); assert_eq!(humanize_age(0), "just now");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
//! Thin shims over `relicario-core`'s migrated parsers, kept here so existing
//! CLI callsites need no import churn. Plan B Phase 7 moved the bodies into
//! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient,
//! mime::guess_for_extension}`.
use anyhow::Result;
use relicario_core::MonthYear;
pub(crate) fn parse_month_year(s: &str) -> Result<MonthYear> {
Ok(MonthYear::parse(s)?)
}
pub(crate) fn guess_mime(filename: &str) -> String {
relicario_core::mime::guess_for_extension(filename).to_string()
}
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
}

View File

@@ -0,0 +1,195 @@
//! Interactive prompt helpers for the CLI.
//!
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! used by the edit handlers to keep current values when the user hits enter
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
//! so integration tests (which don't have a TTY) can inject secrets.
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
//! through the same path so command handlers can use one call site whether
//! the value came from the command line or from an interactive prompt.
use anyhow::Result;
use std::io::BufRead;
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
/// for integration-test use (rpassword reads /dev/tty by default, which is
/// unavailable in assert_cmd-spawned children).
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
if let Some(s) = crate::test_item_secret_override() {
return Ok(s);
}
rpassword::prompt_password(label).map_err(Into::into)
}
fn read_required_line<R: BufRead>(reader: &mut R, label: &str) -> Result<String> {
eprint!("{label}: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
reader.read_line(&mut s)?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
Ok(trimmed)
}
fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<String>> {
eprint!("{label} (leave blank to skip): ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
reader.read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt(label: &str) -> Result<String> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_required_line(&mut reader, label)
}
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_optional_line(&mut reader, label)
}
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
let display = current.unwrap_or("(none)");
eprint!("{label} [{display}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
eprint!("{label} [y/N] ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
}
pub(crate) fn prompt_or_flag<T>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
) -> Result<T> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
prompt_or_flag_with_reader(flag, label, parser, &mut reader)
}
pub(crate) fn prompt_or_flag_optional<T>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
) -> Result<Option<T>> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
prompt_or_flag_optional_with_reader(flag, label, parser, &mut reader)
}
pub(crate) fn prompt_or_flag_with_reader<T, R: BufRead>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
reader: &mut R,
) -> Result<T> {
if let Some(t) = flag {
return Ok(t);
}
let line = read_required_line(reader, label)?;
parser(&line)
}
pub(crate) fn prompt_or_flag_optional_with_reader<T, R: BufRead>(
flag: Option<T>,
label: &str,
parser: impl FnOnce(&str) -> Result<T>,
reader: &mut R,
) -> Result<Option<T>> {
if let Some(t) = flag {
return Ok(Some(t));
}
match read_optional_line(reader, label)? {
None => Ok(None),
Some(line) => parser(&line).map(Some),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn prompt_or_flag_uses_flag_value_when_some() {
let mut reader = Cursor::new(Vec::<u8>::new());
let got = prompt_or_flag_with_reader::<String, _>(
Some("from-flag".to_string()),
"Title",
|_| panic!("parser must not run when flag is Some"),
&mut reader,
).expect("flag value path should succeed");
assert_eq!(got, "from-flag");
}
#[test]
fn prompt_or_flag_prompts_when_none() {
let mut reader = Cursor::new(b"prompted\n".to_vec());
let got = prompt_or_flag_with_reader::<String, _>(
None,
"Title",
|s| Ok(s.to_string()),
&mut reader,
).expect("prompt path should succeed");
assert_eq!(got, "prompted");
}
#[test]
fn prompt_or_flag_optional_returns_some_from_flag_without_reading() {
let mut reader = Cursor::new(Vec::<u8>::new());
let got = prompt_or_flag_optional_with_reader::<String, _>(
Some("flag-val".to_string()),
"URL",
|_| panic!("parser must not run when flag is Some"),
&mut reader,
).expect("flag value path should succeed");
assert_eq!(got, Some("flag-val".to_string()));
}
#[test]
fn prompt_or_flag_optional_prompts_and_blank_yields_none() {
let mut reader = Cursor::new(b"\n".to_vec());
let got = prompt_or_flag_optional_with_reader::<String, _>(
None,
"URL",
|_| panic!("parser must not run on blank input"),
&mut reader,
).expect("blank prompt should succeed with None");
assert_eq!(got, None);
}
#[test]
fn prompt_or_flag_optional_prompts_and_value_runs_parser() {
let mut reader = Cursor::new(b" 42 \n".to_vec());
let got = prompt_or_flag_optional_with_reader::<u32, _>(
None,
"Number",
|s| s.parse::<u32>().map_err(Into::into),
&mut reader,
).expect("value should parse");
assert_eq!(got, Some(42));
}
}

View File

@@ -50,7 +50,7 @@ impl UnlockedVault {
let master_key = derive_master_key( let master_key = derive_master_key(
passphrase.as_bytes(), passphrase.as_bytes(),
&*image_secret, &image_secret,
&salt, &salt,
&params, &params,
)?; )?;
@@ -69,9 +69,15 @@ impl UnlockedVault {
Ok(decrypt_manifest(&bytes, &self.master_key)?) Ok(decrypt_manifest(&bytes, &self.master_key)?)
} }
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> { /// Save the manifest and refresh the plaintext groups.cache. This is the
/// canonical "I just mutated the manifest" funnel — every command that
/// changes the manifest goes through this method, so cache freshness is
/// a compile-time invariant rather than a discipline rule.
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
let bytes = encrypt_manifest(manifest, &self.master_key)?; let bytes = encrypt_manifest(manifest, &self.master_key)?;
atomic_write(&self.manifest_path(), &bytes) atomic_write(&self.manifest_path(), &bytes)?;
crate::helpers::refresh_groups_cache(&self.root, manifest);
Ok(())
} }
pub fn load_settings(&self) -> Result<VaultSettings> { pub fn load_settings(&self) -> Result<VaultSettings> {
@@ -107,17 +113,52 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
Ok(salt) Ok(salt)
} }
fn read_params(root: &Path) -> Result<KdfParams> { #[derive(serde::Serialize, serde::Deserialize)]
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... } pub(crate) struct ParamsFile {
// We extract only the "kdf" sub-object and deserialize it as KdfParams. pub format_version: u32,
#[derive(serde::Deserialize)] pub kdf: ParamsKdf,
struct ParamsFile { pub aead: String,
kdf: KdfParams, pub salt_path: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ParamsKdf {
pub algorithm: String,
pub argon2_m: u32,
pub argon2_t: u32,
pub argon2_p: u32,
}
impl ParamsFile {
pub fn for_new_vault(params: &KdfParams) -> Self {
Self {
format_version: 2,
kdf: ParamsKdf {
algorithm: "argon2id-v0x13".into(),
argon2_m: params.argon2_m,
argon2_t: params.argon2_t,
argon2_p: params.argon2_p,
},
aead: "xchacha20poly1305".into(),
salt_path: ".relicario/salt".into(),
} }
}
pub fn to_kdf_params(&self) -> KdfParams {
KdfParams {
argon2_m: self.kdf.argon2_m,
argon2_t: self.kdf.argon2_t,
argon2_p: self.kdf.argon2_p,
}
}
}
fn read_params(root: &Path) -> Result<KdfParams> {
let s = fs::read_to_string(root.join(".relicario").join("params.json")) let s = fs::read_to_string(root.join(".relicario").join("params.json"))
.context("failed to read .relicario/params.json")?; .context("failed to read .relicario/params.json")?;
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?; let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
Ok(pf.kdf) Ok(pf.to_kdf_params())
} }
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. /// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
@@ -149,3 +190,78 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?; fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = r#"{
"format_version": 2,
"kdf": {
"algorithm": "argon2id-v0x13",
"argon2_m": 65536,
"argon2_t": 3,
"argon2_p": 4
},
"aead": "xchacha20poly1305",
"salt_path": ".relicario/salt"
}"#;
#[test]
fn params_file_round_trips_current_layout() {
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
assert_eq!(pf.format_version, 2);
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
assert_eq!(pf.kdf.argon2_m, 65536);
assert_eq!(pf.kdf.argon2_t, 3);
assert_eq!(pf.kdf.argon2_p, 4);
assert_eq!(pf.aead, "xchacha20poly1305");
assert_eq!(pf.salt_path, ".relicario/salt");
let kdf = pf.to_kdf_params();
assert_eq!(kdf.argon2_m, 65536);
assert_eq!(kdf.argon2_t, 3);
assert_eq!(kdf.argon2_p, 4);
let serialized = serde_json::to_string(&pf).expect("re-serialize");
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
assert_eq!(pf2.format_version, 2);
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
assert_eq!(pf2.kdf.argon2_m, 65536);
assert_eq!(pf2.kdf.argon2_t, 3);
assert_eq!(pf2.kdf.argon2_p, 4);
assert_eq!(pf2.aead, "xchacha20poly1305");
assert_eq!(pf2.salt_path, ".relicario/salt");
}
#[test]
fn for_new_vault_produces_expected_shape() {
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
let pf = ParamsFile::for_new_vault(&params);
let v = serde_json::to_value(&pf).expect("to_value");
assert_eq!(v["format_version"], 2);
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
assert_eq!(v["kdf"]["argon2_m"], 65536);
assert_eq!(v["kdf"]["argon2_t"], 3);
assert_eq!(v["kdf"]["argon2_p"], 4);
assert_eq!(v["aead"], "xchacha20poly1305");
assert_eq!(v["salt_path"], ".relicario/salt");
}
#[test]
fn after_manifest_change_writes_manifest_and_groups_cache() {
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".relicario")).unwrap();
std::fs::create_dir_all(root.join("items")).unwrap();
let vault = UnlockedVault {
root: root.clone(),
master_key: Zeroizing::new([0u8; 32]),
};
let manifest = Manifest::new();
vault.after_manifest_change(&manifest).unwrap();
assert!(root.join("manifest.enc").exists());
assert!(root.join(".relicario/groups.cache").exists());
}
}

View File

@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
// Encrypted blob file is gone. // Encrypted blob file is gone.
let blob_path = v.path() let blob_path = v.path()
.join("attachments") .join("attachments")
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or("")); .join("");
let item_attach_dir = std::fs::read_dir(v.path().join("attachments")) let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
.unwrap().next().unwrap().unwrap().path(); .unwrap().next().unwrap().unwrap().path();
let blob = item_attach_dir.join(format!("{aid}.enc")); let blob = item_attach_dir.join(format!("{aid}.enc"));

View File

@@ -109,6 +109,72 @@ fn rm_restore_purge_cycle() {
assert!(!String::from_utf8(out.stdout).unwrap().contains("target")); assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
} }
#[test]
fn trash_empty_batches_into_one_commit() {
let v = TestVault::init();
// Add 3 items.
for title in ["alpha", "bravo", "charlie"] {
let out = v.run(&[
"add", "login",
"--title", title,
"--username", "u",
"--password", "p",
]);
assert!(out.status.success(), "add {title} failed");
}
// Soft-delete all 3.
for title in ["alpha", "bravo", "charlie"] {
let out = v.run(&["rm", title]);
assert!(out.status.success(), "rm {title} failed");
}
// Set retention to 0 days so the recently-trashed items become purgeable
// (should_purge: now - trashed_at > 0 * 86400 = 0).
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
assert!(out.status.success(), "settings trash-retention failed");
// should_purge uses strict > on (now - trashed_at), so equal-second
// timestamps don't qualify.
std::thread::sleep(std::time::Duration::from_secs(1));
// Count commits before.
let before = std::process::Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.current_dir(v.path())
.output()
.unwrap();
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
// Run trash empty.
let out = v.run(&["trash", "empty"]);
assert!(out.status.success(), "trash empty failed: stderr={}",
String::from_utf8_lossy(&out.stderr));
// Count commits after.
let after = std::process::Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.current_dir(v.path())
.output()
.unwrap();
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
assert_eq!(
after_count - before_count, 1,
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
);
// The remaining `list --trashed` should be empty.
let out = v.run(&["list", "--trashed"]);
let stdout = String::from_utf8(out.stdout).unwrap();
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
"items still in trashed list: stdout={stdout} stderr={stderr}"
);
}
#[test] #[test]
fn generate_random_and_bip39() { fn generate_random_and_bip39() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();

View File

@@ -78,6 +78,7 @@ impl TestVault {
cmd.output().unwrap() cmd.output().unwrap()
} }
#[allow(dead_code)]
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output { pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap(); let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path()) cmd.current_dir(self.dir.path())
@@ -91,6 +92,7 @@ impl TestVault {
cmd.output().unwrap() cmd.output().unwrap()
} }
#[allow(dead_code)]
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap(); let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path()) cmd.current_dir(self.dir.path())

View File

@@ -1,5 +1,7 @@
# Architecture: relicario-core # Architecture: relicario-core
> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)), wire formats (see [../../docs/FORMATS.md](../../docs/FORMATS.md)).
## What this crate is for ## What this crate is for
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the `relicario-core` is the platform-agnostic cryptographic and data-model heart of the
@@ -101,6 +103,38 @@ Pipeline" and "Crate Layout").
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT, auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
Quantization Index Modulation, and crop-recovery extractor. No other module Quantization Index Modulation, and crop-recovery extractor. No other module
imports it; it is consumed only via the public re-export from `lib.rs`. imports it; it is consumed only via the public re-export from `lib.rs`.
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
bytes (salt, params.json, devices.json, manifest, settings, items,
attachments, optional reference JPEG, optional `.git/` tar) in an
XChaCha20-Poly1305 envelope keyed by Argon2id over a user-chosen *backup*
passphrase. The backup key is independent of any vault master key, and
Argon2id parameters are pinned to the v1 values (m=64MiB, t=3, p=4) so a v1
reader doesn't need to negotiate them.
- **`import_lastpass.rs`** — `parse_lastpass_csv` plus `ImportWarning`. Pure
bytes-in / `Vec<Item>`-out LastPass CSV importer: validates the fixed
8-column header, mints fresh IDs and timestamps for each row, downgrades or
skips malformed rows into `ImportWarning`s instead of aborting the import.
Only fatal error is a missing/malformed header.
- **`device.rs`** — Device-identity surface: `DeviceEntry`, `RevokedEntry`,
`generate_keypair`, `sign`, `verify`, `fingerprint`. ed25519 in OpenSSH
format (so private keys are interchangeable with `ssh-keygen`-produced
keys); the same module backs both `.relicario/devices.json` entries and the
server's pre-receive commit-verification hook.
- **`tar_safe.rs`** — `safe_unpack_git_archive` + `DEFAULT_MAX_UNCOMPRESSED`
(1 GiB). Hardened tar reader used by `backup::unpack_backup` for the
bundled `.git/` directory: rejects `..` components, absolute paths, Windows
drive prefixes, symlinks, hardlinks, and any entry whose declared size
(or running total across all entries) exceeds the supplied cap.
- **`recovery_qr.rs`** — `generate_recovery_qr` / `unwrap_recovery_qr` plus
`recovery_qr_to_svg`. Produces a 109-byte XChaCha20-Poly1305 envelope
around the 32-byte image_secret, keyed by Argon2id over a user-chosen
recovery passphrase with the domain-separation prefix
`b"relicario-recovery-v1\0"`. Parameters are pinned at module scope —
changing them invalidates every printed QR — and both salt and nonce are
freshly randomized per call so two QRs printed from the same inputs are
different bytes.
## Invariants & contracts ## Invariants & contracts
@@ -386,11 +420,11 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists `generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
secret; production code is `OsRng` only. secret; production code is `OsRng` only.
- **`ed25519-dalek` is a dependency placeholder.** Listed in - **`ed25519-dalek` is consumed by `device.rs`.** Together with `ssh-key` (for
`Cargo.toml:17` but unused in `src/`. It exists for the future OpenSSH wire encoding) it backs `generate_keypair`, `sign`, and `verify`
device-key surface (`RelicarioError::DeviceKey` is the reserved variant, the same primitives the CLI uses to populate `.relicario/devices.json` and
`error.rs:84-88`); device-key signing currently happens in the server uses to verify pre-receive commit signatures. The corresponding
`relicario-cli` instead. error variant is `RelicarioError::DeviceKey`.
## Test architecture ## Test architecture
@@ -512,3 +546,7 @@ round-trip, and the oversized-image-header rejection path.
source in this crate** (`time.rs:6-8`). Tests that need determinism pass an source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
do not stub `now_unix`. do not stub `now_unix`.
---
**Next:** [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.

View File

@@ -1,8 +1,9 @@
[package] [package]
name = "relicario-core" name = "relicario-core"
version = "0.2.0" version = "0.7.0"
edition = "2021" edition = "2021"
description = "Core library for relicario password manager" description = "Core library for relicario password manager"
license = "GPL-3.0-or-later"
[dependencies] [dependencies]
thiserror = "2" thiserror = "2"
@@ -31,5 +32,6 @@ zstd = { version = "0.13", default-features = false }
tar = { version = "0.4", default-features = false } tar = { version = "0.4", default-features = false }
base64 = "0.22" base64 = "0.22"
csv = "1" csv = "1"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
[dev-dependencies] [dev-dependencies]

View File

@@ -0,0 +1,132 @@
//! RFC 4648 base32 codec, no-padding form, lenient on input.
//!
//! The encoder produces canonical no-padding RFC 4648 output (uppercase ASCII).
//! The decoder is lenient: case-insensitive, optional `=` padding, whitespace
//! anywhere is stripped before decoding.
//!
//! Steam Guard's authenticator uses a different (de-ambiguated) alphabet —
//! see `crate::item_types::totp::STEAM_ALPHABET`. That codec is intentionally
//! NOT routed through this module.
use crate::error::{RelicarioError, Result};
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/// RFC 4648 base32 encoder, no-padding form. Output is uppercase ASCII.
pub fn encode_rfc4648(bytes: &[u8]) -> String {
let mut out = String::new();
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
buffer = (buffer << 8) | (b as u32);
bits += 8;
while bits >= 5 {
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
bits -= 5;
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
}
out
}
/// RFC 4648 base32 decoder, lenient on input.
///
/// Accepts upper- or lower-case letters, optional `=` padding, and whitespace
/// anywhere. Trailing bits less than a full byte are silently discarded
/// (canonical RFC 4648 decode).
pub fn decode_rfc4648_lenient(s: &str) -> Result<Vec<u8>> {
let cleaned: String = s
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_uppercase();
let trimmed = cleaned.trim_end_matches('=');
let mut out: Vec<u8> = Vec::with_capacity(trimmed.len() * 5 / 8);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for ch in trimmed.bytes() {
let idx = ALPHA.iter().position(|&a| a == ch).ok_or_else(|| {
RelicarioError::InvalidBase32(format!("non-alphabet character {:?}", ch as char))
})?;
buffer = (buffer << 5) | (idx as u32);
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buffer >> bits) & 0xff) as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_rfc4648_matches_rfc_test_vectors() {
// RFC 4648 §10 test vectors, no-padding form.
assert_eq!(encode_rfc4648(b""), "");
assert_eq!(encode_rfc4648(b"f"), "MY");
assert_eq!(encode_rfc4648(b"fo"), "MZXQ");
assert_eq!(encode_rfc4648(b"foo"), "MZXW6");
assert_eq!(encode_rfc4648(b"foob"), "MZXW6YQ");
assert_eq!(encode_rfc4648(b"fooba"), "MZXW6YTB");
assert_eq!(encode_rfc4648(b"foobar"), "MZXW6YTBOI");
}
#[test]
fn decode_rfc4648_lenient_inverts_encoder_on_known_vectors() {
let cases: &[(&str, &[u8])] = &[
("", b""),
("MY", b"f"),
("MZXQ", b"fo"),
("MZXW6", b"foo"),
("MZXW6YQ", b"foob"),
("MZXW6YTB", b"fooba"),
("MZXW6YTBOI", b"foobar"),
];
for (s, want) in cases {
assert_eq!(&decode_rfc4648_lenient(s).unwrap()[..], *want);
}
}
#[test]
fn decode_rfc4648_lenient_accepts_lowercase_and_mixed_case() {
assert_eq!(decode_rfc4648_lenient("mzxw6").unwrap(), b"foo");
assert_eq!(decode_rfc4648_lenient("MzXw6yTbOi").unwrap(), b"foobar");
}
#[test]
fn decode_rfc4648_lenient_strips_optional_padding() {
assert_eq!(decode_rfc4648_lenient("MY======").unwrap(), b"f");
assert_eq!(decode_rfc4648_lenient("MZXW6===").unwrap(), b"foo");
assert_eq!(decode_rfc4648_lenient("MZXW6YTBOI======").unwrap(), b"foobar");
}
#[test]
fn decode_rfc4648_lenient_strips_whitespace_anywhere() {
assert_eq!(decode_rfc4648_lenient(" MZXW 6YTB OI ").unwrap(), b"foobar");
assert_eq!(decode_rfc4648_lenient("MZXW\n6YTB\tOI").unwrap(), b"foobar");
}
#[test]
fn decode_rfc4648_lenient_rejects_non_alphabet_chars() {
assert!(matches!(
decode_rfc4648_lenient("MY1"),
Err(RelicarioError::InvalidBase32(_))
));
assert!(decode_rfc4648_lenient("???").is_err());
assert!(decode_rfc4648_lenient("MZ!XW").is_err());
}
#[test]
fn encode_decode_round_trips_arbitrary_bytes() {
let bytes: Vec<u8> = (0u8..=255).collect();
let encoded = encode_rfc4648(&bytes);
assert_eq!(decode_rfc4648_lenient(&encoded).unwrap(), bytes);
}
}

View File

@@ -243,6 +243,23 @@ pub fn derive_master_key(
Ok(output) Ok(output)
} }
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
/// allowing callers to apply their own domain separation before KDF.
pub fn derive_master_key_raw(
input: &[u8],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(input, salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -408,7 +425,7 @@ mod tests {
blob.extend_from_slice(&[0u8; 16]); blob.extend_from_slice(&[0u8; 16]);
let key = Zeroizing::new([0u8; 32]); let key = Zeroizing::new([0u8; 32]);
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt"); let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
match err { match err {
RelicarioError::UnsupportedFormatVersion { found, expected } => { RelicarioError::UnsupportedFormatVersion { found, expected } => {
assert_eq!(found, 0x01); assert_eq!(found, 0x01);

View File

@@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res
Ok(verifying_key.verify(data, &signature).is_ok()) Ok(verifying_key.verify(data, &signature).is_ok())
} }
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
/// `SHA256:<43-char base64 without padding>`.
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
use ssh_key::HashAlg;
let public = PublicKey::from_openssh(public_key_openssh)
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
Ok(public.fingerprint(HashAlg::Sha256).to_string())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -132,4 +142,27 @@ mod tests {
let sig = sign(&private, b"hello").unwrap(); let sig = sign(&private, b"hello").unwrap();
assert!(!verify(&other_public, b"hello", &sig).unwrap()); assert!(!verify(&other_public, b"hello", &sig).unwrap());
} }
#[test]
fn fingerprint_matches_ssh_keygen_format() {
let (_, public) = generate_keypair().unwrap();
let fp = fingerprint(&public).unwrap();
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
let body = fp.strip_prefix("SHA256:").unwrap();
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
}
#[test]
fn fingerprint_is_deterministic() {
let (_, public) = generate_keypair().unwrap();
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
}
#[test]
fn fingerprint_differs_per_key() {
let (_, p1) = generate_keypair().unwrap();
let (_, p2) = generate_keypair().unwrap();
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
}
} }

View File

@@ -51,6 +51,10 @@ pub enum RelicarioError {
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")] #[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
BackupSchemaMismatch { found: u32, expected: u32 }, BackupSchemaMismatch { found: u32, expected: u32 },
/// An error during backup restore (e.g., tar safety validation failure).
#[error("backup restore: {0}")]
BackupRestore(String),
/// CSV header doesn't match the LastPass column layout. /// CSV header doesn't match the LastPass column layout.
#[error("unrecognized CSV header — expected LastPass export format ({0})")] #[error("unrecognized CSV header — expected LastPass export format ({0})")]
ImportCsvHeader(String), ImportCsvHeader(String),
@@ -115,6 +119,21 @@ pub enum RelicarioError {
/// immediately. Use TOTP instead. /// immediately. Use TOTP instead.
#[error("HOTP is not supported: counter persistence requires vault save after each use")] #[error("HOTP is not supported: counter persistence requires vault save after each use")]
HotpNotSupported, HotpNotSupported,
/// Recovery QR generation or parsing failed.
#[error("recovery QR: {0}")]
RecoveryQr(String),
/// Base32 decoding failed (non-alphabet character or other malformed
/// input). Emitted by [`crate::base32::decode_rfc4648_lenient`] and any
/// typed wrappers that delegate to it.
#[error("invalid base32: {0}")]
InvalidBase32(String),
/// Card-expiry month/year string failed to parse. Emitted by
/// [`crate::time::MonthYear::parse`].
#[error("invalid month/year: {0}")]
InvalidMonthYear(String),
} }
/// Crate-wide result alias, reducing boilerplate in function signatures. /// Crate-wide result alias, reducing boilerplate in function signatures.

View File

@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret. /// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
/// ceil(256 / 12) = 22 blocks per copy. /// ceil(256 / 12) = 22 blocks per copy.
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
/// Mid-frequency DCT coefficient positions for embedding, specified as /// Mid-frequency DCT coefficient positions for embedding, specified as
/// (row, col) indices into the 8x8 DCT coefficient matrix. /// (row, col) indices into the 8x8 DCT coefficient matrix.
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
return None; return None;
} }
let mut block = [[0.0f64; 8]; 8]; let mut block = [[0.0f64; 8]; 8];
for row in 0..8 { for (row, block_row) in block.iter_mut().enumerate() {
for col in 0..8 { for (col, cell) in block_row.iter_mut().enumerate() {
block[row][col] = y.get(px + col, py + row); *cell = y.get(px + col, py + row);
} }
} }
Some(block) Some(block)
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
let start_x = region.x_offset + bx * BLOCK_SIZE; let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE;
for row in 0..8 { for (row, block_row) in block.iter().enumerate() {
for col in 0..8 { for (col, &cell) in block_row.iter().enumerate() {
y.set(start_x + col, start_y + row, block[row][col]); y.set(start_x + col, start_y + row, cell);
} }
} }
} }
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0. /// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
fn dct1d(input: &[f64; 8]) -> [f64; 8] { fn dct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8]; let mut output = [0.0f64; 8];
for k in 0..8 { for (k, out_k) in output.iter_mut().enumerate() {
let ck = if k == 0 { let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt() (1.0 / 8.0_f64).sqrt()
} else { } else {
(2.0 / 8.0_f64).sqrt() (2.0 / 8.0_f64).sqrt()
}; };
let mut sum = 0.0; let mut sum = 0.0;
for i in 0..8 { for (i, &x) in input.iter().enumerate() {
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
} }
output[k] = ck * sum; *out_k = ck * sum;
} }
output output
} }
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16) /// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
fn idct1d(input: &[f64; 8]) -> [f64; 8] { fn idct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8]; let mut output = [0.0f64; 8];
for i in 0..8 { for (i, out_i) in output.iter_mut().enumerate() {
let mut sum = 0.0; let mut sum = 0.0;
for k in 0..8 { for (k, &x) in input.iter().enumerate() {
let ck = if k == 0 { let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt() (1.0 / 8.0_f64).sqrt()
} else { } else {
(2.0 / 8.0_f64).sqrt() (2.0 / 8.0_f64).sqrt()
}; };
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos(); sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
} }
output[i] = sum; *out_i = sum;
} }
output output
} }
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
/// ///
/// Pads the last byte with zeros if the bit count is not a multiple of 8. /// Pads the last byte with zeros if the bit count is not a multiple of 8.
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> { fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
for chunk in bits.chunks(8) { for chunk in bits.chunks(8) {
let mut byte = 0u8; let mut byte = 0u8;
for (i, &bit) in chunk.iter().enumerate() { for (i, &bit) in chunk.iter().enumerate() {

View File

@@ -158,8 +158,8 @@ fn map_row(
let totp = if totp_raw.is_empty() { let totp = if totp_raw.is_empty() {
None None
} else { } else {
match decode_base32_totp(totp_raw) { match crate::base32::decode_rfc4648_lenient(totp_raw) {
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { Ok(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
secret: Zeroizing::new(bytes), secret: Zeroizing::new(bytes),
algorithm: crate::item_types::TotpAlgorithm::Sha1, algorithm: crate::item_types::TotpAlgorithm::Sha1,
digits: 6, digits: 6,
@@ -196,25 +196,3 @@ fn map_row(
(Some(item), warning) (Some(item), warning)
} }
/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive,
/// padding optional. Returns None if the input contains any non-alphabet
/// character (after upper-casing). Used by the LastPass importer.
fn decode_base32_totp(secret: &str) -> Option<Vec<u8>> {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
if upper.is_empty() { return None; }
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for ch in upper.bytes() {
let idx = ALPHA.iter().position(|&a| a == ch)?;
buffer = (buffer << 5) | (idx as u32);
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(out)
}

View File

@@ -244,7 +244,7 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()), FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
FieldValue::Totp(cfg) => { FieldValue::Totp(cfg) => {
// Store the base32-encoded secret string for human-recognizability. // Store the base32-encoded secret string for human-recognizability.
let s = base32_encode(&cfg.secret); let s = crate::base32::encode_rfc4648(&cfg.secret);
Zeroizing::new(s) Zeroizing::new(s)
} }
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())), _ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
@@ -252,28 +252,6 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
Ok(s) Ok(s)
} }
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
fn base32_encode(bytes: &[u8]) -> String {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = String::new();
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
buffer = (buffer << 8) | (b as u32);
bits += 8;
while bits >= 5 {
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
bits -= 5;
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -10,6 +10,9 @@ use crate::error::{RelicarioError, Result};
/// Steam Mobile Authenticator's 5-character output alphabet. /// Steam Mobile Authenticator's 5-character output alphabet.
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z). /// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
///
/// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see [`crate::base32`]
/// for the standard implementation.
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY"; const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -21,6 +24,14 @@ pub struct TotpCore {
pub label: Option<String>, pub label: Option<String>,
} }
impl TotpConfig {
/// Decode a base32-encoded TOTP secret (RFC 4648, lenient input) into the
/// canonical `Zeroizing<Vec<u8>>` form used in [`Self::secret`].
pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>> {
Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpConfig { pub struct TotpConfig {
/// Raw bytes of the TOTP secret (decoded from base32 when imported). /// Raw bytes of the TOTP secret (decoded from base32 when imported).
@@ -52,18 +63,15 @@ pub enum TotpAlgorithm {
Sha512, Sha512,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum TotpKind { pub enum TotpKind {
#[default]
Totp, Totp,
Hotp { counter: u64 }, Hotp { counter: u64 },
Steam, Steam,
} }
impl Default for TotpKind {
fn default() -> Self { TotpKind::Totp }
}
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp. /// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
/// ///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`. /// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.

View File

@@ -14,6 +14,8 @@
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and //! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02. //! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`. //! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
//! - [`base32`] — RFC 4648 base32 codec used for TOTP secret encode/decode.
//! - [`mime`] — Filename-extension → MIME-type guess for attachment storage.
//! - [`time`] — unix-seconds + `MonthYear` for card expiries. //! - [`time`] — unix-seconds + `MonthYear` for card expiries.
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the //! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
//! `ItemCore`/`ItemType` enums. //! `ItemCore`/`ItemType` enums.
@@ -46,6 +48,10 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
pub mod ids; pub mod ids;
pub use ids::{AttachmentId, FieldId, ItemId}; pub use ids::{AttachmentId, FieldId, ItemId};
pub mod base32;
pub mod mime;
pub mod time; pub mod time;
pub use time::{now_unix, MonthYear}; pub use time::{now_unix, MonthYear};
@@ -85,4 +91,15 @@ pub mod import_lastpass;
pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
pub mod device; pub mod device;
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
pub mod recovery_qr;
pub use recovery_qr::{
generate_recovery_qr, generate_recovery_qr_with_params,
recovery_qr_to_svg,
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
RecoveryQrPayload,
};

View File

@@ -0,0 +1,49 @@
//! Tiny extension → MIME map for the small set of file types Relicario
//! attaches today. Unknown extensions fall back to `application/octet-stream`.
/// Guess a MIME type from a filename's extension. Case-insensitive.
pub fn guess_for_extension(filename: &str) -> &'static str {
let lower = filename.to_ascii_lowercase();
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"txt" => "text/plain",
"json" => "application/json",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_extensions_match() {
assert_eq!(guess_for_extension("doc.pdf"), "application/pdf");
assert_eq!(guess_for_extension("photo.png"), "image/png");
assert_eq!(guess_for_extension("photo.jpg"), "image/jpeg");
assert_eq!(guess_for_extension("photo.jpeg"), "image/jpeg");
assert_eq!(guess_for_extension("notes.txt"), "text/plain");
assert_eq!(guess_for_extension("data.json"), "application/json");
}
#[test]
fn extension_match_is_case_insensitive() {
assert_eq!(guess_for_extension("doc.PDF"), "application/pdf");
assert_eq!(guess_for_extension("photo.JPEG"), "image/jpeg");
}
#[test]
fn unknown_or_missing_extension_falls_back() {
assert_eq!(guess_for_extension("unknown.xyz"), "application/octet-stream");
assert_eq!(guess_for_extension("noextension"), "application/octet-stream");
assert_eq!(guess_for_extension(""), "application/octet-stream");
}
#[test]
fn uses_extension_after_last_dot() {
assert_eq!(guess_for_extension("path/to/file.pdf"), "application/pdf");
assert_eq!(guess_for_extension("archive.tar.gz"), "application/octet-stream");
}
}

View File

@@ -0,0 +1,284 @@
//! Recovery-QR encoding for the reference image_secret.
//!
//! ## What this module produces
//!
//! Given a user-chosen recovery passphrase and the 32-byte image_secret
//! (extracted from the reference JPEG via [`crate::imgsecret::extract`]), this
//! module produces a 109-byte sealed payload that — at recovery time, with the
//! same passphrase — yields the original image_secret back. The payload is
//! intended to be rendered as a QR v40 EcLevel::M SVG via [`recovery_qr_to_svg`]
//! and printed on paper, so a user who loses access to the reference JPEG can
//! still unlock their vault if they remember the recovery passphrase.
//!
//! ## Why the format is structured this way
//!
//! The payload is an XChaCha20-Poly1305 envelope around the image_secret. The
//! AEAD key (the "wrap key") is derived by Argon2id from a domain-separated
//! input:
//!
//! ```text
//! kdf_input = b"relicario-recovery-v1\0"
//! || u64_be(len(nfc(passphrase)))
//! || nfc(passphrase)
//! wrap_key = Argon2id(kdf_input, kdf_salt, RECOVERY_PRODUCTION_PARAMS) -> 32 bytes
//! ```
//!
//! The `b"relicario-recovery-v1\0"` prefix is **domain separation**: it
//! guarantees that even if the user reuses their vault passphrase as their
//! recovery passphrase, the wrap key derived here can never collide with a
//! vault master key derived in [`crate::crypto::derive_master_key`] (which has
//! a different input shape entirely — passphrase + image_secret, no prefix).
//! Without this prefix, a determined attacker who somehow recovered a wrap key
//! could try it as a master key and vice versa.
//!
//! Both `kdf_salt` and `wrap_nonce` are freshly randomized per call to
//! [`generate_recovery_qr`], so two QRs printed from the same passphrase and
//! image_secret are different bytes — the printed QR does not leak whether
//! the user has printed others before.
//!
//! ## Parameter-pinning rationale
//!
//! The Argon2id parameters used here are NOT [`crate::crypto::KdfParams::default`].
//! They are pinned in `RECOVERY_PRODUCTION_PARAMS` at the value
//! `KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }` — the same values
//! the default happens to have *today*, but deliberately re-stated rather than
//! referenced. This is because `KdfParams::default()` may evolve as we re-tune
//! Argon2 cost for newer hardware, and a recovery QR printed on paper has no
//! way to negotiate parameters at decode time. Changing the pinned values here
//! would silently invalidate every recovery QR a user has ever printed under
//! the previous parameter set. The const lives at module scope so the
//! "pinned, do not change once shipped" property is visible at every use site.
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
use rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
// Recovery QR payload — 109 bytes total:
//
// byte field length
// ------ -------------- ------
// 0..4 MAGIC = "RREC" 4
// 4..5 VERSION = 0x01 1
// 5..37 kdf_salt 32 (random per QR)
// 37..61 wrap_nonce 24 (random per QR)
// 61..109 ciphertext 48 (32 image_secret + 16 AEAD tag)
// ------------------------------
// total 109
const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
// Static assertion that the documented layout above and the PAYLOAD_LEN
// constant cannot drift apart. If a future edit changes one without the other,
// this fails to compile.
const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48);
// Named slice ranges derived from the layout offsets above. Used by
// `unwrap_recovery_qr_with_params` so the byte-position arithmetic at the
// parse site is self-documenting.
const KDF_SALT_RANGE: std::ops::Range<usize> = 5..37;
const WRAP_NONCE_RANGE: std::ops::Range<usize> = 37..61;
const CIPHERTEXT_RANGE: std::ops::Range<usize> = 61..109;
/// Pinned recovery-QR Argon2id parameters. Re-states `KdfParams::default()`'s
/// values rather than referencing them, because a recovery QR printed under
/// one parameter set cannot be decoded under another. **Once shipped, these
/// values MUST NOT change** — doing so silently invalidates every previously
/// printed QR. See the module header for full rationale.
const RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams {
argon2_m: 65536,
argon2_t: 3,
argon2_p: 4,
};
/// A sealed 109-byte recovery payload. The bytes are an opaque package — they
/// only become useful when fed back through [`unwrap_recovery_qr`] together
/// with the recovery passphrase that was used to produce them.
///
/// [`as_bytes`](Self::as_bytes) is the only accessor. The bytes are designed to
/// travel as a single unit; the supported transport is rendering via
/// [`recovery_qr_to_svg`] and printing the QR on paper, but a hex string
/// (sneakernet-friendly) works equally well as long as the full 109 bytes
/// are preserved.
pub struct RecoveryQrPayload {
bytes: [u8; PAYLOAD_LEN],
}
impl RecoveryQrPayload {
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
&self.bytes
}
}
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
let nfc: String = passphrase.nfc().collect();
let nfc_bytes = nfc.as_bytes();
let prefix = b"relicario-recovery-v1\0";
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
input.extend_from_slice(prefix);
// length-prefix on nfc_bytes mirrors crypto::derive_master_key (audit H1)
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
input.extend_from_slice(nfc_bytes);
input
}
fn derive_wrap_key(
passphrase: &str,
kdf_salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let input = recovery_kdf_input(passphrase);
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}
/// Produce a sealed [`RecoveryQrPayload`] from the recovery passphrase and the
/// 32-byte image_secret.
///
/// # Inputs
///
/// - `passphrase`: the user's recovery passphrase (UTF-8). Independent of the
/// vault passphrase, but the user may reuse them — the
/// `b"relicario-recovery-v1\0"` domain-separation prefix in the KDF input
/// guarantees the wrap key still cannot collide with a vault master key.
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG
/// via [`crate::imgsecret::extract`].
///
/// # Output
///
/// A [`RecoveryQrPayload`] whose 109 bytes encode `MAGIC || VERSION || kdf_salt
/// || wrap_nonce || ciphertext`. Both `kdf_salt` and `wrap_nonce` are freshly
/// drawn from `OsRng` on every call, so two payloads generated from the same
/// `(passphrase, image_secret)` pair are distinct bit-for-bit. The printed QR
/// therefore does not reveal that the user has printed others before.
///
/// To render the payload as a printable SVG, see [`recovery_qr_to_svg`].
///
/// # Errors
///
/// Returns [`RelicarioError::RecoveryQr`] if the AEAD wrap fails (extremely
/// unlikely in practice — this can only happen if the cipher implementation
/// itself errors, not on user input).
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
generate_recovery_qr_with_params(passphrase, image_secret, &RECOVERY_PRODUCTION_PARAMS)
}
#[doc(hidden)]
pub fn generate_recovery_qr_with_params(
passphrase: &str,
image_secret: &[u8; 32],
params: &KdfParams,
) -> Result<RecoveryQrPayload> {
let mut kdf_salt = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
let mut wrap_nonce = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
let mut bytes = [0u8; PAYLOAD_LEN];
let mut pos = 0;
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
bytes[pos] = VERSION; pos += 1;
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
bytes[pos..pos+48].copy_from_slice(&ciphertext);
Ok(RecoveryQrPayload { bytes })
}
/// Decode a recovery payload back into the original 32-byte image_secret.
///
/// # Inputs
///
/// - `payload_bytes`: the 109 bytes produced by [`generate_recovery_qr`] (after
/// the QR has been scanned, or the hex transcribed and decoded).
/// - `passphrase`: the recovery passphrase that was used at generate time.
///
/// # Output
///
/// The recovered image_secret as `Zeroizing<[u8; 32]>` — the wrapper ensures
/// the secret is wiped from memory when the binding goes out of scope, so a
/// caller that immediately feeds it into [`crate::crypto::derive_master_key`]
/// and then drops it never leaves a copy in process memory longer than
/// strictly necessary.
///
/// # Errors
///
/// - [`RelicarioError::RecoveryQr`] for **format** problems: wrong length,
/// bad magic, unsupported version byte. These come from inspecting the
/// bytes themselves, before any cryptographic work, so they leak nothing
/// about whether the passphrase is right.
/// - [`RelicarioError::Decrypt`] for **AEAD** failure — wrong passphrase
/// (wrong wrap key) **or** a payload tampered after the fact. The two
/// cases are deliberately not distinguished, mirroring the same
/// non-distinguishing rejection as [`crate::crypto::decrypt`] (audit M4):
/// a Poly1305 tag failure cannot, in principle, leak which bytes were
/// wrong, and the API surface preserves that property.
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &RECOVERY_PRODUCTION_PARAMS)
}
#[doc(hidden)]
pub fn unwrap_recovery_qr_with_params(
payload_bytes: &[u8],
passphrase: &str,
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
if payload_bytes.len() != PAYLOAD_LEN {
return Err(RelicarioError::RecoveryQr(
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
));
}
if &payload_bytes[0..4] != MAGIC {
return Err(RelicarioError::RecoveryQr("bad magic".into()));
}
if payload_bytes[4] != VERSION {
return Err(RelicarioError::RecoveryQr(
format!("unsupported version 0x{:02x}", payload_bytes[4])
));
}
let kdf_salt: &[u8; 32] = payload_bytes[KDF_SALT_RANGE].try_into().expect("slice length validated above");
let wrap_nonce = &payload_bytes[WRAP_NONCE_RANGE];
let ciphertext = &payload_bytes[CIPHERTEXT_RANGE];
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(&plaintext);
Ok(out)
}
/// Render a [`RecoveryQrPayload`] as a printable QR-code SVG string.
///
/// The QR is encoded at **version 40** (the largest standard symbol, 177×177
/// modules) at **error-correction level M** (~15% recoverable), with a
/// minimum rendered dimension of **140×140** SVG units. The 109-byte payload
/// fits comfortably inside v40 at level M — there is significant
/// error-correction headroom left over, which is the point: the QR is
/// expected to live on paper (where smudges, folds, and fading are normal)
/// and must still scan years later.
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
use qrcode::{QrCode, EcLevel};
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
code.render::<qrcode::render::svg::Color>()
.min_dimensions(140, 140)
.build()
}

View File

@@ -0,0 +1,138 @@
//! Safe tar unpacking for backup restore.
//!
//! The standard `tar::Archive::unpack` has no guards against path traversal,
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
//! with `safe_unpack_git_archive`, which validates every entry before returning
//! `(relative_path, bytes)` pairs to the caller.
use std::io::Read;
use std::path::{Component, PathBuf};
use tar::EntryType;
use crate::error::{RelicarioError, Result};
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
/// regular files only.
///
/// # Errors
///
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
///
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
/// - An entry is a symlink or hardlink — "symlink/link rejected".
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
pub fn safe_unpack_git_archive(
tar_bytes: &[u8],
max_uncompressed_bytes: u64,
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
let mut archive = tar::Archive::new(tar_bytes);
let entries = archive
.entries()
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let mut cumulative: u64 = 0;
for entry in entries {
let mut entry = entry.map_err(|e| {
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
})?;
let header = entry.header();
let entry_type = header.entry_type();
// Reject symlinks and hardlinks.
match entry_type {
EntryType::Symlink => {
return Err(RelicarioError::BackupRestore(
"symlink entry rejected".to_string(),
));
}
EntryType::Link => {
return Err(RelicarioError::BackupRestore(
"hardlink entry rejected".to_string(),
));
}
EntryType::Directory => {
// Directories are implicit — skip without reading body.
continue;
}
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
// These are normal file types; fall through to path checks.
}
_ => {
return Err(RelicarioError::BackupRestore(format!(
"unexpected entry type: {:?}",
entry_type
)));
}
}
// Validate the path.
let path = entry.path().map_err(|e| {
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
})?;
let path = path.into_owned();
for component in path.components() {
match component {
Component::ParentDir => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry contains '..' component".to_string(),
));
}
Component::RootDir => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry has absolute path".to_string(),
));
}
Component::Prefix(_) => {
return Err(RelicarioError::BackupRestore(
"path traversal blocked: entry has Windows drive prefix".to_string(),
));
}
Component::Normal(_) | Component::CurDir => {
// Acceptable components.
}
}
}
// Check declared size before reading body.
let claimed = header.size().map_err(|e| {
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
})?;
if claimed > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
)));
}
let new_total = cumulative.saturating_add(claimed);
if new_total > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
)));
}
// Read the file body.
let mut body = Vec::with_capacity(claimed as usize);
entry.read_to_end(&mut body).map_err(|e| {
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
})?;
cumulative += body.len() as u64;
result.push((path, body));
}
Ok(result)
}

View File

@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::{RelicarioError, Result};
/// Current Unix timestamp in seconds. /// Current Unix timestamp in seconds.
pub fn now_unix() -> i64 { pub fn now_unix() -> i64 {
chrono::Utc::now().timestamp() chrono::Utc::now().timestamp()
@@ -15,15 +17,37 @@ pub struct MonthYear {
} }
impl MonthYear { impl MonthYear {
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> { pub fn new(month: u8, year: u16) -> std::result::Result<Self, &'static str> {
if !(1..=12).contains(&month) { if !(1..=12).contains(&month) {
return Err("month must be 1..=12"); return Err("month must be 1..=12");
} }
if year < 2000 || year > 2099 { if !(2000..=2099).contains(&year) {
return Err("year must be 2000..=2099"); return Err("year must be 2000..=2099");
} }
Ok(Self { month, year }) Ok(Self { month, year })
} }
/// Parse a card-expiry string. Accepts `MM/YYYY`, `MM-YYYY`, and `MM/YY`
/// (two-digit year is taken as 20YY).
pub fn parse(s: &str) -> Result<Self> {
let invalid = |detail: String| RelicarioError::InvalidMonthYear(detail);
let (m_str, y_str) = s
.split_once(['/', '-'])
.ok_or_else(|| invalid(format!("expected MM/YYYY, got {s:?}")))?;
let month: u8 = m_str
.parse()
.map_err(|_| invalid(format!("bad month {m_str:?}")))?;
let year: u16 = if y_str.len() == 2 {
2000 + y_str
.parse::<u16>()
.map_err(|_| invalid(format!("bad 2-digit year {y_str:?}")))?
} else {
y_str
.parse()
.map_err(|_| invalid(format!("bad year {y_str:?}")))?
};
Self::new(month, year).map_err(|e| invalid(e.into()))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -60,4 +84,30 @@ mod tests {
let parsed: MonthYear = serde_json::from_str(&json).unwrap(); let parsed: MonthYear = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, my); assert_eq!(parsed, my);
} }
#[test]
fn parse_accepts_mm_slash_yyyy_and_mm_dash_yyyy() {
assert_eq!(MonthYear::parse("01/2026").unwrap(), MonthYear::new(1, 2026).unwrap());
assert_eq!(MonthYear::parse("12/2099").unwrap(), MonthYear::new(12, 2099).unwrap());
assert_eq!(MonthYear::parse("07-2030").unwrap(), MonthYear::new(7, 2030).unwrap());
}
#[test]
fn parse_accepts_mm_slash_yy() {
assert_eq!(MonthYear::parse("01/26").unwrap(), MonthYear::new(1, 2026).unwrap());
assert_eq!(MonthYear::parse("12/99").unwrap(), MonthYear::new(12, 2099).unwrap());
}
#[test]
fn parse_rejects_malformed() {
assert!(matches!(
MonthYear::parse("garbage"),
Err(RelicarioError::InvalidMonthYear(_))
));
assert!(MonthYear::parse("13/2026").is_err()); // bad month
assert!(MonthYear::parse("01/1999").is_err()); // pre-2000
assert!(MonthYear::parse("01/2100").is_err()); // post-2099
assert!(MonthYear::parse("/2026").is_err()); // empty month
assert!(MonthYear::parse("01/").is_err()); // empty year
}
} }

View File

@@ -0,0 +1,60 @@
use relicario_core::{
crypto::KdfParams,
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
};
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
fn test_secret() -> [u8; 32] {
let mut s = [0u8; 32];
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
s
}
#[test]
fn roundtrip_recovers_image_secret() {
let passphrase = "correct-horse-battery-staple";
let secret = test_secret();
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
.expect("generate ok");
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
.expect("unwrap ok");
assert_eq!(recovered.as_ref(), &secret);
}
#[test]
fn wrong_passphrase_fails_decrypt() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
.expect("generate ok");
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
assert!(result.is_err());
}
#[test]
fn payload_is_109_bytes() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
assert_eq!(payload.as_bytes().len(), 109);
}
#[test]
fn svg_output_is_non_empty_xml() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
let svg = recovery_qr_to_svg(&payload);
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
assert!(!svg.is_empty());
}
#[test]
fn bad_magic_returns_error() {
let mut bad = [0u8; 109];
bad[0..4].copy_from_slice(b"NOPE");
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
assert!(result.is_err());
}

View File

@@ -0,0 +1,187 @@
use std::path::PathBuf;
use tar::{Builder, Header, EntryType};
use relicario_core::safe_unpack_git_archive;
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
/// manually to produce truly malicious archives.
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
let mut buf = vec![0u8; 512]; // one header block
// Bytes 0-99: name field (null-padded)
let name_len = raw_path.len().min(100);
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
// Bytes 100-107: mode = "0000644\0"
buf[100..108].copy_from_slice(b"0000644\0");
// Bytes 108-115: uid
buf[108..116].copy_from_slice(b"0000000\0");
// Bytes 116-123: gid
buf[116..124].copy_from_slice(b"0000000\0");
// Bytes 124-135: size (octal, 11 digits + null)
let size_str = format!("{:011o}\0", content.len());
buf[124..136].copy_from_slice(size_str.as_bytes());
// Bytes 136-147: mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// Bytes 148-155: checksum placeholder (spaces during compute)
buf[148..156].copy_from_slice(b" ");
// Byte 156: typeflag = '0' (regular file)
buf[156] = b'0';
// Bytes 257-262: magic "ustar\0"
buf[257..263].copy_from_slice(b"ustar\0");
// Bytes 263-264: version "00"
buf[263..265].copy_from_slice(b"00");
// Compute checksum (sum of all bytes, checksum field treated as spaces).
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
// Append padded content blocks.
let mut out = buf;
if !content.is_empty() {
out.extend_from_slice(content);
// Pad to 512-byte boundary.
let remainder = content.len() % 512;
if remainder != 0 {
out.extend(vec![0u8; 512 - remainder]);
}
}
// Two zero blocks = end-of-archive.
out.extend(vec![0u8; 1024]);
out
}
/// Build a tar with a raw symlink entry (typeflag = '2').
fn raw_symlink_tar() -> Vec<u8> {
let mut buf = vec![0u8; 512];
// name
buf[..9].copy_from_slice(b"evil_link");
// mode
buf[100..108].copy_from_slice(b"0000755\0");
// uid/gid
buf[108..116].copy_from_slice(b"0000000\0");
buf[116..124].copy_from_slice(b"0000000\0");
// size = 0
buf[124..136].copy_from_slice(b"00000000000\0");
// mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// checksum placeholder
buf[148..156].copy_from_slice(b" ");
// typeflag = '2' (symlink)
buf[156] = b'2';
// linkname
let target = b"/etc/passwd";
buf[157..157 + target.len()].copy_from_slice(target);
// magic
buf[257..263].copy_from_slice(b"ustar\0");
buf[263..265].copy_from_slice(b"00");
// Compute checksum.
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
let mut out = buf;
out.extend(vec![0u8; 1024]); // end-of-archive
out
}
fn build_normal_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = b"hello";
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
.unwrap();
builder.finish().unwrap();
}
buf
}
fn build_oversize_tar() -> Vec<u8> {
// Actual 2048-byte body; test will use cap=1024
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = vec![0u8; 2048];
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "bigfile.bin", content.as_slice())
.unwrap();
builder.finish().unwrap();
}
buf
}
#[test]
fn restore_rejects_path_traversal() {
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains(".."),
"got: {msg}"
);
}
#[test]
fn restore_rejects_absolute_path() {
// Craft a tar with "/etc/escaped.txt" using raw bytes.
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains("absolute"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_symlink() {
let bytes = raw_symlink_tar();
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("symlink") || msg.contains("link"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_size_bomb() {
let bytes = build_oversize_tar(); // actual 2048-byte entry
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
let msg = format!("{err:#}");
assert!(
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
"got: {msg}"
);
}
#[test]
fn restore_accepts_normal_files() {
let buf = build_normal_tar();
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
assert_eq!(entries[0].1, b"hello");
}

View File

@@ -2,6 +2,8 @@
name = "relicario-server" name = "relicario-server"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Pre-receive Git hook for relicario password manager"
license = "GPL-3.0-or-later"
[dependencies] [dependencies]
relicario-core = { path = "../relicario-core" } relicario-core = { path = "../relicario-core" }
@@ -9,3 +11,10 @@ anyhow = "1"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tempfile = "3"
regex = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

View File

@@ -1,5 +1,6 @@
//! relicario-server -- pre-receive hook for signature verification. //! relicario-server -- pre-receive hook for signature verification.
use std::fs;
use std::process::Command; use std::process::Command;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -34,49 +35,120 @@ fn main() -> Result<()> {
} }
fn verify_commit(commit: &str) -> Result<()> { fn verify_commit(commit: &str) -> Result<()> {
// Get devices.json at this commit
let devices_json = match git_show(commit, ".relicario/devices.json") { let devices_json = match git_show(commit, ".relicario/devices.json") {
Ok(json) => json, Ok(json) => json,
Err(_) => { Err(_) => {
// No devices.json yet -- bootstrap mode, allow unsigned eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
return Ok(()); return Ok(());
} }
}; };
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json) let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
.context("parse devices.json")?; .context("parse devices.json")?;
// Bootstrap: if devices.json is empty, allow unsigned
if devices.is_empty() {
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
return Ok(());
}
// Get revoked.json (may not exist)
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json") let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
.ok() .ok()
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(); .unwrap_or_default();
// Get commit signature // True bootstrap: no devices ever registered and none revoked.
if devices.is_empty() && revoked.is_empty() {
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
return Ok(());
}
// Build temp allowed-signers file from registered devices.
let tmp = tempfile::tempdir().context("create tempdir")?;
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for d in &devices {
allowed_body.push_str("relicario ");
allowed_body.push_str(d.public_key.trim());
allowed_body.push('\n');
}
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
// Run git verify-commit --raw. Capture both exit code and stderr.
// NOTE: we do NOT short-circuit on non-zero exit here because even for
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
let output = Command::new("git") let output = Command::new("git")
.args(["verify-commit", "--raw", commit]) .args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output() .output()
.context("git verify-commit")?; .context("git verify-commit")?;
// Check if signed
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
eprintln!("REJECT: commit {} is not signed by a registered device", commit); // Parse the SHA-256 fingerprint from stderr.
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
Some(m) => m.as_str().to_string(),
None => {
// No fingerprint in stderr = unsigned or completely malformed signature.
eprintln!(
"REJECT: commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Build fingerprint → entry maps.
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
std::collections::HashMap::new();
for d in &devices {
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
device_by_fp.insert(fp, d);
}
}
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
std::collections::HashMap::new();
for r in &revoked {
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
revoked_by_fp.insert(fp, r);
}
}
// Get committer date (NOT author date).
let ct_out = Command::new("git")
.args(["show", "-s", "--format=%ct", commit])
.output()
.context("git show committer date")?;
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
.trim()
.parse()
.context("parse committer timestamp")?;
// Check revocation FIRST (revoked entries may not be in devices anymore).
if let Some(r) = revoked_by_fp.get(&signing_fp) {
if committer_ts >= r.revoked_at {
eprintln!(
"REJECT: commit {commit} — signed by revoked device '{}' \
(committer ts {committer_ts} >= revoked_at {})",
r.name, r.revoked_at
);
std::process::exit(1);
}
// Historical commit: committer_ts < revoked_at → was valid when signed.
eprintln!(
"OK: commit {commit} — historical commit signed by '{}' before revocation",
r.name
);
return Ok(());
}
// Not revoked — must be in active devices.
if !device_by_fp.contains_key(&signing_fp) {
eprintln!(
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
);
std::process::exit(1); std::process::exit(1);
} }
// Ensure the signing key is not revoked. eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
// The allowed-signers file approach means git verify-commit already checks
// against the list; we additionally guard against revoked.json entries.
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
eprintln!("OK: commit {} verified", commit);
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,230 @@
//! Acceptance tests for `relicario-server verify-commit`.
//!
//! Four scenarios from audit S1:
//! 1. Registered non-revoked key → exit 0
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
use tempfile::TempDir;
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
let priv_path = dir.join(format!("{name}.key"));
let pub_path = dir.join(format!("{name}.pub"));
fs::write(&priv_path, priv_pem.as_str()).unwrap();
fs::write(&pub_path, &pub_line).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
}
(priv_path, pub_path, pub_line)
}
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
let mut cmd = Command::new("git");
cmd.current_dir(repo).args(args);
for (k, v) in extra_env {
cmd.env(k, v);
}
let status = cmd.status().expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn init_repo(repo: &Path) {
git(repo, &["init", "-q", "-b", "main"], &[]);
git(repo, &["config", "user.email", "test@test"], &[]);
git(repo, &["config", "user.name", "test"], &[]);
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
}
fn sign_commit(
repo: &Path,
signing_key: &Path,
allowed_signers: &Path,
committer_unix: i64,
msg: &str,
file_path: &str,
file_content: &str,
) -> String {
fs::write(repo.join(file_path), file_content).unwrap();
git(repo, &["add", file_path], &[]);
let date = format!("@{committer_unix} +0000");
git(
repo,
&[
"-c", "gpg.format=ssh",
"-c", &format!("user.signingkey={}", signing_key.display()),
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
"commit", "-S", "-q", "-m", msg,
],
&[
("GIT_AUTHOR_DATE", &date),
("GIT_COMMITTER_DATE", &date),
],
);
let out = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
let dir = repo.join(".relicario");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
git(repo, &["add", ".relicario"], &[]);
git(repo, &["commit", "-q", "-m", "device files"], &[]);
}
#[test]
fn registered_non_revoked_key_accepted() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
write_device_files(
repo,
&[DeviceEntry {
name: "alice".into(),
public_key: pub_a.clone(),
added_at: 1_700_000_000,
added_by: "bootstrap".into(),
}],
&[],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.success();
}
#[test]
fn unregistered_key_rejected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (_, _, pub_a) = write_keypair(repo, "alice");
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
// Only Alice is registered.
write_device_files(
repo,
&[DeviceEntry {
name: "alice".into(),
public_key: pub_a.clone(),
added_at: 1_700_000_000,
added_by: "bootstrap".into(),
}],
&[],
);
// Evil signs against a file containing both keys so git commit signing works,
// but the binary's allowed-signers (from devices.json) only has Alice.
let allowed = repo.join("test_allowed_signers");
fs::write(
&allowed,
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
)
.unwrap();
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("unregistered"));
}
#[test]
fn revoked_key_after_revoked_at_rejected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
write_device_files(
repo,
&[],
&[RevokedEntry {
name: "alice".into(),
public_key: pub_a.clone(),
revoked_at: 1_705_000_000,
revoked_by: "admin".into(),
}],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
// Commit dated AFTER revocation.
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("revoked"));
}
#[test]
fn revoked_key_before_revoked_at_accepted_historical() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
init_repo(repo);
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
// Same as above: Alice only in revoked.json.
write_device_files(
repo,
&[],
&[RevokedEntry {
name: "alice".into(),
public_key: pub_a.clone(),
revoked_at: 1_705_000_000,
revoked_by: "admin".into(),
}],
);
let allowed = repo.join("test_allowed_signers");
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
// Commit dated BEFORE revocation -- historical case must pass.
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-commit", &sha])
.assert()
.success();
}

View File

@@ -1,8 +1,9 @@
[package] [package]
name = "relicario-wasm" name = "relicario-wasm"
version = "0.2.0" version = "0.7.0"
edition = "2021" edition = "2021"
description = "WASM bindings for relicario password manager" description = "WASM bindings for relicario password manager"
license = "GPL-3.0-or-later"
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]

View File

@@ -8,10 +8,17 @@ mod session;
mod device; mod device;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use relicario_core::{derive_master_key, imgsecret, KdfParams}; use relicario_core::{derive_master_key, imgsecret, KdfParams};
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS. /// Handle returned from `unlock`. Backed by a `u32`; opaque to JS.
///
/// Dropping the handle (or invoking `.free()` from JS) removes the entry from
/// the session registry, zeroizing the wrapped master key and image_secret.
/// `lock(handle)` remains available as the explicit early-cleanup path; the
/// `Drop` impl is the safety net that catches code paths which forget to call
/// `lock` before letting the handle go out of scope.
#[wasm_bindgen] #[wasm_bindgen]
pub struct SessionHandle(u32); pub struct SessionHandle(u32);
@@ -21,6 +28,23 @@ impl SessionHandle {
pub fn value(&self) -> u32 { self.0 } pub fn value(&self) -> u32 { self.0 }
} }
impl Drop for SessionHandle {
fn drop(&mut self) { let _ = session::remove(self.0); }
}
#[doc(hidden)]
pub fn __test_make_handle() -> SessionHandle {
SessionHandle(session::insert(
Zeroizing::new([0x77u8; 32]),
Zeroizing::new([0u8; 32]),
))
}
#[doc(hidden)]
pub fn __test_session_exists(handle: u32) -> bool {
session::with(handle, |_| ()).is_some()
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn unlock( pub fn unlock(
passphrase: &str, passphrase: &str,
@@ -36,7 +60,8 @@ pub fn unlock(
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params) let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?; .map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key); let stored_secret = Zeroizing::new(image_secret);
let handle = session::insert(master_key, stored_secret);
Ok(SessionHandle(handle)) Ok(SessionHandle(handle))
} }
@@ -305,6 +330,32 @@ pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsEr
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string())) imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
} }
// ── Pure parsers (no session needed) ────────────────────────────────────────
use relicario_core::{base32 as core_base32, mime as core_mime, MonthYear};
/// Parse a card-expiry string (`MM/YYYY` / `MM-YYYY` / `MM/YY`).
/// Returns a plain `{ month, year }` object on success.
#[wasm_bindgen]
pub fn parse_month_year(s: &str) -> Result<JsValue, JsError> {
let my = MonthYear::parse(s).map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&my)
}
/// Decode an RFC 4648 base32 string (case-insensitive, optional padding,
/// whitespace-stripped). Returned as `Uint8Array` on the JS side.
#[wasm_bindgen]
pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError> {
core_base32::decode_rfc4648_lenient(s).map_err(|e| JsError::new(&e.to_string()))
}
/// Guess a MIME type from a filename's extension. Returns
/// `application/octet-stream` for unknown or missing extensions.
#[wasm_bindgen]
pub fn guess_mime(filename: &str) -> String {
core_mime::guess_for_extension(filename).to_string()
}
use relicario_core::item_types::{TotpConfig, compute_totp_code}; use relicario_core::item_types::{TotpConfig, compute_totp_code};
#[wasm_bindgen] #[wasm_bindgen]
@@ -484,6 +535,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
Ok(json.to_string()) Ok(json.to_string())
} }
// ── Recovery QR bindings ─────────────────────────────────────────────────────
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
/// Generate a recovery QR SVG for the current session.
/// Returns the SVG string. The passphrase wraps the image_secret under a
/// separate key (domain-separated from the master key derivation).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
handle: &SessionHandle,
passphrase: &str,
) -> Result<String, JsError> {
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovery_qr_to_svg(&payload))
}
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
payload_b64: &str,
passphrase: &str,
) -> Result<Vec<u8>, JsError> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
let recovered = unwrap_recovery_qr(&bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovered.to_vec())
}
#[cfg(test)] #[cfg(test)]
mod session_tests { mod session_tests {
use super::*; use super::*;
@@ -492,16 +576,29 @@ mod session_tests {
#[test] #[test]
fn insert_then_remove_clears_entry() { fn insert_then_remove_clears_entry() {
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32])); let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
assert_ne!(h, 0); assert_ne!(h, 0);
assert!(session::remove(h)); assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false assert!(!session::remove(h)); // second remove false
} }
#[test]
fn dropping_session_handle_clears_registry_entry() {
session::clear();
let handle = SessionHandle(session::insert(
Zeroizing::new([0x33u8; 32]),
Zeroizing::new([0u8; 32]),
));
let id = handle.value();
assert!(session::with(id, |_| ()).is_some());
drop(handle);
assert!(session::with(id, |_| ()).is_none());
}
#[test] #[test]
fn with_yields_key_only_while_session_lives() { fn with_yields_key_only_while_session_lives() {
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32])); let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
let byte = session::with(h, |k| k[0]); let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22)); assert_eq!(byte, Some(0x22));
session::remove(h); session::remove(h);
@@ -513,7 +610,7 @@ mod session_tests {
fn manifest_round_trip_via_handle() { fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest}; use relicario_core::{Manifest, decrypt_manifest};
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32])); let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
let handle = SessionHandle(h); let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]); let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new(); let empty = Manifest::new();
@@ -553,4 +650,24 @@ mod session_tests {
// Should fail with a header validation error. // Should fail with a header validation error.
assert!(err.is_err()); assert!(err.is_err());
} }
#[test]
fn base32_decode_lenient_round_trips_known_vector() {
let bytes = super::base32_decode_lenient("MZXW6YTBOI").unwrap();
assert_eq!(bytes, b"foobar");
}
#[test]
fn guess_mime_known_and_unknown_extensions() {
assert_eq!(super::guess_mime("doc.pdf"), "application/pdf");
assert_eq!(super::guess_mime("photo.JPEG"), "image/jpeg");
assert_eq!(super::guess_mime("file.xyz"), "application/octet-stream");
}
// Error paths and JsValue serialization can't be exercised natively —
// JsError::new and serde_wasm_bindgen::Serializer call wasm-bindgen
// imports that panic off-wasm (same constraint as
// `parse_lastpass_csv_json_propagates_header_errors` above). Those
// paths are covered in core: `time::tests::parse_rejects_malformed`
// and `base32::tests::decode_rfc4648_lenient_rejects_non_alphabet_chars`.
} }

View File

@@ -6,12 +6,17 @@ use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use zeroize::Zeroizing; use zeroize::Zeroizing;
pub struct SessionData {
pub master_key: Zeroizing<[u8; 32]>,
pub image_secret: Zeroizing<[u8; 32]>,
}
thread_local! { thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new()); static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) }; static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
} }
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 { pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| { let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut(); let mut n = n.borrow_mut();
let h = *n; let h = *n;
@@ -19,17 +24,31 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h h
}); });
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); }); SESSIONS.with(|s| {
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
});
handle handle
} }
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R> pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R, F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{ {
SESSIONS.with(|s| s.borrow().get(&handle).map(f)) SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
} }
/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
}
/// Remove a session entry. Called by both `lock(handle)` (the explicit
/// path) and `impl Drop for SessionHandle` (the safety net). Returns
/// `true` if an entry was removed, `false` if the handle was already gone.
pub fn remove(handle: u32) -> bool { pub fn remove(handle: u32) -> bool {
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some()) SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
} }

View File

@@ -0,0 +1,16 @@
//! Belt-and-suspenders companion to the native `dropping_session_handle_clears_registry_entry`
//! test in `lib.rs`. This file exists for `wasm-pack test --node` symmetry; the
//! native test in the same crate is what gates CI.
use wasm_bindgen_test::wasm_bindgen_test;
use relicario_wasm::{__test_make_handle, __test_session_exists};
#[wasm_bindgen_test]
fn dropping_session_handle_clears_registry_entry() {
let handle = __test_make_handle();
let id = handle.value();
assert!(__test_session_exists(id));
drop(handle);
assert!(!__test_session_exists(id));
}

View File

@@ -1,4 +1,6 @@
# Relicario — Architecture # Relicario — Crypto Pipeline
> **Audience:** anyone evaluating or auditing the crypto. This doc owns Argon2id parameters and rationale, XChaCha20-Poly1305 rationale, vault creation/unlock flow diagrams, DCT-steganography embed and extract flows, and the high-level encrypted-file-format diagram. **Does NOT own:** byte-level schemas or JSON shapes (see [FORMATS.md](FORMATS.md)), attacker scenarios (see [SECURITY.md](SECURITY.md)), or per-module crypto implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
## System Overview ## System Overview
@@ -83,8 +85,9 @@ vault_salt ────────►│ │
┌──────────────────┐ ┌──────────────────┐
master_key ────────►│ XChaCha20- │──────► manifest.enc master_key ────────►│ XChaCha20- │──────► manifest.enc
empty manifest ────►│ Poly1305 │ empty manifest ────►│ Poly1305 │ settings.enc
└──────────────────┘ default settings ──►│ encrypt (×2) (parallel artifacts;
└──────────────────┘ independent nonces)
┌──────────────────┐ ┌──────────────────┐
│ git init │──────► vault repo │ git init │──────► vault repo
@@ -92,6 +95,14 @@ empty manifest ────►│ Poly1305 │
└──────────────────┘ └──────────────────┘
``` ```
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
attachment encryption, and field-history tracking are not shown above —
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
The flow above covers only the crypto-pipeline shape that vault init
establishes; the per-item lifecycle reuses the same `master_key` +
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
`attachments/<item-id>/<aid>.enc`.
## Unlock Flow (every vault operation) ## Unlock Flow (every vault operation)
``` ```
@@ -152,11 +163,14 @@ master_key ────────►│ XChaCha20 │──────
│ selected block: │ │ selected block: │
│ │ │ │
│ QIM embed bits │ │ QIM embed bits │
│ in positions │ in zig-zag
4-15 (mid-freq) positions 6-17
│ (mid-frequency) │
│ │ │ │
│ Repeat secret │ │ Repeat secret │
20+ times MIN_COPIES (5)
│ to 50 times, │
│ by capacity │
└────────┬─────────┘ └────────┬─────────┘
@@ -172,6 +186,8 @@ master_key ────────►│ XChaCha20 │──────
carries 256-bit secret) carries 256-bit secret)
``` ```
The redundancy count is chosen at embed time based on available DCT capacity: `num_copies = (total_blocks / BLOCKS_PER_COPY).min(50)`, with `BLOCKS_PER_COPY = 22` and a floor of `MIN_COPIES = 5` (`crates/relicario-core/src/imgsecret.rs:78,530-537`). Images that cannot fit at least 5 copies are rejected before embed. Majority voting across these copies at extract time requires ≥ 60 % confidence per bit.
## Extraction (with crop recovery) ## Extraction (with crop recovery)
``` ```
@@ -205,10 +221,12 @@ Input JPEG (possibly re-encoded or cropped)
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐ ┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
│ version │ nonce │ ciphertext │ auth tag │ │ version │ nonce │ ciphertext │ auth tag │
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │ │ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
│ 0x01 │ random per write │ XChaCha20 stream │ Poly1305 MAC │ │ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
└─────────┴────────────────────────┴──────────────────┴──────────────────┘ └─────────┴────────────────────────┴──────────────────┴──────────────────┘
``` ```
`VERSION_BYTE = 0x02` (`crates/relicario-core/src/crypto.rs:59`). Blobs starting with any other byte are rejected with `UnsupportedFormatVersion { found, expected: 0x02 }`. The legacy `0x01` format from the pre-typed-items era is no longer supported.
## Crate Architecture ## Crate Architecture
``` ```
@@ -258,3 +276,7 @@ Stolen device: ████░░░░░░░░░░░░░
Both factors compromised: game over (same as every password manager) Both factors compromised: game over (same as every password manager)
``` ```
---
**Next:** [FORMATS.md](FORMATS.md) — the byte-level wire formats.

108
docs/FORMATS.md Normal file
View File

@@ -0,0 +1,108 @@
# Relicario Wire Formats
> **Audience:** anyone implementing a compatible client or reading raw vault bytes. This doc owns the `.enc` blob layout, `params.json` / `salt` / `devices.json` / `revoked.json` shapes, the manifest JSON schema, the `.relbak` envelope, item-ID formats, and the settings JSON schema. **Does NOT own:** why these formats look this way (see [CRYPTO.md](CRYPTO.md)), threat model around them (see [SECURITY.md](SECURITY.md)), or Rust struct internals (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md)).
> Quick-reference for the load-bearing binary and JSON formats. Check this file before touching serialization, versioning, or storage layout code. Full diagrams and invariants live in the per-crate `ARCHITECTURE.md` files.
## Encrypted blob (`.enc` files)
Every encrypted file — `manifest.enc`, `settings.enc`, `items/<id>.enc`, `attachments/<item-id>/<aid>.enc` — uses the layout produced by `relicario_core::crypto::encrypt` (`crypto.rs`):
```
┌─────────┬────────────────────────┬──────────────────┬──────────────────┐
│ version │ nonce │ ciphertext │ auth tag │
│ 1 byte │ 24 bytes │ N bytes │ 16 bytes │
│ 0x02 │ random per write │ XChaCha20 stream │ Poly1305 MAC │
└─────────┴────────────────────────┴──────────────────┴──────────────────┘
```
- `VERSION_BYTE = 0x02` (`crypto.rs:59`). Any blob starting with `0x01` is rejected with `UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
- Minimum valid blob length: 41 bytes (1 + 24 + 0 + 16).
- Nonces are always fresh from `OsRng` — no caller-supplied nonces.
- Full diagram: `docs/CRYPTO.md` § "Encrypted File Format".
## `.relicario/params.json`
```json
{
"format_version": 2,
"aead": "xchacha20-poly1305",
"salt_path": ".relicario/salt",
"kdf": {
"argon2_m": 65536,
"argon2_t": 3,
"argon2_p": 4
}
}
```
Parsed via `ParamsFile { kdf: KdfParams }` in `session.rs`. The `kdf` nesting is intentional — `format_version`, `aead`, and `salt_path` co-exist for forward-compat probing. Do not flatten. Production defaults: `m=65536` (64 MiB), `t=3`, `p=4`. Tests use `m=256, t=1, p=1`.
## `.relicario/salt`
32 raw bytes. Not secret. Generated once at vault init via `OsRng`. Feeds Argon2id as the KDF salt.
## Manifest (`manifest.enc`)
Decrypts to JSON matching the `Manifest` struct (`manifest.rs`).
- **Schema version:** `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`). v1 manifests (pre-typed-items) fail to parse and are not supported.
- **`ManifestEntry` fields** (declared order in `manifest.rs:21-38`): `id`, `type`, `title`, `tags`, `favorite`, `group`, `icon_hint`, `modified`, `trashed_at`, `attachment_summaries`. The `type` field is `r#type: ItemType` in Rust but serializes as the bare JSON key `"type"` (no serde rename — `r#` is just the raw-identifier escape). `group`, `icon_hint`, and `trashed_at` are `#[serde(skip_serializing_if = "Option::is_none")]`; `tags`, `favorite`, and `attachment_summaries` use `#[serde(default)]`.
- The manifest is rebuilt from scratch on every `upsert` — it can never drift from the source-of-truth item files.
- Supports case-insensitive title/tag search without decrypting any item.
## `.relicario/devices.json`
```json
[
{ "name": "laptop", "public_key": "<hex-encoded ed25519 public key>" }
]
```
An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes accepted). Both `devices.json` and `revoked.json` must be empty for bootstrap mode to activate — a non-empty `revoked.json` alone forces strict verification.
## `.relicario/revoked.json`
```json
[
{ "name": "old-laptop", "public_key": "<hex>", "revoked_at": 1746000000 }
]
```
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
## Item IDs and Field IDs
| Kind | Length | Entropy | Source |
|---|---|---|---|
| `ItemId` | 16 hex chars | 64 bits | `OsRng` |
| `FieldId` | 16 hex chars | 64 bits | `OsRng` |
| `AttachmentId` | 32 hex chars | 128 bits | first 16 bytes (32 hex chars) of `SHA-256` over the plaintext |
`AttachmentId` is content-addressed — identical plaintexts deduplicate in git automatically. The 128-bit truncation (`ids.rs:59-69`) was widened from 64 bits per audit I2/B4 to put birthday-collision risk out of reach.
## `.relbak` backup format
A zstd-compressed tar archive containing a bare git clone of the vault. Designed for `relicario backup export/restore`.
Full spec: `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`.
## `ItemCore` JSON (internal)
`ItemCore` uses `#[serde(tag = "type")]` — the outer JSON object gets a `"type"` discriminator key. No `*Core` struct may have a field named `"type"` (use `"kind"` instead — see `CardKind`, `TotpKind`).
Full item type inventory: `crates/relicario-core/ARCHITECTURE.md` § "Module map".
## KDF input construction
The password fed to Argon2id is length-prefixed to prevent extension attacks:
```
u64_be(len(passphrase)) || passphrase_bytes || u64_be(32) || image_secret
```
NFC-normalized before hashing. Covered in `crypto.rs:229-236` and tested in `tests/format_v2.rs:44-54`.
---
**Next:** [SECURITY.md](SECURITY.md) — the threat model.

View File

@@ -1,5 +1,7 @@
# Relicario Security Model # Relicario Security Model
> **Audience:** auditors and curious users. This doc owns the threat model, attacker-scenarios table, device-authentication model, env-var trust surface, and known limitations. **Does NOT own:** crypto primitive details (see [CRYPTO.md](CRYPTO.md)), wire formats (see [FORMATS.md](FORMATS.md)), or implementation (see [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) and [../crates/relicario-cli/ARCHITECTURE.md](../crates/relicario-cli/ARCHITECTURE.md)).
## Cryptographic Protection ## Cryptographic Protection
Relicario uses two-factor vault decryption: Relicario uses two-factor vault decryption:
@@ -48,6 +50,19 @@ When enabled, device authentication provides:
- **Push access control**: Deploy keys managed via Gitea API - **Push access control**: Deploy keys managed via Gitea API
- **Instant revocation**: One command cuts off both signing and push - **Instant revocation**: One command cuts off both signing and push
Enforcement requires deploying the `relicario-server` pre-receive hook
on the vault remote. The crate provides two subcommands:
- `relicario-server generate-hook` — emits the hook script to install at
`<repo>/hooks/pre-receive`
- `relicario-server verify-commit <sha>` — checks one commit's signature
against `.relicario/devices.json` and `.relicario/revoked.json` as of
that commit; the hook calls this for every pushed ref
Without the server hook, signed commits provide authorship metadata only
— any process with push access can land an unsigned commit, since
verification is otherwise advisory.
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`. See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
## Access Control ## Access Control
@@ -57,5 +72,39 @@ Without device authentication, access control is transport-layer only:
- **CLI**: SSH key authentication to git remote - **CLI**: SSH key authentication to git remote
- **Extension**: Git credentials in browser storage - **Extension**: Git credentials in browser storage
Device registration was optional before v0.4.0. With device auth enabled, Device registration is optional but recommended for shared vaults.
all commits must be signed by a registered device.
## Configuration env vars
Relicario reads the following environment variables. Each is a trust
boundary: an attacker who can set them in the user's environment can
influence Relicario's behavior. They are listed here for security
reviewers to audit the surface in one place.
### User-facing (active in all builds)
| Variable | Purpose | Trust |
|---|---|---|
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
### Debug-only (compiled out of `cargo build --release`)
The following variables are gated behind `cfg(debug_assertions)` and
are **no-ops** in release builds. The env-var lookup is removed by the
optimiser from any binary built without debug assertions (i.e. the
standard `--release` profile).
| Variable | Purpose |
|---|---|
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
---
**Next:** [../crates/relicario-core/ARCHITECTURE.md](../crates/relicario-core/ARCHITECTURE.md) — implementation, starting with the platform-agnostic core.

View File

@@ -0,0 +1,165 @@
# Multi-Agent Development Paradigm
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
---
## Overview
| Role | Terminal | Branch | Responsibilities |
|------|----------|--------|-----------------|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
---
## Starting a lift
### Prerequisites
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
- [ ] No uncommitted changes in main that would confuse the devs
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
### Launch sequence
```bash
# 1. Start the relay server (this terminal becomes the relay log)
tools/relay/start.sh # prints copy-paste instructions, then starts server
# Optional: use a multiplexer to auto-open all four terminals
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
```
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
---
## Coordination protocol
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
| Kind | Block header | When used |
|------|-------------|-----------|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
| `free` | (none) | Ad-hoc messages not covered by the above |
A well-formed `status` block:
```
## STATUS UPDATE — DEV-B
Time: 2026-05-02T14:30:00-07:00
Branch: feature/v0.5.0-plan-b-extension-ux
Task: P4 / error-copy map
Status: DONE
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
Tests: green
Notes: No issues. Ready for PM review of P4 before starting B1.
```
---
## Using the relay tools
All three Claude Code sessions have these tools available when the relay server is running:
```
post_message(from, to, kind, body) → { id }
read_messages(for) → RelayMessage[] (drains inbox)
list_pending(for) → { count, kinds } (non-destructive)
```
Typical dev flow per task:
```
1. read_messages(for="dev-b") # check for directives before starting
2. ... do the work ...
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
```
Typical PM flow:
```
1. read_messages(for="pm") # see what devs posted
2. ... review ...
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
```
---
## If the relay server isn't running
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
To restart a crashed server mid-lift:
```bash
tools/relay/start.sh
```
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
---
## Generating kickoff prompts
### Full workflow (spec → plans → kickoff)
**Step 1 — Write a spec**
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
**Step 2 — Invoke the kickoff skill**
Say anything like:
- "kick off the multi-agent thing for v0.6.0"
- "spin up PM and devs for this release"
- "set up the three-terminal paradigm"
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
4. Commit the prompts and print launch instructions
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
**Step 3 — Launch**
```bash
tools/relay/start.sh # prints prompt file paths, starts relay server
# open N+1 terminals, paste each prompt below its '---' line
```
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
---
## Ending a lift
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
3. PM tags the release (only after explicit user `yes`)
4. Ctrl-C the relay terminal — all in-memory messages are discarded
---
## Roles and boundaries (quick reference)
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
**User must:** authorize all merges and the release tag. Everything else is delegated.

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

@@ -5,8 +5,9 @@ Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
## Summary ## Summary
- **Total findings:** 14 - **Total findings:** 14
- **Fixed inline:** 6 - **Fixed inline (initial pass):** 6
- **Need user input (proposed only):** 8 - **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
- **No action needed:** 0
- **Top 3 recommendations:** - **Top 3 recommendations:**
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0. 1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3). 2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
@@ -24,7 +25,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises. **Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`". **Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (>50 words of new prose; touches the framing of the whole overview doc) **Status:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
--- ---
@@ -34,7 +35,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness. **Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`. **Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled per audit constraints) **Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
--- ---
@@ -44,7 +45,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX. **Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`). **Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled; phrasing is a judgment call) **Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
--- ---
@@ -54,7 +55,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4). **Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).` **Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled) **Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits)."
--- ---
@@ -114,7 +115,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system. **Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`. **Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them) **Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
**Status:** Proposed; needs user decision (>50 words of new prose, design choice between rewriting vs trimming) **Status:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
--- ---
@@ -124,7 +125,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current. **Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat. **Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch) **Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
**Status:** Proposed; needs user decision (security wording — exact phrasing matters) **Status:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
--- ---
@@ -134,7 +135,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts. **Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec. **Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
**Severity:** nice-to-have **Severity:** nice-to-have
**Status:** Proposed; needs user decision (>50 words of new prose) **Status:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
--- ---
@@ -144,7 +145,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact. **Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file. **Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
**Severity:** informational **Severity:** informational
**Status:** Proposed; needs user decision (touches a historical spec the user may want to leave frozen) **Status:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
--- ---
@@ -160,10 +161,18 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
## Inline-fix verification ## Inline-fix verification
Files modified during this audit: ### Initial pass (commit `900ccf1`):
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap. - `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line. - `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds). - `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
No source files, `Cargo.lock`, or extension code were modified. CLAUDE.md, SECURITY.md, and the foundational design spec were not modified — those changes need user review per the audit constraints. ### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
No source files, `Cargo.lock`, or extension code were modified at any point.

View File

@@ -0,0 +1,194 @@
# Dev A Kickoff Prompt — arch-followup Plan A
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 arch-followup "architecture-review followups" release train.
Plan A is the **security & docs polish PR** — the small one that ships first, in under a day, with no dependencies on Plans B or C. It closes the only defense-in-depth crypto gap the architecture review flagged (`SessionHandle` has no `impl Drop`, so wasm-bindgen's `.free()` is a cleanup no-op while the master key sits in WASM linear memory), removes the JS-side error swallow that was masking that fact, brings `recovery_qr.rs` documentation up to the density of `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`, and finishes the relay-launcher dev-c expansion. Four phases, all S-effort.
A PM in another terminal coordinates you with Dev-B (CLI restructure) and Dev-C (extension restructure). 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 ../relicario-plan-a -b feature/2026-05-04-a-security-polish
cd ../relicario-plan-a
pwd # should print /home/alee/Sources/relicario-plan-a (or similar absolute path)
```
**ALL subsequent work happens in `/home/alee/Sources/relicario-plan-a`**. Force-cd subagents into this directory — the project's `CLAUDE.md` memory rule explicitly requires that subagent prompts MUST start with `cd /home/alee/Sources/relicario-plan-a` so subagents don't accidentally commit to main. This is non-negotiable.
Today: 2026-05-04. 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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.1, P1.7, P1.8 + the partner JS-side fix at `service-worker/session.ts:26` + the `start.sh` launcher follow-up only**)
3. `docs/superpowers/specs/2026-05-04-security-polish-design.md` — your plan, execute phase by phase
You do NOT need to read Plan B or Plan C in detail. Skim Plan B's "WASM/extension parity seam" paragraph (Phase 8) and Plan C's "Risks → `.free()` callsite policy" paragraph only if a coordination question arises — they're the points where Plans B/C reference your work.
## Execution mode
Use **subagent-driven-development** (per project memory's default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario-plan-a
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:** Phase 1 (Rust `impl Drop for SessionHandle` + wasm-bindgen-test + native fallback test), Phase 2 (JS `.free()` audit + remove the `try { current.free() }` swallow at `extension/src/service-worker/session.ts:26`), Phase 3 (`recovery_qr.rs` documentation pass — module-level `//!`, ASCII layout diagram, doc-comments on the four public items, `production_params` rationale or `const`), Phase 4 (`tools/relay/start.sh` dev-c expansion + verification that `queue.test.ts:54` is already fixed).
**Out of scope:** anything in Plan B (CLI restructure — `cli/main.rs` split, `git_run` helper, parser migration to core, etc.) or Plan C (extension restructure — `setup.ts` SW migration, `vault.ts` split, `StateHost` typing, SW router dedup). The other P2 WASM cleanups (double-lookup, Vec<u8> clone, naming inconsistency, concurrency primitive split). DEV-C's other relay P2s (queue TTL, `call.py`/`call.ts` tracking decision). The 8 "Open architectural decisions". If you trip over an out-of-scope issue or a new bug while doing your work, file it via a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- This is a defense-in-depth crypto fix. Do NOT skip Phase 1's `wasm-bindgen-test` AND the native `#[test]` fallback — both are required by the plan's Done criteria.
- Do NOT remove the `try { current.free() }` swallow (Phase 2) until Phase 1's `impl Drop` has landed in the same branch — Phase 2 explicitly depends on Phase 1.
- Phase 3 must produce documentation density that visibly matches `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`. If your doc-block is shorter than `crypto.rs`'s top comment, it's not done.
- Phase 4: confirm `queue.test.ts:54` is already fixed in commit `061facd` (read the file; assert the line is `assert.ok(isRole("dev-c"));`). If it isn't, escalate via `## QUESTION TO PM` — the plan was drafted assuming it was fixed.
- The plan's Done criteria includes recording the `grep -rn "\.free\b" extension/src/` output in the PR description as the audit deliverable. Do NOT merge the PR without that grep recorded.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of multiple terminals. The relay routes messages between them.
**At every phase boundary** (complete, blocked, or question): call `read_messages(for="dev-a")` first, then post your update via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
```
## STATUS UPDATE — DEV-A
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
Branch: feature/2026-05-04-a-security-polish
Task: <phase 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 phase, 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 (or relayed by user if relay is down). Acknowledge and act.
## Authority within the plan
You don't need PM permission to:
- Execute phase-to-phase per the plan
- Make implementation decisions consistent with the plan and synthesis
- Choose between the plan's optional micro-cleanups in Phase 3 (the named-constant slice ranges, the `recovery_kdf_input` length-prefix comment) — proceed if they make the code clearer
- Decide whether to add `wasm.lock(handle)` before `.free()` in `clearCurrent()` (Phase 2 leaves this to your judgment; the plan recommends just `free()` post-Phase-1)
- Write tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate to PM when:
- You discover the Rust toolchain is older than 1.79 (Phase 3 falls back to a runtime `#[test]` for the layout assertion — confirm with PM before switching)
- You discover the `.free()` audit grep returns more than one match (the plan was drafted expecting one; investigate and report)
- You discover `queue.test.ts:54` is NOT actually fixed (Phase 4 falls back to a real code change)
- You discover the `start.sh` launcher requires deeper changes than the four locations the plan describes
- A test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- Anything destructive (per project rules)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
# From the worktree root (/home/alee/Sources/relicario-plan-a):
cargo test -p relicario-core
cargo test -p relicario-wasm
cd extension && npm test && cd ..
# Audit deliverable (record output in PR description):
grep -rn "\.free\b" extension/src/
# Optional but recommended for the wasm-bindgen-test (requires wasm-pack):
# wasm-pack test --node crates/relicario-wasm
# Manual launcher checks (Phase 4):
bash tools/relay/start.sh --manual # confirm "Open 4 new terminals" + Dev-C prompt path
# bash tools/relay/start.sh --kitty # if on a kitty terminal: confirm 4 tabs open
```
Then push and open the PR:
```bash
git push -u origin feature/2026-05-04-a-security-polish
gh pr create --base main --head feature/2026-05-04-a-security-polish --title "docs+fix(arch-followup): Drop impl + recovery_qr docs + relay launcher (Plan A)" --body "$(cat <<'EOF'
## Summary
Architecture-review followup Plan A (security & docs polish). Source: `docs/superpowers/specs/2026-05-04-security-polish-design.md`.
- **P1.1** — `SessionHandle` now has `impl Drop` so wasm-bindgen's `.free()` actually clears the master key from WASM linear memory. Native + wasm-bindgen tests cover construct → drop → registry-empty.
- **JS partner fix** — removed the `try { current.free() }` swallow at `extension/src/service-worker/session.ts:26`. Crypto-state-transition errors now propagate.
- **P1.7** — `crates/relicario-core/src/recovery_qr.rs` documentation pass: module-level `//!`, ASCII layout diagram, doc-comments on the four public items, `production_params` parameter-pinning rationale.
- **P1.8** — confirmed `tools/relay/queue.test.ts:54` already matches the new role union (committed in `061facd`); `tools/relay/start.sh` extended to discover and launch a fourth Dev-C window in `--manual`, `--tmux`, `--kitty` modes.
## Audit deliverables
`.free()` callsites under `extension/src/` (recorded for future regression baseline):
```
<paste the grep output here>
```
## Test plan
- [ ] `cargo test -p relicario-core` passes (recovery_qr tests still green; layout static assertion compiles)
- [ ] `cargo test -p relicario-wasm` passes including new `Drop` test
- [ ] `cd extension && npm test` passes including new `clearCurrent()` test
- [ ] `grep -rn "\.free\b" extension/src/` returns exactly one match (the SW callsite)
- [ ] `bash tools/relay/start.sh --manual` shows "Open 4 new terminals" and lists the Dev-C prompt path
## Done criteria
Per `docs/superpowers/specs/2026-05-04-security-polish-design.md` Done criteria — every checkbox.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL (post via `post_message`).
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created at `/home/alee/Sources/relicario-plan-a`, plan absorbed, on `feature/2026-05-04-a-security-polish`). Post it via `post_message(from="dev-a", to="pm", kind="status", body="...")`. Then start Phase 1 of your plan (Rust `impl Drop for SessionHandle` + tests).

View File

@@ -0,0 +1,207 @@
# Dev B Kickoff Prompt — arch-followup Plan B
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 arch-followup "architecture-review followups" release train.
Plan B is the **CLI restructure** — the "single biggest readability lift" per the synthesis. M-L effort, multi-day. It splits `crates/relicario-cli/src/main.rs` (2641 LOC) into a `commands/` folder + `prompt.rs` + `parse.rs`, then layers on the duplicated git-error UX consolidation, manifest-after-mutation cache discipline, `ParamsFile` dedup, batched purge, and the migration of pure parsers (and a third copy of base32) into `relicario-core` with WASM re-exports. Eight phases.
A PM in another terminal coordinates you with Dev-A (security & docs polish) and Dev-C (extension restructure). 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 ../relicario-plan-b -b feature/2026-05-04-b-cli-restructure
cd ../relicario-plan-b
pwd # should print /home/alee/Sources/relicario-plan-b (or similar absolute path)
```
**ALL subsequent work happens in `/home/alee/Sources/relicario-plan-b`**. Force-cd subagents into this directory — the project's `CLAUDE.md` memory rule explicitly requires that subagent prompts MUST start with `cd /home/alee/Sources/relicario-plan-b` so subagents don't accidentally commit to main. This is non-negotiable.
Today: 2026-05-04. 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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules (Spanish flourish in chat replies only, capitalization, autonomy defaults, CLI/extension parity philosophy)
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.2, P1.3, P1.10 + the in-scope CLI P2s only**)
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — your plan, execute phase by phase
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (your primary source for line-level context — the synthesis abbreviates)
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read **only** the "Boundary notes for DEV-B" section near the end (cross-boundary contracts you must respect when designing the WASM exports for Phase 8)
You do NOT need to read Plan A or Plan C in detail. If a coordination question arises, skim Plan C's "Risks → WASM boundary coordination" paragraph (it cites your Phase 8 explicitly).
## Execution mode
Use **subagent-driven-development** (per project memory's default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario-plan-b
```
…before any other instruction. This is non-negotiable per project memory.
**Sequencing matters.** Phase 1 (the mechanical `main.rs` split) is the precondition for every other phase — phases 2-6 touch files that don't exist yet until phase 1 lands. Do NOT start phase 2 until phase 1's `cargo test --workspace` is green and a checkpoint commit is in place.
## Your scope and boundaries
**In scope:**
- Phase 1 — Mechanical split of `main.rs` into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr,init,generate,rate}.rs` + `prompt.rs` + `parse.rs`
- Phase 2 — `helpers::git_run` + sweep of the 16 `bail!("git X failed")` sites
- Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression
- Phase 4 — `Vault::after_manifest_change` + sweep of the 7 `refresh_groups_cache` sites
- Phase 5 — Single canonical `ParamsFile` (one definition shared between init writer and unlock reader)
- Phase 6 — Batched purge in `cmd_purge` and `cmd_trash_empty` (3 git invocations for an N-item purge instead of 3N)
- Phase 7 — Migrate `parse_month_year` / `base32_decode_lenient` / `guess_mime` to `relicario-core` + `pub(crate) mod base32` (closes DEV-A's three-base32-impls finding)
- Phase 8 — WASM exports for the migrated parsers + `extension/src/wasm.d.ts` mirror
**Out of scope:** anything in Plan A (security/docs polish — Drop impl, recovery_qr docs, relay launcher) or Plan C (extension restructure — setup.ts SW migration, vault.ts split, StateHost typing, SW router dedup). The CLI P3 nits (let _ = entry pattern, Lock subcommand visibility, Display for ItemType, helpers::relicario_dir adoption sweep, gitea Client per-call construction, edit_and_history.rs scripted prompts, dead test variable, three-test-env-var macro, cmd_recovery_qr_unwrap empty input check, Task 12 cleanup). Server findings (P2/P3 in DEV-B's relicario-server section). WASM findings beyond the parser exports needed for P1.10 (DEV-B's WASM P2 list — double-lookup, Vec<u8> clone, naming, concurrency primitive split). The 8 "Open architectural decisions". The WASM JS-naming snake_case → camelCase decision (deferred to a separate plan). 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:**
- Phase 1 is **mechanical** — no logic changes, no signature changes, no error-message reword. Run `cargo check -p relicario-cli` between every file extraction; existing CLI integration tests at `crates/relicario-cli/tests/*` must stay green throughout. They are the regression budget.
- Phase 2's `git_run` switches from `.status()` (inherited stderr to TTY) to `.output()` (captured); the captured stderr MUST be printed to the user's stderr unmodified on failure. Do not silently swallow.
- Phase 5's `ParamsFile` migration is on-disk-format-sensitive. Field names and types MUST match the existing `params.json` shape exactly; the round-trip test against a fixture string is required by the plan's Done criteria.
- Phase 7 — `MonthYear::new` currently returns `Result<_, &'static str>` (DEV-A's P3 nit). The plan recommends re-wrapping the error in `MonthYear::parse` rather than migrating `new` to `RelicarioError`. If you'd rather migrate `new` for consistency, escalate to PM first — this is a cross-plan coordination concern.
- Phase 8 keeps **snake_case** JS names (consistent with every existing export). Do not introduce camelCase for the three new exports. The snake_case → camelCase decision is deferred to a separate plan.
- Phase 8 updates `extension/src/wasm.d.ts` and the new `#[wasm_bindgen]` exports in the same commit. Per DEV-C's boundary note, `wasm.d.ts` is hand-maintained — do not let the two surfaces drift even temporarily.
- The Steam alphabet at `crates/relicario-core/src/item_types/totp.rs:13` is **intentionally non-RFC-4648** and must NOT move into the new `pub(crate) mod base32`. Add a neighbour comment per the plan.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of multiple terminals. The relay routes messages between them.
**At every phase boundary** (complete, blocked, or question): call `read_messages(for="dev-b")` first, then post your update via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
```
## STATUS UPDATE — DEV-B
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
Branch: feature/2026-05-04-b-cli-restructure
Task: <phase 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 phase, 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 (or relayed by user if relay is down). Acknowledge and act.
## Cross-plan coordination
- **Plan C consumes your Phase 8 WASM exports** (`parse_month_year` / `base32_decode_lenient` / `guess_mime`) — but only as a deferred follow-up, NOT in Plan C's current execution. You ship the seam; Plan C does not wire the SW handlers in this train. Sequence Phase 8 to land before Plan C touches `wasm.d.ts` if both must touch it.
- **`extension/src/wasm.d.ts` shared touchpoint with Plan C.** Plan C says it likely does NOT need to touch this file (its `create_vault`/`attach_vault` handlers reuse already-declared WASM entries). If Plan C does end up touching it, sequence Plan B's edits first and ask Plan C to rebase.
- **Plan A's `impl Drop for SessionHandle`** is independent of you. Your `git_run` and parser-migration work doesn't touch the WASM crate's session module. No conflict.
## Authority within the plan
You don't need PM permission to:
- Execute phase-to-phase per the plan
- Make implementation decisions consistent with the plan and synthesis
- Add new tests, refactor your own code, fix bugs you introduce
- Choose between optional approaches the plan calls out (e.g. whether `Vault::after_manifest_change` calls `save_manifest` internally vs renaming the existing method to `save_manifest_raw`)
- Push commits to your feature branch
You **do** escalate to PM when:
- You discover the grep at the top of Phase 7 returns non-CLI consumers of `parse_month_year` / `base32_decode_lenient` / `guess_mime` / `base32_encode` / `decode_base32_totp` (the plan was drafted assuming zero non-CLI consumers; investigate)
- A `cargo test --workspace` failure you can't reproduce locally
- You want to deviate on the `MonthYear::new` consistency point (see Hard Rules)
- You discover the `ParamsFile` round-trip is not field-compatible with the current on-disk format (rename, type change, etc.)
- A test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- Anything destructive (per project rules)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
# From the worktree root (/home/alee/Sources/relicario-plan-b):
cargo test --workspace
cargo clippy --workspace --all-targets --no-deps
cargo build -p relicario-wasm --target wasm32-unknown-unknown
# Done-criteria sanity greps (all should return zero matches):
grep -n 'bail!("git ' crates/relicario-cli/src/
grep -n 'refresh_groups_cache' crates/relicario-cli/src/ | grep -v 'session\.rs'
# These should each return one match:
grep -n 'struct ParamsFile' crates/relicario-cli/src/
# CLI smoke (post-split, end-to-end):
cargo run -p relicario-cli -- --help
```
Then push and open the PR:
```bash
git push -u origin feature/2026-05-04-b-cli-restructure
gh pr create --base main --head feature/2026-05-04-b-cli-restructure --title "refactor(cli): split main.rs + git_run helper + parsers→core (Plan B)" --body "$(cat <<'EOF'
## Summary
Architecture-review followup Plan B (CLI restructure — single biggest readability lift). Source: `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`.
- **P1.2** — `cli/main.rs` split from 2641 LOC into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr,init,generate,rate}.rs` + `prompt.rs` + `parse.rs`. `main.rs` retains clap + dispatch only (~470 lines).
- **P1.3** — `helpers::git_run(repo, args, context)` captures stderr; 16 duplicated bail sites collapsed.
- **P1.10** — `parse_month_year` / `base32_decode_lenient` / `guess_mime` migrated to `relicario-core` (`MonthYear::parse`, `pub(crate) mod base32::{encode_rfc4648,decode_rfc4648_lenient}`, `mime::guess_for_extension`); also closes DEV-A's three-base32-impls finding by extracting the shared module. WASM exports added; `extension/src/wasm.d.ts` mirrored.
- **CLI P2 cluster** — `prompt_or_flag<T>` compression of `build_*_item`; `Vault::after_manifest_change` centralizes the `refresh_groups_cache` discipline (7 sites collapsed); single canonical `ParamsFile` shared between init writer and unlock reader; batched purge — a 50-item `trash empty` is now 3 git invocations instead of 150.
## Test plan
- [ ] `cargo test --workspace` passes
- [ ] `cargo clippy --workspace` silent
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean
- [ ] `grep -n 'bail!("git ' crates/relicario-cli/src/` returns zero matches
- [ ] `grep -n 'refresh_groups_cache' crates/relicario-cli/src/` returns zero matches outside `session.rs`
- [ ] `grep -n 'struct ParamsFile' crates/relicario-cli/src/` returns one match
- [ ] New test asserts a multi-item `trash empty` produces exactly one new git commit
- [ ] All existing CLI integration tests at `crates/relicario-cli/tests/*` still pass without modification
## Done criteria
Per `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Done criteria — every checkbox.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL (post via `post_message`).
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created at `/home/alee/Sources/relicario-plan-b`, plan absorbed, on `feature/2026-05-04-b-cli-restructure`). Post it via `post_message(from="dev-b", to="pm", kind="status", body="...")`. Then start Phase 1 of your plan (mechanical split of `cli/main.rs`). Remember: phase 1 is mechanical — `cargo check` between every file extraction.

View File

@@ -0,0 +1,218 @@
# Dev C Kickoff Prompt — arch-followup Plan C
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 arch-followup "architecture-review followups" release train.
Plan C is the **extension restructure** — the largest of the three (multi-day to multi-week). It eliminates the two steepest learning cliffs in the extension. After this plan ships, `setup.ts` no longer imports `relicario-wasm` directly (it isn't the pattern; it was the exception); `vault.ts` shrinks from 1027 LOC to ~200 of routing + state; `shared/state.ts` becomes type-checked end-to-end; the duplicated SW router helpers consolidate into one home each; and the extension closes its last CLI-parity gap (`relicario status` → vault-sidebar status indicator). Six phases.
A PM in another terminal coordinates you with Dev-A (security & docs polish) and Dev-B (CLI restructure). 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 ../relicario-plan-c -b feature/2026-05-04-c-extension-restructure
cd ../relicario-plan-c
pwd # should print /home/alee/Sources/relicario-plan-c (or similar absolute path)
```
**ALL subsequent work happens in `/home/alee/Sources/relicario-plan-c`**. Force-cd subagents into this directory — the project's `CLAUDE.md` memory rule explicitly requires that subagent prompts MUST start with `cd /home/alee/Sources/relicario-plan-c` so subagents don't accidentally commit to main. This is non-negotiable.
Today: 2026-05-04. 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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules (Spanish flourish in chat replies only, capitalization, autonomy defaults, CLI/extension parity philosophy, security defense-in-depth)
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2s + the `relicario status` parity gap only**)
3. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — your plan, execute phase by phase
4. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — DEV-C's full notes (your primary source — the synthesis abbreviates)
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — read **only** the "Boundary notes for DEV-C" section near the end (14 numbered contracts the JS side must respect when interacting with WASM; several inform your scope)
You do NOT need to read Plan A or Plan B in detail. Skim Plan A's Phase 2 (the `service-worker/session.ts:26` swallow removal) and Plan B's Phase 8 (WASM parser exports) only if a coordination question arises.
## Execution mode
Use **subagent-driven-development** (per project memory's default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario-plan-c
```
…before any other instruction. This is non-negotiable per project memory.
**Sequencing matters.** Phase 1 (typed `StateHost`) is the precondition for phases 3 and 4. Phase 2 (SW storage extraction) is independent and can ship in parallel. Phases 3 and 4 both depend on phase 1. Phase 5 (P2 cluster) and phase 6 (`get_vault_status`) are independent of 3 and 4 — they can run in parallel.
## Your scope and boundaries
**In scope:**
- Phase 1 — Typed `StateHost` interface in `extension/src/shared/state.ts` (no `any` in public surface) + generic `getState`/`setState` over `keyof PopupState` + double-registration guard + `__resetHostForTests` helper. Includes migration of `View` and `PopupState` from `extension/src/popup/popup.ts` to `extension/src/shared/types.ts` (or a new `shared/popup-state.ts`) to avoid a `popup → shared → popup` circular import.
- Phase 2 — Extract `extension/src/service-worker/storage.ts` (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist` from both router files) + move `itemToManifestEntry` to `extension/src/service-worker/vault.ts`.
- Phase 3 — Setup wizard SW migration: add `create_vault` and `attach_vault` SW messages; rewrite `setup.ts` as UI-only that posts those messages; convert the 6-step procedural wizard to a step-registry pattern; add `clearWizardState()` on `beforeunload` + step-0 reset.
- Phase 4 — Split `vault.ts` into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`. Lift `vault_locked` RPC intercept into `shared/state.ts`. Reset `state.drawerOpen` on non-list `renderPane`. Debounce sidebar search (50-100ms).
- Phase 5 — P2 cluster: inactivity-timer reset on content-callable messages (with documented exclusion set); `state.gitHost` clear on session expiry; teardown helper extraction (`teardownSettingsCommon`); `Promise.allSettled` in devices/trash; MutationObserver debounce in `content/detector.ts`.
- Phase 6 — `get_vault_status` SW message + vault-sidebar status indicator (closes the `relicario status` parity gap).
**Out of scope:** anything in Plan A (security/docs polish — `impl Drop`, `service-worker/session.ts:26` swallow removal, `.free()` audit, `recovery_qr.rs` docs, server hardening, env-var audit) or Plan B (CLI restructure — `cli/main.rs` split, `git_run`, parser migration to core; you only **consume** the WASM exports as a deferred follow-up). Extension P3s (form-header `isInTab()` redundancy, popup.ts `isInTab()` heuristic, item-form.ts `renderComingSoon` dead code, `types/login.ts` size, `vault.ts:18-26` backup-panel comment, capture/detector/fill username-finder dedup, capture submit-button hook scope, setup.ts passphrase-score `-1` sentinel, `setup.ts:1056-1062` chrome.storage bypass, `setup.ts:1-7` "5-step" header, glyphs.ts partial adoption, `types.ts` TotpKind flat-union, `totp-tools.ts:39-46` swallowed rejections, generator-panel cleanup guard, `item-list.ts` popover listeners, popup `popup.ts:178-181` unconditional teardowns). Other parity items (per-attachment `delete_attachment` SW message, `list --tag` filter doc note). Cross-cutting items not explicitly listed (chrome.storage.local direct reads outside the setup migration, bun test runner doc note, manifest version sync). The 8 "Open architectural decisions". WASM JS-naming snake_case → camelCase (deferred to a separate plan). Anything touching the in-flight uncommitted v0.5.x work. 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:**
- **Phase 1 first.** Do NOT start phases 3 or 4 until phase 1 is green and committed. The typed `StateHost` is the contract phases 3 and 4 build against.
- **`View` / `PopupState` migration is part of phase 1**, not phase 4. Doing it later creates circular imports that surface mid-refactor and waste a day.
- **Do NOT redo Plan A's `.free()` swallow removal** at `extension/src/service-worker/session.ts:26`. That's Dev-A's. But wherever your refactor *moves* a `.free()` callsite — most notably during phase 3 when `setup.ts`'s `verifiedHandle` retires and the new `create_vault`/`attach_vault` SW handlers acquire their own handles — the new location MUST call `wasm.lock(handle)` first regardless of whether Plan A's Rust-side `impl Drop` has landed yet. Cite Plan A as the source of the policy in your phase 3 commit message.
- **`extension/src/wasm.d.ts` coordination with Plan B.** Plan B Phase 8 will touch this file for new parser exports. Verify by reading `extension/src/service-worker/vault.ts` whether your `create_vault`/`attach_vault` SW handlers need new WASM entry points — they likely don't (the SW already orchestrates `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt`). If you DO need new entries, escalate via `## QUESTION TO PM` so the touch order with Plan B can be sequenced.
- **`create_vault` and `attach_vault` SW handlers must be transactional** — they hold their own internal session reference for the duration of the operation and do NOT consult or reset the user-facing inactivity timer until they return successfully. Document this contract in the handler header comments.
- **Phase 4 `vault_locked` channel unification** keeps both signals (the SW's `session_expired` event AND the new `shared/state.ts` wrapper's intercept) firing during the migration window. Collapse only after both surfaces are verified consuming from `shared/state.ts`. Add a regression test asserting popup lock screen renders on `session_expired` and vault tab lock screen renders on the SW response intercept.
- **Round out the WASM stub at `extension/src/__stubs__/relicario_wasm.stub.ts`** as part of phase 3. DEV-C noted only 7 of ~25 exports are stubbed; phase 3's vitest tests for `create_vault`/`attach_vault` need stubs for `embed_image_secret`, `register_device`, `manifest_encrypt`. Add them rather than file a separate ticket.
- The `recovery_qr_generated_at` direct chrome.storage.local write at `setup.ts:1056-1062` is **out of scope** — leave it as-is; defer to a P3 cleanup.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of multiple terminals. The relay routes messages between them.
**At every phase boundary** (complete, blocked, or question): call `read_messages(for="dev-c")` first, then post your update via `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
```
## STATUS UPDATE — DEV-C
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
Branch: feature/2026-05-04-c-extension-restructure
Task: <phase 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-C
Time: <iso8601>
Context: <what phase, 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 (or relayed by user if relay is down). Acknowledge and act.
## Cross-plan coordination
- **Plan A owns the `.free()` swallow removal** (`service-worker/session.ts:26`) and the Rust `impl Drop for SessionHandle`. Do not redo that work. Do honor the policy where you move callsites (see Hard Rules).
- **Plan B Phase 8 ships WASM parser exports** (`parse_month_year` / `base32_decode_lenient` / `guess_mime`) that the extension can eventually consume. Consumption (SW message handlers wrapping the new exports) is **explicitly deferred to a future plan** — do NOT design those handlers in this train. The seam exists; nobody is wiring it yet.
- **`extension/src/wasm.d.ts` shared touchpoint with Plan B.** See Hard Rules — you likely don't need to touch it. If you do, escalate to PM for sequencing.
## Authority within the plan
You don't need PM permission to:
- Execute phase-to-phase per the plan
- Make implementation decisions consistent with the plan and synthesis
- Choose how the typed `StateHost` exposes `setState` (the plan suggests `setState<K extends keyof PopupState>`; pick the variant that gives the cleanest call-site ergonomics)
- Pick which file `View` and `PopupState` migrate to (`shared/types.ts` vs new `shared/popup-state.ts`) — the plan accepts either
- Add new tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate to PM when:
- You discover phase 3's setup-to-SW migration needs new WASM entry points (would touch `wasm.d.ts` and conflict with Plan B Phase 8)
- You discover the `view`/`PopupState` migration in phase 1 surfaces more TS errors than the plan estimated (~15-30); if you hit 100+ errors, the surface area is bigger than the plan accounts for
- You discover a real bug in the existing `vault_locked` channels (e.g. popup currently doesn't actually receive `session_expired` despite the plan's premise)
- A vitest test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- Anything destructive (per project rules)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
# From the worktree root (/home/alee/Sources/relicario-plan-c):
cd extension
npm test # vitest
npm run build # Chrome build
npm run build:firefox # Firefox build
cd ..
# Done-criteria sanity greps:
grep -n ': any' extension/src/shared/state.ts # should return zero
grep -rn 'relicario-wasm' extension/src/setup/ # should return zero (post-Phase-3)
wc -l extension/src/setup/setup.ts # should be ≤ ~500
wc -l extension/src/vault/vault.ts # should be ~200
grep -n 'loadDeviceSettings\|loadBlacklist\|saveBlacklist' \
extension/src/service-worker/router/popup-only.ts \
extension/src/service-worker/router/content-callable.ts # should be imports only, no defs
grep -n 'itemToManifestEntry' extension/src/service-worker/router/ # should be imports only
```
Then push and open the PR:
```bash
git push -u origin feature/2026-05-04-c-extension-restructure
gh pr create --base main --head feature/2026-05-04-c-extension-restructure --title "refactor(ext): typed StateHost + setup→SW + vault.ts split (Plan C)" --body "$(cat <<'EOF'
## Summary
Architecture-review followup Plan C (extension restructure — eliminates the two steepest learning cliffs). Source: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`.
- **P1.6** — `extension/src/shared/state.ts` now has a concrete `StateHost` interface (no `any` in public surface), `getState`/`setState` generic over `keyof PopupState`, double-registration guard, `__resetHostForTests` helper. `View` and `PopupState` migrated from `popup/popup.ts` to `shared/types.ts` to break circular import.
- **P1.9** — `service-worker/storage.ts` extracted; `itemToManifestEntry` moved to `service-worker/vault.ts`. Both router files import; no more duplicated definitions.
- **P1.4** — `setup.ts` no longer imports `relicario-wasm`. New `create_vault` / `attach_vault` SW messages handle vault creation transactionally. Procedural wizard converted to a step-registry pattern (`{ id, render, attach }[]`). `clearWizardState()` on `beforeunload` + step-0 reset wipes sensitive `Uint8Array` material best-effort. `setup.ts` LOC dropped from 1220 to ~500.
- **P1.5** — `vault.ts` split into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`. `vault.ts` retained at ~200 LOC (routing + state only). `vault_locked` RPC intercept lifted into `shared/state.ts` so popup and vault tab consume one channel. Drawer auto-closes on non-list views. Sidebar search debounced.
- **P2 cluster** — inactivity timer resets on all messages except a documented exclusion set; `state.gitHost` clears on session expiry; `teardownSettingsCommon` extracted; `devices.ts`/`trash.ts` use `Promise.allSettled`; `content/detector.ts` MutationObserver debounced.
- **`get_vault_status`** — closes the `relicario status` parity gap. New SW message returns cached `{ ahead, behind, lastSyncAt, pendingItems }`; vault sidebar renders an indicator on mount + manual refresh.
## Cross-plan coordination respected
- **Plan A** owns the `service-worker/session.ts:26` swallow removal and the Rust `impl Drop`. This PR does NOT redo that work. Wherever this refactor moved a `.free()` callsite (Phase 3 setup-to-SW migration), the new location calls `wasm.lock(handle)` first regardless of Plan A's status.
- **Plan B Phase 8** WASM parser exports are a seam this PR does NOT consume in this train. Future plan wires the SW handlers.
- **`extension/src/wasm.d.ts`** not touched by this PR (verified at Phase 3).
## Test plan
- [ ] `cd extension && npm test` passes (vitest including new tests for typed state, SW storage helpers, `clearWizardState`, drawer auto-close, `vault_locked` channel, `get_vault_status`)
- [ ] `cd extension && npm run build && npm run build:firefox` clean
- [ ] `grep -n ': any' extension/src/shared/state.ts` returns zero
- [ ] `grep -rn 'relicario-wasm' extension/src/setup/` returns zero
- [ ] `wc -l extension/src/setup/setup.ts` ≤ ~500
- [ ] `wc -l extension/src/vault/vault.ts` ~200
- [ ] Manual smoke: load extension → setup → unlock → vault tab → drawer behavior → settings → trash
- [ ] Manual smoke: trigger session expiry, confirm both popup and vault tab show lock screen
- [ ] Manual smoke: vault sidebar status indicator updates on sync
## Done criteria
Per `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` Done criteria — every checkbox.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL (post via `post_message`).
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created at `/home/alee/Sources/relicario-plan-c`, plan absorbed, on `feature/2026-05-04-c-extension-restructure`). Post it via `post_message(from="dev-c", to="pm", kind="status", body="...")`. Then start Phase 1 of your plan (typed `StateHost` + `View`/`PopupState` migration). Remember: phase 1 is the precondition for phases 3 and 4 — do not start them until phase 1 is green.

View File

@@ -0,0 +1,162 @@
# PM Kickoff Prompt — arch-followup architecture-review followups
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the arch-followup "architecture-review followups" release train. 3 senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all 4 terminals; a relay MCP server routes messages between you so the user does not need to copy-paste.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-05-04. 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 (Spanish flourish in chat replies only, capitalization, autonomy defaults, never run git-destructive commands without asking)
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — the canonical synthesis that drove all three plans (10 P1s + P2/P3 tail + 8 open architectural decisions)
3. `docs/superpowers/specs/2026-05-04-security-polish-design.md` — Plan A: security & docs polish (S, independent, ships first)
4. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B: CLI restructure (M-L, multi-day)
5. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — Plan C: extension restructure (L, multi-day to multi-week, largest)
6. `docs/superpowers/coordination/RELAY.md` — multi-agent paradigm + relay reference (you are inside this paradigm right now)
Skim the per-reviewer notes only if a dev's question forces it: `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` (Rust core), `dev-b-notes.md` (Rust consumers), `dev-c-notes.md` (TypeScript).
## 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 (e.g. CHANGELOG entries per merged PR, version bumps if any) — this is your hands-on work
- Coordinate sequencing of the cross-plan touch points (see "Cross-plan coordination" below)
- Tag a milestone (e.g. `arch-followup-complete` or whatever the user prefers) once everything is integrated **— but only after explicit user approval**
## Your boundaries
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` 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`, `rm -rf`).
## Cross-plan coordination (you must enforce)
The three plans were drafted with explicit cross-boundary cites. Hold devs to them:
- **Plan A is fully independent of B and C.** It can ship anytime. There is no merge-order dependency.
- **Plan B Phase 8 (WASM parser exports + `wasm.d.ts` mirror) is a seam Plan C will eventually consume**, but the consumption (SW message handlers wrapping the new exports) is **explicitly deferred to a future plan**. B does NOT block C, and C does NOT block B during execution. The seam exists; nobody is wiring it yet.
- **Both B and C touch `extension/src/wasm.d.ts` at most once each.** If both must touch it in the same window, sequence Plan B's edits first and rebase Plan C on top. Plan C's design says it likely does NOT need to touch `wasm.d.ts` (its `create_vault`/`attach_vault` SW handlers reuse already-declared WASM entries) — verify when C reaches Phase 3.
- **Plan A owns the removal of the `try { current.free() }` swallow** at `extension/src/service-worker/session.ts:26` and the Rust-side `impl Drop for SessionHandle`. Plan C must NOT redo that work, but wherever Plan C *moves* a `.free()` callsite (most notably during the Phase 3 setup-to-SW migration where `setup.ts`'s `verifiedHandle` retires and the new `create_vault`/`attach_vault` handlers acquire their own handles), the new location must call `wasm.lock(handle)` first regardless of Plan A's status.
## Judgment calls in the plans worth flagging
The subagents who drafted the plans flagged these decisions for your awareness:
- **Plan A Phase 4 — `tools/relay/queue.test.ts:54` is already fixed** in commit `061facd` (planning subagent verified). The phase records this as a verification step rather than a code change. Plan A's real Phase 4 work is `tools/relay/start.sh` — both `launch_tmux` and `launch_kitty` still launch only PM/Dev-A/Dev-B (no Dev-C window); the manual-mode banner still says "Open 3 new terminals"; no `*-dev-c-prompt.md` is discovered. That part IS still needed.
- **Plan A `.free()` audit yielded exactly one callsite** under `extension/src/` (the SW one at `session.ts:26`). The plan records the grep command for reproducibility; if a future surface adds a second callsite, this PR's grep becomes the baseline.
- **Plan B Phase 7 — `MonthYear::new` currently returns `Result<_, &'static str>`** (DEV-A's P3 nit). Plan B recommends re-wrapping the error in `MonthYear::parse` rather than migrating `new` to `RelicarioError` — keeps Plan B focused. If you'd rather have Plan B address the consistency now, raise it with the user before Dev-B starts Phase 7.
- **Plan B Phase 8 keeps snake_case naming** for the new WASM exports (consistent with every existing export). The snake_case → camelCase decision (DEV-B/DEV-C P3) is **explicitly deferred to a separate plan**; introducing camelCase only for the three new exports would create a worst-of-both-worlds inconsistency.
- **Plan C Phase 1 — `View` and `PopupState` currently live in `extension/src/popup/popup.ts`** (lines 70-92). The phase 1 typed `StateHost` interface needs to import them, but doing so directly creates a `popup → shared → popup` circular import. The plan calls for migrating those types to `extension/src/shared/types.ts` (or a new `shared/popup-state.ts`) as part of phase 1 before re-importing. Make sure Dev-C has done this migration before consuming TS errors elsewhere.
- **Plan C noted only 7 of ~25 WASM exports** are currently stubbed in `extension/src/__stubs__/relicario_wasm.stub.ts`. Plan C Phase 3's vitest tests for `create_vault`/`attach_vault` will need additional stubs (`embed_image_secret`, `register_device`, `manifest_encrypt`) — Dev-C should round those out as part of the phase rather than file a separate ticket.
If any of these conflict with your judgment, raise it with the user before kickoff.
## The 8 "Open architectural decisions"
The synthesis appendix lists 8 decisions that are user-judgement calls, not implementation tasks:
1. Was `impl Drop for SessionHandle` deliberately omitted? — **Plan A confirms it was not**, and ships the fix.
2. Should CLI parsers migrate to core? — **Plan B Phase 7 says yes** and ships the migration.
3. Bootstrap rule for missing `devices.json`**out of scope**, defer.
4. `Lock` no-op CLI subcommand visibility — **out of scope**, defer.
5. Task 12 cleanup status (`cmd_backup_export` still reads `devices.json`) — **out of scope**, defer.
6. `tools/relay/call.py` and `call.ts` tracking — **already tracked** (per `RELAY.md`).
7. WASM JS naming snake_case → camelCase — **explicitly deferred** to a separate plan (Plan B Phase 8 does NOT take a position).
8. `.gitea_env_vars` gitignore — **already done** in commit `4a726c2`.
You do not need to act on any of these unless the user reopens one. They're listed so you have the context if a dev surfaces a related question.
## Coordination protocol
You are one of 4 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.
**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 — arch-followup
Devs: <per-dev one-line state>
PM: <what you're working on>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev A REVIEW-READY", "Plan A merged">
```
## 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 (synthesis) and the plan's Done criteria checklist
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash — the project preserves git as audit log per `CLAUDE.md`)
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. For Plan A in particular (defense-in-depth crypto fix), an independent review is worth the cost.
## Per-plan acceptance gating
- **Plan A:** every Done-criteria checkbox in `2026-05-04-security-polish-design.md` checked off; the `.free()` audit grep recorded in PR description; both the wasm-bindgen-test and the native `#[test]` for `Drop` present and passing; `recovery_qr.rs` documentation density visibly matches `crypto.rs` / `imgsecret.rs`.
- **Plan B:** every Done-criteria checkbox in `2026-05-04-cli-restructure-design.md` checked off; `cargo test --workspace` green; `cargo clippy --workspace` silent; `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean; the 16 `bail!("git X failed")` sites all collapsed; the 7 `refresh_groups_cache` callsites all using `after_manifest_change`; one canonical `ParamsFile`; batched purge measured (3 git invocations for ≥3 items).
- **Plan C:** every Done-criteria checkbox in `2026-05-04-extension-restructure-design.md` checked off; vitest green; `bun run build` + `bun run build:firefox` clean; no `any` in `StateHost`; SW router files import the new helpers; `setup.ts` ≤ ~500 LOC and does not import `relicario-wasm`; `vault.ts` ~200 LOC of routing+state only; single `vault_locked` channel.
## Pre-tag checklist
Before tagging or otherwise marking the followup train complete:
- [ ] All three plan branches merged to main
- [ ] Full test suite green on main: `cargo test --workspace && cd extension && npm test && cd ../tools/relay && bun test`
- [ ] Standard build green on main: `cargo build && cd extension && npm run build && npm run build:firefox`
- [ ] User-driven smoke test of the merged result (load extension, exercise unlock + setup + a vault op; run the CLI through `relicario init`/`add`/`list`/`sync`)
- [ ] Synthesis P2/P3 tail re-evaluated — anything still worth a follow-up plan? If so, draft a one-line tracker.
- [ ] Explicit user approval to mark the train complete
## 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 synthesis, all three plans, and the cross-plan coordination notes. Note the judgment calls above for the user's awareness.
3. Send opening directives to all three devs via `post_message`:
- **Dev-A:** start Plan A Phase 1 (Rust `impl Drop` + test). It's S-effort and independent — no waiting on anyone.
- **Dev-B:** start Plan B Phase 1 (mechanical split of `cli/main.rs`). Cite that this is the precondition for everything else in B.
- **Dev-C:** start Plan C Phase 1 (typed `StateHost` + `View`/`PopupState` migration). Cite that phase 1 is the precondition for phases 3 and 4. Also cite the migration of `View`/`PopupState` to `shared/types.ts` to avoid the circular import.
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing them to proceed deeper into their plans.

View File

@@ -0,0 +1,64 @@
# CLI Tail — Cycle 2 Coordinator
**Date:** 2026-05-09
**Status:** Draft (launches once cycle-1 prerequisites land)
**Theme:** parallelize the post-split tail of Plan B (the CLI restructure) across three independent streams. Plan B's eight phases are already defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`; this coordinator only partitions the remaining phases across cycle-2 streams and records the cross-stream contracts.
## What this is
The cycle-1 four-agent run (`2026-05-04-arch-followup-*`) ships:
- **Stream A** — Plan A (security + docs polish): `impl Drop for SessionHandle`, JS swallow removal, `recovery_qr.rs` docs, `start.sh` fourth-window. Independent of B and C.
- **Stream B** — Plan B Phases 1 + 2 only (mechanical `main.rs` split + `helpers::git_run` + 16-site sweep). Stops after Phase 2 per a 2026-05-09 user-driven RESCOPE directive.
- **Stream C** — Plan C (extension restructure). Did not launch in cycle 1 (DEV-C never acked); remains pending and is *not* picked up by cycle 2 (still its own multi-week effort, separate kickoff).
The remaining six Plan B phases (3 through 8) are partitioned across three cycle-2 streams below. Each cycle-2 stream is independent of the other two once cycle-1 Stream B (Phase 1 + 2) has merged to `main`.
## Pre-launch checklist (cycle 2 cannot open until all green)
- [ ] Cycle-1 Stream A merged to `main`
- [ ] Cycle-1 Stream B PR (Phase 1 + 2 bundle) merged to `main`
- [ ] Working tree clean on `main`; `git pull` reflects both merges
- [ ] All cycle-1 worktrees torn down (`git worktree remove ../relicario.arch-followup-stream-a` and `*-stream-b`); cycle-1 branches deleted locally if requested
- [ ] Relay server still running on `localhost:7331` (check `ss -ltn 'sport = :7331'`)
- [ ] Cycle-2 kickoff prompts present in `docs/superpowers/coordination/2026-05-09-cli-tail-{pm,dev-a,dev-b,dev-c}-prompt.md`
## Stream partition
| Stream | Branch | Worktree | Plan B phases | Theme |
|---|---|---|---|---|
| A | `feature/cli-tail-stream-a-prompt-helpers` | `/home/alee/Sources/relicario.cli-tail-stream-a` | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
| B | `feature/cli-tail-stream-b-session-manifest` | `/home/alee/Sources/relicario.cli-tail-stream-b` | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
| C | `feature/cli-tail-stream-c-core-wasm-seam` | `/home/alee/Sources/relicario.cli-tail-stream-c` | Phases 7, 8 | parser migration to `relicario-core` + base32 dedup + WASM exports |
Phases reference the canonical definitions in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`. Devs do NOT redesign — they execute against that spec.
## Cross-stream dependencies (cycle 2)
- **Stream A and Stream B**: both touch `crates/relicario-cli/src/commands/*.rs` files but in disjoint ways. Stream A modifies `commands/add.rs` (the seven `build_*_item` builders). Stream B modifies `commands/init.rs` (`ParamsFile`), `commands/trash.rs` (batched purge), and seven manifest-mutation sites scattered across `commands/{add,edit,trash,attach,settings,import}.rs`. Conflict surface is `commands/add.rs` (A modifies builders; B modifies the `after_manifest_change` callsite). Whoever opens their PR second rebases.
- **Stream B internal sequencing**: Phase 6 (batched purge) depends on Phase 4 (`after_manifest_change` wrapper) — Phase 6's commit message logic uses the wrapper. Phase 5 (`ParamsFile`) is independent of 4 and 6 within Stream B; can ship first, last, or middle.
- **Stream C**: touches `crates/relicario-core/`, `crates/relicario-wasm/`, and `extension/src/wasm.d.ts` only. Zero overlap with Streams A and B. Internal sequencing: Phase 7 (parser migration to core) before Phase 8 (WASM exports + `wasm.d.ts` mirror).
- **No cross-stream interface contracts.** All three plans were finalized in cycle 1; the partition does not introduce new contracts.
## Pre-merge checklist (per cycle-2 stream)
Same as cycle 1, plus a narration check:
- [ ] Stream's owned phases all complete per Plan B's "Done criteria"
- [ ] `cargo test --workspace` green on the stream's worktree
- [ ] `cargo clippy --workspace` silent
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, but Stream C in particular)
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` tests pass without modification
- [ ] Narration discipline observed — STATUS UPDATEs include in-flight beats, not just phase boundaries
- [ ] PR description cross-references the corresponding Plan B phase numbers
## Out of scope for cycle 2
- Plan C (extension restructure) — multi-week effort, scheduled separately when DEV-C bandwidth available
- The Plan B `helpers::git_run` itself (shipped in cycle 1 Stream B)
- The cycle-1 P3 nits explicitly out-of-scope in Plan B
- The eight "Open architectural decisions" from the synthesis
## Tag
No release tag for cycle 2. Same as the cycle-1 architecture-review followup train — these are structural-cleanup bundles, not versioned releases. Each stream merges via `gh pr merge --merge` (preserve history; no squash per project convention).

View File

@@ -0,0 +1,199 @@
# Dev A Kickoff Prompt — CLI Tail (Cycle 2) Stream A
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream A of the CLI-tail cycle-2 release.
Stream A is **Plan B Phase 3**`prompt_or_flag<T>` helper plus the seven `build_*_item` builder compression in the CLI. Single phase, S-M effort. The phase is defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression". Cycle 1 already shipped the mechanical `main.rs` split (Phase 1) and the `helpers::git_run` sweep (Phase 2), so the file tree under `crates/relicario-cli/src/commands/` and `prompt.rs` is in place — your job is to add the helper to `prompt.rs` and refactor the seven builders in `commands/add.rs`.
A PM in another terminal coordinates you with Dev-B (session/manifest discipline — Phases 4, 5, 6) and Dev-C (parser migration + WASM seam — Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add ../relicario.cli-tail-stream-a -b feature/cli-tail-stream-a-prompt-helpers
cd ../relicario.cli-tail-stream-a
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-a
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-a`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-a` so subagents don't accidentally commit to main. This is non-negotiable.
Today: 2026-05-09. 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"}'
```
**Cycle-1 lessons baked in (read once):**
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phase 3 only
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (your scope is **Phase 3 only**; read the whole plan for context, but execute Phase 3)
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the `build_*_item` discussion (line-level context for the seven builders the synthesis abbreviates)
## Execution mode
Use **subagent-driven-development** (per `CLAUDE.md` memory default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per sub-step, two-stage review.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario.cli-tail-stream-a
```
…before any other instruction. Non-negotiable per project memory.
## Your scope and boundaries
**In scope:** Plan B Phase 3 — adding `prompt_or_flag<T>` (and `prompt_or_flag_optional<T>`) to `crates/relicario-cli/src/prompt.rs`, then refactoring the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper. Per-type bodies should shrink by ~30%.
**Out of scope:**
- Phases 4, 5, 6 (Dev-B owns) — `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
- Anything outside Plan B's Phase 3 definition. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- Do not change the CLI's external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination protocol
You are one of four terminals. The user runs all four; the PM in another terminal coordinates you.
**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 (so the user sees what's running)
- When a subagent returns with a decision worth flagging (an unexpected finding, a trade-off taken, a surprise)
- When a sub-task completes (e.g. `prompt_or_flag` helper landed; first builder converted)
- When you change direction or hit something unexpected
- When you start a new sub-step
The `Notes` field should narrate WHAT happened and WHY — not just "Phase 3 done". Three sentences max. Examples of useful: "subagent reported `build_login_item` already takes Result-wrapped fields, so the conversion is just chain-flattening"; "found one builder uses `prompt_secret`, kept it on raw `prompt_secret` since `prompt_or_flag` doesn't handle the no-echo case." Examples of NOT useful: "builder converted" with no context; "tests pass" with no count.
Print every STATUS UPDATE locally before/after sending it so the user reads it in your own terminal.
**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 here. Format:
```
## STATUS UPDATE — DEV-A
Time: <iso8601>
Branch: feature/cli-tail-stream-a-prompt-helpers
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: <WHAT and WHY — 3 sentences max>
```
**When you need PM input mid-task**: post via `post_message(kind="question")`:
```
## 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
```
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM.
## Ship-it autonomy + simplify discipline
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** 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`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
**Speed without spaghetti — required before every REVIEW-READY:**
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and 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 find yourself writing similar code twice, extract — even if the spec only mentioned one site.
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
## Authority within Phase 3
You don't need PM permission to:
- Execute sub-steps per Plan B's Phase 3
- Make implementation decisions consistent with Plan B
- Write tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate when:
- A scope question outside Plan B Phase 3
- A test you can't make green after honest debugging
- A discovered bug not in Plan B
- Anything destructive (per `CLAUDE.md`)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd /home/alee/Sources/relicario.cli-tail-stream-a
cargo test --workspace
cargo clippy --workspace
cargo build -p relicario-wasm --target wasm32-unknown-unknown
```
All three must be green / clean. Then push and open the PR:
```bash
git push -u origin feature/cli-tail-stream-a-prompt-helpers
gh pr create --base main --head feature/cli-tail-stream-a-prompt-helpers --title "refactor(cli): prompt_or_flag helper + build_*_item compression (Plan B Phase 3)" --body "$(cat <<'EOF'
## Summary
- Adds `prompt_or_flag<T>` and `prompt_or_flag_optional<T>` to `crates/relicario-cli/src/prompt.rs`
- Refactors the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper
- Per-type bodies shrink by ~30%; existing CLI integration tests pass without modification
## Plan B Phase 3
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phase 3.
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
## Test plan
- [x] cargo test --workspace
- [x] cargo clippy --workspace
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
- [x] Existing crates/relicario-cli/tests/* pass without modification
🤖 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 `feature/cli-tail-stream-a-prompt-helpers`), then start Phase 3 sub-step 1 (add `prompt_or_flag<T>` to `prompt.rs`).

View File

@@ -0,0 +1,210 @@
# Dev B Kickoff Prompt — CLI Tail (Cycle 2) Stream B
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream B of the CLI-tail cycle-2 release.
Stream B is **Plan B Phases 4, 5, 6** — session/manifest discipline. Three phases, S-M effort each, total mid-day to multi-day:
- **Phase 4** — `Vault::after_manifest_change(&self, manifest: &Manifest)` wrapper that funnels the seven manifest-mutation sites in `commands/{add,edit,trash,attach,settings,import}.rs` through one `save_manifest + groups-cache write` path. Marks `save_manifest` as `pub(crate)` (or renames it `save_manifest_raw`) so callers must use the wrapper.
- **Phase 5** — Single canonical `ParamsFile` in `crates/relicario-cli/src/session.rs`, replacing the two-definition split between `commands/init.rs` (write side) and `session.rs:114` (read side). Adds `Serialize` + `Deserialize`, `for_new_vault` constructor, `into_kdf_params` inversion. On-disk JSON format must round-trip with current `params.json` files.
- **Phase 6** — Batched purge in `cmd_purge` and `cmd_trash_empty`. Renames `purge_item` to `purge_item_filesystem` (filesystem mutation only); the callers accumulate paths and run a single `git_run(...["rm", "-rf", "--ignore-unmatch", paths...])` plus `git_run(...["add", "manifest.enc"])` plus one `git_run(...["commit"])` per batch. A 50-item `trash empty` should fire 3 git invocations total, not 150.
The phases are defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 4", "Phase 5", "Phase 6". Internal sequencing: Phase 4 before Phase 6 (Phase 6 uses `after_manifest_change`); Phase 5 is independent of 4 and 6.
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-C (Plan B Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add ../relicario.cli-tail-stream-b -b feature/cli-tail-stream-b-session-manifest
cd ../relicario.cli-tail-stream-b
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-b
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-b`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-b` so subagents don't accidentally commit to main. Non-negotiable.
Today: 2026-05-09. 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"}'
```
**Cycle-1 lessons baked in (read once):**
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 4, 5, 6 only
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 4, 5, 6)
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only)
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes; the relevant sections are `refresh_groups_cache` discipline, `ParamsFile` dedup, batched purge
## Execution mode
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario.cli-tail-stream-b
```
…before any other instruction.
## Your scope and boundaries
**In scope:** Plan B Phases 4, 5, 6.
**Out of scope:**
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + `build_*_item` compression
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
- Anything outside Plan B Phases 4-6. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- Phase 5 must round-trip with existing on-disk `params.json` — write a fixture-string test that reads a known-current params.json and asserts the canonical struct parses it identically. On-disk format change would break existing vaults.
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
- The `groups.cache` plaintext "failures silently swallowed" doc-comment from current `helpers.rs:90-93` must be preserved on the new `after_manifest_change` wrapper. Don't change the policy.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
**Internal phase sequencing (within Stream B):**
- Phase 5 (`ParamsFile`) is independent — ship first to get it out of the way, OR last for diff-locality with the session-touching Phase 4. Either is fine; pick whichever reviews more cleanly.
- Phase 4 (`after_manifest_change`) before Phase 6 (`batched purge`). Phase 6's commit logic relies on the wrapper.
## Coordination protocol
You are one of four terminals. The PM coordinates you with Dev-A and Dev-C.
**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 with a decision worth flagging (a found-but-unexpected coupling, a trade-off taken)
- When a sub-task completes (e.g. `after_manifest_change` wrapper landed; first manifest-mutation site converted; `ParamsFile` round-trip test green)
- When you change direction or hit something unexpected
- When you start a new phase
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "Phase 5 fixture test caught that `format_version` was previously emitted but never read; preserved the field but kept the read side tolerant"; "found one manifest-mutation site in `commands/import.rs` that did NOT call `refresh_groups_cache` historically (DEV-B notes flagged 7 sites; this is an 8th — surfacing as a question)." Print every STATUS UPDATE locally too.
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")`, then post and print using:
```
## STATUS UPDATE — DEV-B
Time: <iso8601>
Branch: feature/cli-tail-stream-b-session-manifest
Task: <phase number / sub-step>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which failed) | N/A>
Notes: <WHAT and WHY — 3 sentences max>
```
**For PM input mid-task**:
```
## 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
```
## Ship-it autonomy + simplify discipline
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** 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`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
**Speed without spaghetti — required before every REVIEW-READY:**
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and 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 find yourself writing similar code twice, extract — even if the spec only mentioned one site.
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
## Authority within Phases 4-6
You don't need PM permission to:
- Execute sub-steps per Plan B's Phases 4, 5, 6
- Make implementation decisions consistent with Plan B
- Write tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate when:
- A scope question outside Plan B Phases 4-6
- A test you can't make green after honest debugging
- A discovered bug not in Plan B
- Anything destructive (per `CLAUDE.md`)
- Before opening the PR for review
- If you find an unexpected manifest-mutation site beyond the seven DEV-B notes flagged (likely surfaces in Phase 4)
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd /home/alee/Sources/relicario.cli-tail-stream-b
cargo test --workspace
cargo clippy --workspace
cargo build -p relicario-wasm --target wasm32-unknown-unknown
```
All three must be green / clean. Then push and open the PR:
```bash
git push -u origin feature/cli-tail-stream-b-session-manifest
gh pr create --base main --head feature/cli-tail-stream-b-session-manifest --title "refactor(cli): session/manifest discipline (Plan B Phases 4, 5, 6)" --body "$(cat <<'EOF'
## Summary
- Phase 4 — `Vault::after_manifest_change` wrapper funnels seven manifest-mutation sites; `save_manifest` made `pub(crate)` so callers can't bypass the wrapper
- Phase 5 — Single canonical `ParamsFile` in `session.rs` replaces the two-definition split; on-disk JSON round-trips with existing vaults (fixture-string test)
- Phase 6 — Batched purge: a 50-item `trash empty` now fires 3 git invocations instead of 150
## Plan B Phases 4-6
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 4, 5, 6.
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
## Test plan
- [x] cargo test --workspace
- [x] cargo clippy --workspace
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
- [x] params.json round-trip test against existing on-disk format
- [x] `trash empty` with N items produces 1 commit (regression invariant)
- [x] Existing crates/relicario-cli/tests/* pass without modification
🤖 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 `feature/cli-tail-stream-b-session-manifest`), then start Phase 4 (or Phase 5 if you prefer to ship the independent piece first — call it out in the status update).

View File

@@ -0,0 +1,219 @@
# Dev C Kickoff Prompt — CLI Tail (Cycle 2) Stream C
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream C of the CLI-tail cycle-2 release.
Stream C is **Plan B Phases 7 and 8** — the parser migration to `relicario-core` plus the WASM seam. Two phases, M effort:
- **Phase 7** — Migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` from `crates/relicario-cli/src/parse.rs` into `relicario-core` (`MonthYear::parse` on `time.rs`, new `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648_lenient`, new `mime::guess_for_extension`). Pair with DEV-A's P2 base32 dedup: extract the inline `base32_encode` from `crates/relicario-core/src/item.rs:255-275` and `decode_base32_totp` from `crates/relicario-core/src/import_lastpass.rs:202-220` into the new shared module. Steam's `STEAM_ALPHABET` at `item_types/totp.rs:13` stays untouched (with a neighbour comment). The CLI's `parse.rs` becomes a thin re-export shim — no callsite changes in cycle 2.
- **Phase 8** — `#[wasm_bindgen]` exports for the three migrated parsers (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) plus the matching declarations in `extension/src/wasm.d.ts`. snake_case JS naming consistent with every existing export. Plan C (extension restructure) does NOT consume these this round — the seam ships in cycle 2; consumption is a future plan.
Phase definitions are canonical in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8. Internal sequencing: Phase 7 before Phase 8.
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-B (Plan B Phases 4, 5, 6). With the relay server running, you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git worktree add ../relicario.cli-tail-stream-c -b feature/cli-tail-stream-c-core-wasm-seam
cd ../relicario.cli-tail-stream-c
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-c
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-c`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-c` so subagents don't accidentally commit to main. Non-negotiable.
Today: 2026-05-09. 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"}'
```
**Cycle-1 lessons baked in (read once):**
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 DEV-A and DEV-B both hit this; documenting once here so cycle-2 DEV-C does not.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 7 + 8 only
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 7 and 8)
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
5. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — DEV-A's notes; the relevant section is the P2 "three base32 implementations" finding (the dedup that pairs with your Phase 7)
6. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the parser-migration P2 (line-level context for `parse_month_year`, `base32_decode_lenient`, `guess_mime`)
7. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read **only** the "Boundary notes for DEV-B" section (cross-boundary contracts — `wasm.d.ts` is hand-maintained; every change must mirror; BigInt typing care for `attachment_encrypt`-style paths, but your three new exports take only `&str` and return primitives so they avoid that class)
## Execution mode
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review.
**Every subagent prompt MUST start with**:
```
cd /home/alee/Sources/relicario.cli-tail-stream-c
```
…before any other instruction.
## Your scope and boundaries
**In scope:** Plan B Phases 7 and 8 — parser migration to `relicario-core` (paired with DEV-A P2 base32 dedup), then WASM exports + `extension/src/wasm.d.ts` mirror.
**Out of scope:**
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + builder compression
- Phases 4, 5, 6 (Dev-B owns) — session/manifest discipline
- Plan C (extension restructure) — consumption of your new WASM exports is explicitly deferred to a future plan; you ship the seam, you do NOT wire SW message handlers in the extension.
- Anything outside Plan B Phases 7-8. If you trip over an out-of-scope issue (e.g. a fourth base32 implementation surfaces; a parser the CLI uses that wasn't in Plan B's three), file a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- Steam's `STEAM_ALPHABET` at `crates/relicario-core/src/item_types/totp.rs:13` is intentionally non-RFC-4648; do NOT consolidate it into the new shared base32 module. Add a neighbour comment: `// not RFC 4648 — Steam Guard's de-ambiguated alphabet; see crate::base32 for the standard impl.`
- The CLI's `parse.rs` becomes a thin re-export shim — keep callsite imports unchanged in cycle 2 (no caller-side import churn).
- WASM JS naming stays snake_case for the three new exports — consistent with every existing `#[wasm_bindgen]` export. Do NOT introduce camelCase here; that decision is explicitly deferred per Plan B.
- `extension/src/wasm.d.ts` mirror lands in the same commit as the Rust `#[wasm_bindgen]` additions. Both sides updated together; no half-state.
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
**Internal phase sequencing (within Stream C):**
- Phase 7 (parser migration to core + base32 dedup) before Phase 8 (WASM exports). Phase 8 imports from the new core paths; Phase 7 must compile clean first.
## Coordination protocol
You are one of four terminals. The PM coordinates you with Dev-A and Dev-B.
**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 with a decision worth flagging (an unexpected coupling, an alternative API shape considered, a found-but-flagged out-of-scope issue)
- When a sub-task completes (e.g. base32 module landed; `MonthYear::parse` integrated; first WASM export wired)
- When you change direction or hit something unexpected
- When you start a new phase
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "subagent surfaced a fourth base32 callsite in `crates/relicario-core/src/manifest.rs:??`; not in DEV-A P2's flagged list — escalating as a question"; "kept `MonthYear::parse` returning `Result<Self, RelicarioError>` rather than touching `MonthYear::new`'s `&'static str` per Plan B's recommendation; `new`-to-`RelicarioError` is DEV-A's separate P3"; "WASM exports compile clean; `wasm.d.ts` mirror passes `tsc --noEmit` in `extension/`." Print every STATUS UPDATE locally too.
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message` and print here. Format:
```
## STATUS UPDATE — DEV-C
Time: <iso8601>
Branch: feature/cli-tail-stream-c-core-wasm-seam
Task: <phase number / sub-step>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which failed) | N/A>
Notes: <WHAT and WHY — 3 sentences max>
```
**For PM input mid-task**:
```
## 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
```
## Ship-it autonomy + simplify discipline
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** 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`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
**Speed without spaghetti — required before every REVIEW-READY:**
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and 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 find yourself writing similar code twice, extract — even if the spec only mentioned one site.
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
## Authority within Phases 7-8
You don't need PM permission to:
- Execute sub-steps per Plan B's Phases 7 and 8
- Make implementation decisions consistent with Plan B
- Write tests, refactor your own code, fix bugs you introduce
- Push commits to your feature branch
You **do** escalate when:
- A scope question outside Plan B Phases 7-8
- A test you can't make green after honest debugging
- A discovered bug not in Plan B
- A fourth base32 implementation or a parser surfaces beyond DEV-A P2 + Plan B's three
- Anything destructive (per `CLAUDE.md`)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd /home/alee/Sources/relicario.cli-tail-stream-c
cargo test --workspace
cargo clippy --workspace
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cd extension && npm run test # verify wasm.d.ts mirror compiles against TS callers
```
All four must be green / clean. Then push and open the PR:
```bash
cd /home/alee/Sources/relicario.cli-tail-stream-c
git push -u origin feature/cli-tail-stream-c-core-wasm-seam
gh pr create --base main --head feature/cli-tail-stream-c-core-wasm-seam --title "refactor(core,wasm): migrate parsers + base32 dedup + WASM exports (Plan B Phases 7, 8)" --body "$(cat <<'EOF'
## Summary
- Phase 7 — `parse_month_year`, `base32_decode_lenient`, `guess_mime` migrated from CLI to `relicario-core` (`MonthYear::parse`, new `pub(crate) mod base32`, new `mime::guess_for_extension`); base32 dedup folds `crates/relicario-core/src/item.rs:255-275` and `import_lastpass.rs:202-220` into the new shared module (Steam alphabet untouched per neighbour comment)
- Phase 8 — `#[wasm_bindgen]` exports for the three migrated parsers; `extension/src/wasm.d.ts` mirror updated in the same commit; snake_case JS naming consistent with existing exports
- The CLI's `parse.rs` is a thin re-export shim; existing CLI callsites unchanged
## Plan B Phases 7-8
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8.
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
## Test plan
- [x] cargo test --workspace
- [x] cargo clippy --workspace
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
- [x] cd extension && npm run test (verifies wasm.d.ts compiles)
- [x] Existing crates/relicario-cli/tests/* pass without modification
- [x] Existing crates/relicario-core/tests/* pass without modification
## Out of scope (deferred)
- Extension consumption of the new WASM exports — Plan C territory; no SW message handlers wired in this PR
- camelCase JS naming for the three new exports — explicitly snake_case per Plan B; the camelCase decision is its own future plan
🤖 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 `feature/cli-tail-stream-c-core-wasm-seam`), then start Phase 7 sub-step 1 (create `crates/relicario-core/src/base32.rs` with the unified `encode_rfc4648` / `decode_rfc4648_lenient` shape).

View File

@@ -0,0 +1,145 @@
# PM Kickoff Prompt — CLI Tail (Cycle 2)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the CLI-tail cycle-2 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals.
This release has no version tag — it's the second cycle of the architecture-review structural-cleanup bundle. Cycle 1 shipped Plan A (security + docs polish) and Plan B Phases 1 + 2 (mechanical `main.rs` split + `git_run` helper). Cycle 2 partitions the remaining six Plan B phases (3 through 8) across three independent streams. Plan C (extension restructure) is *not* in cycle 2 — it stays pending until DEV-C bandwidth is available, on its own kickoff.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-05-09. Project rules in `CLAUDE.md` apply (Spanish flourish in chat replies only, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking, default to subagent-driven execution, force-cd subagents into their worktree).
**Pre-launch state assumed:** cycle-1 Stream A merged, cycle-1 Stream B PR (Phase 1 + 2) merged, working tree clean on `main`, relay server alive on `localhost:7331`. Verify with `git log --oneline -5` and `ss -ltn 'sport = :7331'` before sending opening directives. If either is not in place, surface to the user before proceeding.
## Cycle 1 outcomes (read for context — your context starts cold)
The cycle-1 four-agent run (`docs/superpowers/coordination/2026-05-04-arch-followup-*-prompt.md`) produced:
- **Stream A (security + docs polish)** merged to `main`. Key commits: `1e858e1` impl Drop for SessionHandle, `03d0781` SW free() unswallow, `229e483` recovery_qr.rs documentation, `f8296fa` rustdoc warning fix on a private intra-doc link, `0c9387f` start.sh fourth-window. Plan A complete.
- **Stream B (CLI restructure Phases 1 + 2 only)** merged to `main` per a 2026-05-09 RESCOPE directive that halted Plan B at Phase 2 to enable cycle-2 parallelization. Key commits: `97c8f99` 15-site git_run sweep, `f3cdbed` git_run helper. `main.rs` shipped at 509 LOC (vs spec's ≤500); the 9-LOC overshoot is `#[arg(...)]` attribute density on 9 sub-enums and was accepted at merge — substance criterion (clap surface + dispatch + 2 shim families only) was met. DEV-B chose Plan B's option (b) for `git_run` (capture stderr + replay on failure) over option (a) (terminal-aware streaming).
- **Stream C (extension restructure)** did NOT launch in cycle 1 (cycle-1 DEV-C never acked). Plan C remains pending and is *not* part of cycle 2 — it is a multi-week effort scheduled separately on its own kickoff.
- **17 pre-existing extension test failures** on the kickoff baseline `bd3d53f` were documented in cycle-1 Stream A's PR. They sit in `extension/src/{service-worker,popup}/...` (devices/router/settings clusters) and pre-date the architecture review. Treat as the regression baseline: any cycle-2 red test outside this 17-failure cluster is a new regression and a stream's responsibility.
## Lessons learned (bake into your coordination)
Cycle 1 surfaced three operational gotchas worth pre-empting:
- **Prefer single-line relay message bodies.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals in body content. Compose `body` fields as a single line with sentences separated by periods; use ` -- ` for stronger breaks. The relay itself accepts multi-line bodies, but the consuming dev's monitor may not.
- **Python f-string footgun in inbox-monitor scripts.** If a dev reports a `SyntaxError: unexpected character after line continuation character`, their polling script likely uses `print(f"... {m.get(\"from\")} ...")` — Python f-strings cannot contain backslash-escaped quotes inside brace expressions. Fix is single quotes: `m.get('from')`.
- **Narration policy is non-negotiable.** Cycle 1 added it mid-run; cycle 2 has it baked into every kickoff. Devs MUST emit `Status: IN-PROGRESS` updates at meaningful in-flight moments (subagent dispatch, surprise findings, sub-task complete, phase start), not just at phase boundaries. You MUST narrate to the user in plain prose between tool calls — when a STATUS UPDATE lands, summarize it for the user before deciding; when you send a directive, state the rationale; when you dispatch a subagent, say so. Enforce both.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md`**partition spec for this cycle. The canonical source for who owns what.**
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (phase definitions). Cycle 2 executes Phases 3 through 8.
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — original synthesis (read the P-tags Plan B addresses: P1.2, P1.3, P1.10, plus the four CLI P2s)
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (line-level context the synthesis abbreviates)
You do NOT need to read Plans A or C in detail — they're out of cycle-2 scope. Skim the partition coordinator's "Cross-stream dependencies" section so you know what conflicts to watch for.
## Stream overview (from coordinator)
| Stream | Branch | Owner | Plan B phases | Theme |
|---|---|---|---|---|
| A | `feature/cli-tail-stream-a-prompt-helpers` | DEV-A | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
| B | `feature/cli-tail-stream-b-session-manifest` | DEV-B | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
| C | `feature/cli-tail-stream-c-core-wasm-seam` | DEV-C | Phases 7, 8 | parser migration to core + base32 dedup + WASM exports |
**No interface contracts between streams.** All three are independent once the cycle-1 PRs have merged. Conflict surface: `commands/add.rs` (A modifies builders; B modifies a manifest-mutation callsite). Whichever stream opens its PR second rebases.
## Your authority
- Approve or deny scope changes from devs
- Review and merge PRs from each stream's feature branch
- Edit `docs/`, `CLAUDE.md`, or other doc artifacts as needed; do not write feature code
## Your boundaries
- Don't write feature code yourself. Edits to docs / `CLAUDE.md` are fine.
- Don't deviate from Plan B's phase definitions 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 — no tag planned for this cycle.
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
## 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:
```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"}'
```
## Coordination protocol
You are one of four terminals. Use `post_message` / `read_messages` directly. Call `read_messages(for="pm")` before every action.
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is this 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.
**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, give a current rollup:
```
## RELEASE STATUS — CLI Tail (Cycle 2)
Devs: <per-dev one-line state>
PM: <what you're working on>
Blockers: <list, or "none">
Next milestone: <e.g., "Stream A REVIEW-READY", "all three streams merged">
```
## 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 Plan B's "Done criteria" entries for that stream's phases
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash per project convention)
5. If red: post `Action: HOLD` with specific concerns
Use `superpowers:requesting-code-review` if you want a deeper independent review from a fresh subagent before approving.
## Pre-merge checklist (per stream)
Before each `MERGE-APPROVED`:
- [ ] Plan B's "Done criteria" for the stream's owned phases all checked
- [ ] `cargo test --workspace` green on the stream's worktree
- [ ] `cargo clippy --workspace` silent
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, Stream C especially)
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` pass without modification
- [ ] Narration discipline observed in the PR's STATUS UPDATE history
## First action
1. Call `read_messages(for="pm")` to drain any early inbox messages.
2. Verify pre-launch state: `git log --oneline -5 main`, `git status`, `ss -ltn 'sport = :7331'`. If any check fails, surface to the user before proceeding.
3. Emit a `## RELEASE STATUS` block confirming context absorbed.
4. Wait for setup-acknowledge STATUS UPDATEs from all three devs (their kickoff prompts have them post one after creating their worktree). Once all three are in, post opening `PROCEED` directives confirming each stream's plan path and phase scope.
5. Standing watch: drain inbox before each action; respond to QUESTIONs and STATUS UPDATEs as they arrive.

View File

@@ -0,0 +1,199 @@
# RELAY — Multi-Agent Kickoff & Coordination
How to spin up parallel Claude Code sessions that coordinate over a shared MCP relay. One PM, two or more Devs, each in their own terminal, each on their own branch / worktree, exchanging structured messages.
## TL;DR — three commands
```bash
# 1. Generate kickoff prompts (interactive — answers the design questions)
# In any Claude Code session in this repo:
/multi-agent-kickoff
# 2. Start the relay + open the windows
bash tools/relay/start.sh --kitty # or --tmux, or --manual
# 3. In each new Claude window, paste the prompt below the `---` line
# from the file the launcher prints (e.g. coordination/<date>-pm-prompt.md)
```
That's the whole workflow. Everything below is the why and the troubleshooting.
## What this is
The "PM/Senior-Dev paradigm" — one Claude session acts as project manager, two or more Claude sessions act as senior developers, each running its own subagents on a feature branch in its own worktree. They coordinate by sending each other typed messages (status / question / directive / free) through a tiny MCP server running locally.
When to use it:
- You have **2+ implementation plans** that share a release target and want to execute them in parallel under one coordinator.
- You want each stream isolated (separate worktree, separate branch) so subagents can't accidentally commit to main or step on each other's files.
- You want one human (the user) to be a relay-of-last-resort but not a router — the PM does the routing.
When NOT to use it: one-off tasks, single-stream plans, anything where the overhead of "spin up four windows" exceeds the work itself. For those, just work in the foreground.
## The pieces
```
┌──────────────────┐ HTTP/SSE ┌──────────────────┐
│ Relay (MCP) │◀───────────│ PM session │
│ tools/relay/ │ │ (Claude Code) │
│ port 7331 │ └──────────────────┘
│ │ ┌──────────────────┐
│ Per-role inbox │◀───────────│ Dev-A session │
│ in-memory │ │ (Claude Code) │
│ consume-once │ └──────────────────┘
└──────────────────┘ ┌──────────────────┐
◀────────│ Dev-B session │
│ (Claude Code) │
└──────────────────┘
┌──────── (optional) ───────┐
◀────────│ Dev-C session │
└───────────────────┘
```
- **Relay MCP server** — `tools/relay/server.ts`. HTTP/SSE on `localhost:7331`. Exposes three MCP tools: `post_message`, `read_messages`, `list_pending`. Per-connection MCP server instance prevents routing collisions across concurrent SSE clients.
- **In-memory queue** — `tools/relay/queue.ts`. Per-role inbox (`pm`, `dev-a`, `dev-b`, `dev-c`). `read` is consume-once (FIFO drain). No TTL, no persistence, no cap — relay is dev-only ephemeral; restart the server to wipe state.
- **Launcher** — `tools/relay/start.sh`. Three modes (manual / tmux / kitty) that all start the relay and either open the role windows or print the commands for you to open them by hand.
- **Fallback shim** — `tools/relay/call.py` (Python) and `tools/relay/call.ts` (TS). Direct CLI access to the same MCP tools, for when the in-Claude MCP client isn't loading or you want to script a status check from a regular shell. Both are tracked in the repo and load-bearing for the multi-agent flow — do not delete.
## Invocation
### Step 1 — Generate the kickoff prompts
In any Claude Code session inside this repo, run:
```
/multi-agent-kickoff
```
The skill walks you through a short Q&A (release target, branch names per dev, plan-file paths, coordination cadence) and writes four prompt files to `docs/superpowers/coordination/`:
```
<date>-pm-prompt.md
<date>-dev-a-prompt.md
<date>-dev-b-prompt.md
<date>-dev-c-prompt.md (only if 3 devs)
```
Each prompt is self-contained: it tells the receiving session its role, its branch / worktree, the plan it owns, and the coordination protocol (block format, when to send status, who to escalate to). The launcher script discovers the latest `*-pm-prompt.md` / `*-dev-a-prompt.md` / etc. by `mtime`, so the most recently generated set wins automatically.
### Step 2 — Start the relay
Pick a launcher mode that matches your terminal setup.
**`--kitty` (recommended on kitty)**
```bash
bash tools/relay/start.sh --kitty
```
Opens 4 (or 5 with dev-c) tabs in the current kitty window: one for the relay log, one per role. Each role tab launches `claude` in the repo root. Paste the corresponding prompt into each role tab to start the session.
**`--tmux` (recommended on non-kitty)**
```bash
bash tools/relay/start.sh --tmux
```
Creates a tmux session `relay-lift` with windows `relay`, `pm`, `dev-a`, `dev-b` (and `dev-c` if a fourth prompt is found). Attaches automatically. `Ctrl-b N` to navigate windows. Detach with `Ctrl-b d`.
**`--manual` (for any terminal)**
```bash
bash tools/relay/start.sh --manual
```
Starts the relay in the current terminal and prints `cat <path>` commands for each role. Open new terminals yourself and paste the printed commands; this is the most flexible mode for unusual setups (split panes, remote sessions, terminal multiplexers other than tmux).
The launcher uses port **7331**. If it's already in use the script aborts with the kill command — `kill $(lsof -ti:7331)` clears it.
### Step 3 — Drive the coordination
The PM session is the entry point. Talk to PM about goals; PM decides who's working on what and posts directives to the dev sessions via the relay. Each dev reads its inbox, executes, and posts back status / questions. The user (you) is mostly a watcher — PM should self-route.
Common rhythm:
- **PM at start:** posts a directive to each dev describing the first slice.
- **Dev on completion:** posts status with branch / commit / what shipped.
- **Dev when blocked:** posts a question; PM unblocks (decision) or escalates to user.
- **PM end-of-cycle:** asks each dev for a status, summarizes, decides next slice.
Message kinds (`MessageKind` in `queue.ts`):
| Kind | Use when |
|------|----------|
| `status` | "I shipped X, branch is at Y, ready for next slice" |
| `question` | "Should I do A or B? Blocking until I hear back" |
| `directive` | PM-to-dev: "Next, do X. Constraints are Y. Acceptance is Z." |
| `free` | Anything that doesn't fit the above (FYI, side-channel chatter) |
Block format inside `body` is freeform markdown. The kickoff prompts include the project's preferred block templates.
## Fallback — when the MCP client misbehaves
If a Claude session can't reach the relay's MCP tools (transient SSE hiccup, MCP server failed to register, sandboxed network), use the shim:
```bash
# From any shell, with the relay running on 7331:
python3 tools/relay/call.py read_messages '{"for":"pm"}'
python3 tools/relay/call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"shipped X"}'
python3 tools/relay/call.py list_pending '{"for":"dev-b"}'
```
`call.ts` is the same surface in TypeScript (`bun run tools/relay/call.ts ...`) for when you want to script from a TS context. Both shims speak raw MCP over the SSE transport; output is the JSON-RPC response.
The kickoff prompts reference `call.py` by path — if the in-Claude MCP client breaks mid-session, the dev can fall back to `Bash python3 tools/relay/call.py ...` and keep coordinating without restarting.
## Where things live
```
docs/superpowers/coordination/
├── RELAY.md ← you are here
├── <date>-pm-prompt.md generated by /multi-agent-kickoff
├── <date>-dev-a-prompt.md
├── <date>-dev-b-prompt.md
├── <date>-dev-c-prompt.md (optional, 4-role mode)
└── archive/ older kickoff sets
tools/relay/
├── start.sh launcher (manual / tmux / kitty)
├── server.ts MCP server (HTTP/SSE on :7331)
├── queue.ts in-memory per-role FIFO
├── queue.test.ts node:test — run with `bun test`
├── call.py Python MCP-client shim (fallback)
├── call.ts TypeScript MCP-client shim (fallback)
├── package.json
└── tsconfig.json
```
The launcher's prompt-discovery is `ls -t "$COORD_DIR"/*-<role>-prompt.md | head -1` — newest wins. To switch back to a previous kickoff set, either delete the newer files or move them under `archive/`.
## Conventions
- **Roles are fixed strings:** `pm`, `dev-a`, `dev-b`, `dev-c`. Adding a new role means editing `Role` in `queue.ts`, `KNOWN_ROLES`, the `enum` in `server.ts`'s tool schema, and the launcher.
- **Worktree per dev:** each dev session works in its own git worktree on its own branch. Subagents must `cd` into the worktree first — the multi-agent-kickoff skill bakes this rule into the dev prompts (subagents have been known to commit to `main` if the worktree cwd is only set in a header).
- **Branch naming follows the release train:** `feature/<release>-<dev>-<scope>`. PM owns the merge order; devs do not merge each other's branches.
- **No squashing:** the project preserves git history as audit log (per `CLAUDE.md`). Devs commit small and often; PM coordinates rebases at integration time, not before.
- **The user is not the router.** PM should issue directives directly to devs via the relay. The user steps in only for cross-stream design decisions or when PM explicitly escalates.
## Troubleshooting
- **"port 7331 is already in use"** — another relay is running. `kill $(lsof -ti:7331)`, then re-run `start.sh`.
- **Launcher can't find a prompt** (`(none found)` in the printed paths) — `/multi-agent-kickoff` hasn't been run yet, or all generated prompts are under `archive/`. Re-run the skill.
- **Dev session committing to `main` instead of its worktree** — its subagent prompts are missing the force-`cd` header. Regenerate the dev prompt via `/multi-agent-kickoff` (the skill bakes in the cd rule) or hand-edit the prompt to start with `cd <worktree-path>`.
- **MCP tools don't show up in the Claude session** — restart the session. If it persists, fall back to `call.py`. If `call.py` also fails, check the relay log window for stack traces; the SSE transport sometimes wedges if a client disconnects ungracefully.
- **`bun test` failing in `tools/relay/`** — relay tests use `node:test` via bun. Run from `tools/relay/`, not the repo root: `cd tools/relay && bun test`. Extension tests use vitest and live elsewhere; don't conflate.
- **One dev session is silent** — check `python3 tools/relay/call.py list_pending '{"for":"<role>"}'` from any shell. If the dev's inbox has unread messages, they may have crashed or detached. Open the role's window and resume.
## Caveats
- **In-memory queue is dev-only.** Restart the relay = lose all queued messages. There is no persistence by design — coordination is meant to flow forward, not be replayable.
- **No auth.** The relay binds to `localhost:7331` with no token. Don't expose the port; don't run on a shared machine.
- **The relay is not a chat history.** `read_messages` drains the inbox. If you need to refer back to what was said, copy-paste into a session note or the PR description; don't expect the relay to remember.
- **Context costs scale with session count.** Four parallel Claude sessions burn four context windows. Use this paradigm when the parallel speedup justifies the cost — for sequential work, one session is cheaper.
## See also
- `tools/relay/server.ts` — MCP tool definitions (`post_message`, `read_messages`, `list_pending`) and their schemas.
- `tools/relay/queue.ts``Role` / `MessageKind` types; the canonical per-role-inbox semantics.
- `docs/superpowers/coordination/<date>-pm-prompt.md` — the latest PM kickoff (the actual operational instructions PM runs by).
- The `multi-agent-kickoff` skill — generates the kickoff prompt set.

View File

@@ -0,0 +1,165 @@
# Dev A Kickoff Prompt — Architecture Review (Rust core)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior reviewer** owning the Rust core review for Relicario's whole-codebase architecture audit (2026-05-04). You are one of three dev reviewers (A/B/C) reporting to a PM in another terminal. Your scope is `crates/relicario-core/` and its tests.
The user wants to be able to **read and understand this codebase and learn by tinkering**, even though they don't know Rust. Your review lens is therefore *architectural clarity for a smart newcomer*, not just correctness. Naming, layering, comments at non-obvious boundaries, dead code, leaky abstractions, opportunities to simplify — all in scope.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Stay on `main`. **Do not check out branches, do not create worktrees, do not modify code.** This is a review-only role.
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
- The working tree has uncommitted changes (manifest bumps, vault tweaks, relay tooling). Run `git status` once for awareness; otherwise review HEAD as the canonical state. Flag anything weird about the uncommitted state in your notes if it suggests an in-flight architectural issue.
## 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 pass: `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 (the relay server was not running when your session opened), 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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — the foundational spec (threat model, crypto pipeline, format)
3. `crates/relicario-core/src/lib.rs` — public API surface
4. Then walk every file in `crates/relicario-core/src/` and `crates/relicario-core/tests/` deliberately. The point is depth, not coverage rate.
You are NOT required to read the other crates (DEV-B owns them) or the extension (DEV-C owns it). If something in core only makes sense by looking at how it's consumed, file a `## QUESTION TO PM` rather than crossing the boundary.
## Your scope and boundaries
**In scope:**
- `crates/relicario-core/src/*.rs` — every file
- `crates/relicario-core/tests/*.rs` — integration tests
- `crates/relicario-core/Cargo.toml` — dependencies and features
**Out of scope (other reviewers' territory):**
- `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/` — DEV-B
- `extension/`, `tools/relay/` — DEV-C
- `docs/` outside the spec link above
**Hard rules:**
- **No code changes.** Not even trivial doc-comment fixes. Surface in your notes; the user decides what to act on after PM synthesis.
- No git commits, no branch creation, no destructive ops.
- If you spend more than ~30 minutes on one file, post a status update with what you've found so far and move on. Cover the whole core, then return for depth.
## What to look for (review lens)
Walk every file in `crates/relicario-core/src/` and assess:
1. **Architectural clarity for a Rust newcomer.** Would a smart person who knows another language be able to follow this? Where are the surprise idioms, the silent assumptions, the "you have to know Rust to read this" cliffs?
2. **Naming.** Are types, functions, fields named for what they mean? Any cryptic two-letter abbreviations? Names that lie about what they do?
3. **Layering.** Is `relicario-core` actually platform-agnostic (no fs, no net, no git)? Any leaks? Any types that pretend to be pure data but hide I/O?
4. **API ergonomics.** Are public types easy to construct and consume? Any footguns (e.g. easy-to-misuse nonces, builders that compile but corrupt)? Are errors descriptive?
5. **Crypto correctness invariants.** Argon2id params, XChaCha20-Poly1305 nonce uniqueness, key zeroization, AAD usage, version/format gates. Are these enforced by types or by convention? If by convention, is that convention obvious?
6. **DCT steganography (`imgsecret.rs`).** This is the most magical file. Does it have enough explanation for a reader to map code to spec section?
7. **Comments.** Where there ARE comments, do they explain *why* (good) or *what* (rot)? Where they're missing, is it because the code is self-explanatory, or because nobody got around to it?
8. **Tests.** Do they cover happy path AND format/crypto edge cases? Are test helpers well-named? Any dead test cases?
9. **Dead code, unused features, abandoned experiments.** Use `cargo clippy -p relicario-core` and `cargo +nightly udeps -p relicario-core 2>/dev/null || true` if you have time, but mostly: just notice things.
10. **Simplification opportunities.** Three similar lines is better than premature abstraction; but six similar match arms might want a helper. Note candidates; don't insist.
You may run `cargo build -p relicario-core`, `cargo test -p relicario-core`, `cargo clippy -p relicario-core` to confirm assumptions, but the review is not gated on green — it's gated on understanding.
## Output
Write your findings to `docs/superpowers/reviews/2026-05-04-dev-a-notes.md`. Create the `reviews/` directory with `mkdir -p` if it doesn't exist.
Structure:
```markdown
# DEV-A Architecture Review Notes — Rust Core
## Summary
3-5 sentences: what's the overall architectural shape, what's the strongest part, what's the weakest part.
## Findings (prioritized: P1 = address before more code lands, P2 = soon, P3 = nice-to-have)
### P1 — <short title>
**File(s):** `crates/relicario-core/src/foo.rs:123`
**Observation:** <what you saw>
**Why it matters:** <especially for a Rust newcomer reading the code>
**Suggested direction:** <one or two sentences; do NOT rewrite — just point>
(repeat for each finding, P1 first, then P2, then P3)
## File-by-file walk (one paragraph each)
For every file in core, a paragraph: what it does, what's clear, what's not. Brief is fine — this is the appendix.
## Beginner-friendliness assessment
A paragraph: how readable is this crate for a competent dev who has never written Rust? What single change would help most?
```
The PM will synthesize your notes (plus DEV-B's and DEV-C's) into the final review doc.
## Coordination protocol
**Before each major pass** (e.g. starting file walk, switching to findings write-up): call `read_messages(for="dev-a")`.
**Status update format** (post via `post_message(from="dev-a", to="pm", kind="status", body="...")`, also print here):
```
## STATUS UPDATE — DEV-A
Time: <iso8601>
Phase: SETUP | READING | WALKING | WRITING | REVIEW-COMPLETE
Files covered: <e.g. 8/19 src + 0/7 tests>
Findings so far: <P1: N, P2: N, P3: N>
Notes: <≤3 sentences>
```
**Question format** (when you need PM input — e.g. you're unsure if something is in scope, or you suspect a cross-cutting issue):
```
## QUESTION TO PM — DEV-A
Time: <iso8601>
Context: <what file, what concern>
Options: <A: ... / B: ... / C: ...>
Recommended: <your pick + one-sentence rationale>
Blocker: yes | no
```
You'll receive `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act.
## Authority
You don't need PM permission to:
- Decide reading order within scope
- Decide whether a finding is P1/P2/P3
- Use subagents to parallelize file reads (force-cd into `/home/alee/Sources/relicario` per project memory rule — every subagent prompt MUST start with `cd /home/alee/Sources/relicario` so the subagent doesn't wander)
- Run `cargo` commands (build, test, clippy) read-only
You **do** escalate to PM when:
- You suspect an issue that crosses into DEV-B or DEV-C territory
- A finding is so severe (e.g. exploitable crypto bug) that it deserves immediate attention
- You're tempted to fix something inline (don't — escalate and let the PM/user decide)
## REVIEW-COMPLETE criteria
Before posting `Phase: REVIEW-COMPLETE`:
- [ ] Every file in `crates/relicario-core/src/` walked
- [ ] Every file in `crates/relicario-core/tests/` walked
- [ ] Notes written to `docs/superpowers/reviews/2026-05-04-dev-a-notes.md`
- [ ] At least one paragraph in the beginner-friendliness assessment
- [ ] No P1 finding left vague — every P1 has a file:line and a suggested direction
## First action
1. Call `read_messages(for="dev-a")`.
2. Read the project rules and the spec.
3. Skim `lib.rs` and the file list under `crates/relicario-core/src/`.
4. Emit a `## STATUS UPDATE` with `Phase: SETUP` confirming spec absorbed and file count to walk.
5. Begin the file walk. Save findings as you go.

View File

@@ -0,0 +1,198 @@
# Dev B Kickoff Prompt — Architecture Review (Rust consumers: CLI, server, WASM)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior reviewer** owning the Rust consumer-layer review for Relicario's whole-codebase architecture audit (2026-05-04). You are one of three dev reviewers (A/B/C) reporting to a PM in another terminal. Your scope is the three Rust crates that consume `relicario-core`: `relicario-cli`, `relicario-server`, and `relicario-wasm`.
The user wants to be able to **read and understand this codebase and learn by tinkering**, even though they don't know Rust. Your review lens is therefore *architectural clarity for a smart newcomer*, not just correctness. Naming, layering, where business logic actually lives, comments at non-obvious boundaries, dead code, leaky abstractions, opportunities to simplify — all in scope.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Stay on `main`. **Do not check out branches, do not create worktrees, do not modify code.** This is a review-only role.
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
- The working tree has uncommitted changes (manifest bumps, vault tweaks, relay tooling). Run `git status` once for awareness; otherwise review HEAD as the canonical state. Flag anything weird about the uncommitted state in your notes if it suggests an in-flight architectural issue.
## 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 pass: `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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — the foundational spec
3. `crates/relicario-core/src/lib.rs`**just** the public API surface (you do NOT review core; that's DEV-A — but you must understand what your crates depend on)
4. Then walk each consumer crate deliberately:
- `crates/relicario-cli/src/main.rs` then the rest of `crates/relicario-cli/src/` and `crates/relicario-cli/tests/`
- `crates/relicario-server/src/main.rs` and `crates/relicario-server/tests/`
- `crates/relicario-wasm/src/lib.rs` then `crates/relicario-wasm/src/`
You are NOT required to read deeply into `relicario-core` internals (DEV-A owns them) or the extension TypeScript (DEV-C owns it). The WASM crate is your boundary with DEV-C; review it from the Rust side, and trust DEV-C to review it from the JS side.
## Your scope and boundaries
**In scope:**
- `crates/relicario-cli/` — entire crate (src + tests + Cargo.toml)
- `crates/relicario-server/` — entire crate
- `crates/relicario-wasm/` — entire crate
**Out of scope (other reviewers' territory):**
- `crates/relicario-core/` internals — DEV-A
- `extension/`, `tools/relay/` — DEV-C
- `docs/` outside the spec link above
**Hard rules:**
- **No code changes.** Not even trivial. Surface in notes.
- No git commits, no branch creation, no destructive ops.
- If you spot a core-internal issue while you're poking at it, file a `## QUESTION TO PM` so DEV-A can be alerted; don't expand your own scope.
## What to look for (review lens)
For each crate, assess:
### relicario-cli (the user-facing binary)
1. **Command surface.** Does the clap structure match the spec's intended UX? Are subcommand names and flags discoverable? Any flags that exist but don't do what their name suggests?
2. **Where business logic lives.** `relicario-core` should be doing the work; the CLI should be glue (parse args → call core → format output). Any place where the CLI has logic that should be in core?
3. **Session handling.** `session.rs` holds `UnlockedVault` with the master key in `Zeroizing`. Are session lifetimes clear? Any path where the key outlives its window?
4. **Filesystem and git.** The CLI is the only place that touches fs and shells out to git. Is `helpers.rs` doing this cleanly? Any path-handling bugs? Any place where `git_command` is invoked with insufficient error mapping?
5. **Tests.** Integration tests under `crates/relicario-cli/tests/` — do they test the CLI surface or just re-test core? Are fixtures synthetic (per project convention)? Any mocked filesystem leaks?
6. **Error UX.** When something goes wrong, does the user get a useful message, or does a `RelicarioError` get printed raw?
### relicario-server (Git pre-receive hook)
1. **Surface.** Two subcommands (`verify-commit`, `generate-hook`). Is the contract with Git clear? Does `generate-hook` produce a hook that's actually correct?
2. **Trust model.** This is the only piece that runs server-side. Does it correctly enforce "the server only sees opaque ciphertext" — i.e. it never tries to decrypt, only verifies signatures/format?
3. **Failure modes.** What happens if a malformed commit lands? Are rejections actionable for the pushing client?
### relicario-wasm (extension bridge)
1. **JS surface.** What does `#[wasm_bindgen]` actually expose? Is the API minimal and well-typed (`SessionHandle` opaque), or does it leak Rust internals?
2. **Session handle pattern.** `SessionHandle``Zeroizing<[u8;32]>` indirection. Is the lifetime story sound? What happens when JS drops the handle?
3. **Error mapping.** Rust errors → JS exceptions. Are messages useful on the JS side, or do they just say "internal error"?
4. **Build target.** Does `cargo build -p relicario-wasm --target wasm32-unknown-unknown` succeed clean? Any feature flags that look gnarly?
5. **Boundary with DEV-C.** What does the TS side need to know that isn't documented in the WASM crate? Note these for DEV-C cross-reference.
### Cross-cutting (all three crates)
1. **Naming.** Same questions as DEV-A.
2. **Comments.** Same.
3. **Layering.** Does each crate import only what it should? Any place where a consumer crate is reaching past `relicario-core`'s public API?
4. **Dead code, abandoned features.** Notice things.
5. **Simplification.** Where would a Rust newcomer trip? What's the single change that would help most?
You may run `cargo build`, `cargo test -p relicario-cli`, `cargo test -p relicario-server`, `cargo build -p relicario-wasm --target wasm32-unknown-unknown`, `cargo clippy --workspace`. The review is not gated on green; it's gated on understanding.
## Output
Write your findings to `docs/superpowers/reviews/2026-05-04-dev-b-notes.md`. Create the `reviews/` directory with `mkdir -p` if it doesn't exist.
Structure:
```markdown
# DEV-B Architecture Review Notes — Rust Consumers (CLI, Server, WASM)
## Summary
3-5 sentences spanning all three crates: what's the shape of the consumer layer, where is it strongest, where is it weakest.
## Findings (prioritized: P1 / P2 / P3)
Group by crate, P1 first within each:
### relicario-cli
#### P1 — <short title>
**File(s):** `crates/relicario-cli/src/main.rs:456`
**Observation:** ...
**Why it matters:** ...
**Suggested direction:** ...
### relicario-server
...
### relicario-wasm
...
### Cross-cutting
Findings that span more than one crate (e.g. an error-handling pattern that's inconsistent across all three).
## File-by-file walk
One paragraph per file across all three crates. Appendix-grade detail.
## Boundary notes for DEV-C
What about the WASM JS surface should DEV-C double-check from the TypeScript side?
## Beginner-friendliness assessment
A paragraph: how readable are these crates for a competent dev who has never written Rust? What single change would help most?
```
## Coordination protocol
**Before each major pass:** `read_messages(for="dev-b")`.
**Status update format** (post via `post_message(from="dev-b", to="pm", kind="status", body="...")`, also print here):
```
## STATUS UPDATE — DEV-B
Time: <iso8601>
Phase: SETUP | READING | WALKING-CLI | WALKING-SERVER | WALKING-WASM | WRITING | REVIEW-COMPLETE
Crates covered: <e.g. cli ✓ / server ✓ / wasm ⏳>
Findings so far: <P1: N, P2: N, P3: N>
Notes: <≤3 sentences>
```
**Question format:**
```
## QUESTION TO PM — DEV-B
Time: <iso8601>
Context: <what crate, what concern>
Options: <A / B / C>
Recommended: <pick + one-sentence rationale>
Blocker: yes | no
```
You'll receive `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act.
## Authority
You don't need PM permission to:
- Decide reading order within scope
- Decide P1/P2/P3 prioritization
- Use subagents to parallelize crate reads (force-cd into `/home/alee/Sources/relicario` per project memory rule — every subagent prompt MUST start with `cd /home/alee/Sources/relicario`)
- Run `cargo` commands (build, test, clippy) read-only
You **do** escalate to PM when:
- An issue spans into DEV-A's core or DEV-C's TS territory
- A finding is severe enough to deserve immediate attention
- You're tempted to fix something inline (don't)
## REVIEW-COMPLETE criteria
- [ ] Every src file in cli/server/wasm walked
- [ ] Every test file walked
- [ ] Notes written to `docs/superpowers/reviews/2026-05-04-dev-b-notes.md`
- [ ] Boundary-notes section for DEV-C populated
- [ ] Every P1 has a file:line and suggested direction
## First action
1. Call `read_messages(for="dev-b")`.
2. Read project rules and spec.
3. Skim `relicario-core/src/lib.rs` for the public API your crates depend on.
4. Emit a `## STATUS UPDATE` with `Phase: SETUP` confirming setup, listing crates+file counts you'll walk.
5. Begin the walk: cli first, then server, then wasm. Save findings as you go.

View File

@@ -0,0 +1,214 @@
# Dev C Kickoff Prompt — Architecture Review (TypeScript: extension + relay tooling)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior reviewer** owning the TypeScript review for Relicario's whole-codebase architecture audit (2026-05-04). You are one of three dev reviewers (A/B/C) reporting to a PM in another terminal. Your scope is the browser extension (`extension/`) and the dev tooling (`tools/relay/`).
The user wants to be able to **read and understand this codebase and learn by tinkering**, including the parts they're more comfortable with (TS). Your review lens is therefore *architectural clarity*, with extra attention to:
- Parity with the CLI (per project memory: CLI/extension parity is a design philosophy — never ship "CLI first, extension follow-up")
- The popup ↔ service-worker ↔ content script boundary
- The WASM bridge from the JS side
- Naming, layering, dead code, simplification opportunities
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Stay on `main`. **Do not check out branches, do not create worktrees, do not modify code.** This is a review-only role.
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization, autonomy defaults, never run git-destructive commands without asking).
- The working tree has uncommitted changes — note especially: `extension/manifest.json`, `extension/manifest.firefox.json`, `extension/package*.json`, `extension/src/shared/glyphs.ts` and `__tests__/glyphs.test.ts`, `extension/src/vault/vault.css`, `extension/src/vault/vault.ts`, `tools/relay/queue.ts`, `tools/relay/server.ts`, plus untracked `tools/relay/call.py`, `tools/relay/call.ts`, `.gitea_env_vars`. Run `git status` and `git diff` once. Decide whether the uncommitted diff is "in flight, ignore for review" or "already part of the architecture and worth flagging" — note your call in the notes file.
## Relay server
A message-bus MCP server is running on `localhost:7331`. Note that you are reviewing this server itself — but for coordination, you still use it. 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 pass: `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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules (note especially the CLI/extension parity rule)
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — foundational spec (skim, focus on the parts that touch the extension)
3. `extension/package.json`, `extension/manifest.json`, `extension/manifest.firefox.json` — extension shape
4. `extension/src/wasm.d.ts` and `extension/src/__stubs__/relicario_wasm.stub.ts` — your WASM boundary
5. Then walk in this order:
- `extension/src/service-worker/` (the trusted core of the extension)
- `extension/src/shared/` (shared utilities)
- `extension/src/popup/` (popup UI)
- `extension/src/vault/` (full-tab vault UI)
- `extension/src/content/` (content scripts: detector, fill, capture)
- `extension/src/setup/` (setup wizard)
- `tools/relay/` (the dev-only message bus, separate concern)
You are NOT required to read deeply into the Rust crates — DEV-A owns core, DEV-B owns CLI/server/WASM. From your side, you only need to understand the WASM JS surface that the extension consumes.
## Your scope and boundaries
**In scope:**
- `extension/` — entire tree (excluding `node_modules/` and built artifacts)
- `tools/relay/` — entire tree (excluding `node_modules/`)
- The WASM JS surface as consumed from TS (`wasm.d.ts`, the stub, every call site)
**Out of scope (other reviewers' territory):**
- `crates/relicario-core/` internals — DEV-A
- `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/` Rust internals — DEV-B
- `docs/` outside the spec link above
**Hard rules:**
- **No code changes.** Not even trivial. Surface in notes.
- No git commits, no branch creation, no destructive ops.
- If you find a Rust-side issue while looking at the WASM boundary, file a `## QUESTION TO PM` so DEV-B can be alerted; don't expand scope.
## What to look for (review lens)
### Extension architecture
1. **Layering — popup vs service-worker vs content vs vault tab.** Each has a clear role; are the lines clean? Any place where popup is doing trusted work that should live in the service-worker, or vice versa? Any direct WASM calls in content scripts (a security smell)?
2. **Message-passing.** `extension/src/shared/messages.ts` and the service-worker router — is the typing tight? Are there messages that exist but are never sent, or sent but never handled?
3. **WASM session handle.** The session handle is a number (an opaque pointer into the Rust session map). Where does it live? Who creates it, who drops it? Is the locked/unlocked state machine obvious from the code?
4. **CLI/extension parity.** Walk through the CLI's commands (skim `crates/relicario-cli/src/main.rs` for the surface only) and check: for every CLI capability, does the extension have an equivalent UI path? If not, is that a deliberate choice or an oversight? This is a project-philosophy lens — flag any gap as a P1 architectural issue.
5. **Component organization.** `extension/src/popup/components/` has settings, item-form, item-list, fields, generator-panel, etc. Are component boundaries clean? Any teardown leaks (the project has had teardown bugs before — see commit `ddfb95d` and `8baef5b`)?
6. **Storage and state.** What lives in `chrome.storage.local`? In `chrome.storage.session`? Any sensitive data persisted that shouldn't be? Any cleartext secrets in DOM that survive teardown?
7. **Settings architecture.** v0.5.1 just landed a unified left-nav settings (commit `bd6a301`). Is the new structure clean, or are there leftover bits from the old structure?
### Vault tab vs popup
8. **Two render targets, one component.** Settings (and possibly other components) render into both the popup and the vault tab. Is the rendering target abstracted, or are there `if (in popup)` branches? Note any pattern that breaks down.
9. **Vault tab session timeout.** There's been work on this (per project memory). Is the session-timer logic in `service-worker/session-timer.ts` legible?
### Shared utilities
10. **`shared/types.ts`, `shared/messages.ts`, `shared/state.ts`.** Are these doing what their names suggest? Any `any`-leaking? Any types that should be generated from the WASM bindings instead of hand-maintained?
11. **`shared/glyphs.ts`.** Per project rules, no inline emoji — all UI glyphs go through this. Is the abstraction tight? (Recent uncommitted edits here — note your read.)
### Relay tooling (`tools/relay/`)
12. **Single-purpose dev tool.** It's the message bus you're using right now. Is it appropriately isolated from the rest of the codebase (no cross-imports, separate package.json)? Is the role enum (`pm`, `dev-a`, `dev-b`, `dev-c`) maintained in lockstep across `queue.ts`, `server.ts`, and any client (`call.py`, `call.ts`)?
13. **Untracked `call.py` and `call.ts`.** What are these for? Are they the documented fallback shims? Should they be tracked?
### Cross-cutting
14. **Naming.** Are TS names crisp? Any that lie about behavior?
15. **Dead code.** `bun run build` cleanly? Any imports unused? Any components rendered nowhere?
16. **Simplification.** Where would a beginner trip? What's the single change that would help most?
17. **Tests.** `extension/src/**/__tests__/*.test.ts` — meaningful coverage, or smoke-only?
You may run `bun run build`, `bun run build:firefox`, `bun run test`, `cd tools/relay && bun test`, `bun run lint` if available. Review is not gated on green; it's gated on understanding.
## Output
Write your findings to `docs/superpowers/reviews/2026-05-04-dev-c-notes.md`. Create the `reviews/` directory with `mkdir -p` if it doesn't exist.
Structure:
```markdown
# DEV-C Architecture Review Notes — TypeScript (Extension + Relay)
## Summary
3-5 sentences: shape of the TS layer, strongest part, weakest part. Note the CLI/extension parity status.
## Findings (prioritized: P1 / P2 / P3)
Group by area, P1 first within each:
### Extension — service-worker
### Extension — popup + components
### Extension — vault tab
### Extension — content scripts
### Extension — setup
### Extension — shared utilities
### WASM boundary (JS side)
### Relay tooling
### CLI/extension parity gaps
### Cross-cutting
(use the same finding format as DEV-A/DEV-B: file:line, observation, why it matters, suggested direction)
## File-by-file walk
One paragraph per file (or per small group of related files). Appendix-grade.
## CLI/extension parity table
| CLI capability | Extension equivalent | Notes |
|----------------|----------------------|-------|
| ... | ✓ or ✗ or partial | ... |
## Boundary notes for DEV-B
What about the WASM JS surface (or session handle, error mapping) does DEV-B need to double-check from the Rust side?
## Beginner-friendliness assessment
A paragraph for the TS side specifically.
## Uncommitted-state read
A short paragraph: what's in the working tree but not in HEAD, and is any of it architecturally relevant?
```
## Coordination protocol
**Before each major pass:** `read_messages(for="dev-c")`.
**Status update format** (post via `post_message(from="dev-c", to="pm", kind="status", body="...")`, also print here):
```
## STATUS UPDATE — DEV-C
Time: <iso8601>
Phase: SETUP | READING | WALKING-SW | WALKING-POPUP | WALKING-VAULT | WALKING-CONTENT | WALKING-SETUP | WALKING-SHARED | WALKING-RELAY | WRITING | REVIEW-COMPLETE
Areas covered: <checklist>
Findings so far: <P1: N, P2: N, P3: N>
Notes: <≤3 sentences>
```
**Question format:**
```
## QUESTION TO PM — DEV-C
Time: <iso8601>
Context: <what file, what concern>
Options: <A / B / C>
Recommended: <pick + one-sentence rationale>
Blocker: yes | no
```
You'll receive `## DIRECTIVE TO DEV-C` blocks from the PM via relay. Acknowledge and act.
## Authority
You don't need PM permission to:
- Decide reading order within scope
- Decide P1/P2/P3 prioritization
- Use subagents to parallelize area reads (force-cd into `/home/alee/Sources/relicario` per project memory rule — every subagent prompt MUST start with `cd /home/alee/Sources/relicario`)
- Run `bun` and `cargo` commands read-only
You **do** escalate to PM when:
- A finding spans into DEV-A's core or DEV-B's Rust crates
- A finding is severe (e.g. a real security smell, leaked secret in storage)
- You're tempted to fix something inline (don't)
## REVIEW-COMPLETE criteria
- [ ] Every TS file under `extension/src/` walked (including `__tests__`)
- [ ] Every TS file under `tools/relay/` walked
- [ ] Notes written to `docs/superpowers/reviews/2026-05-04-dev-c-notes.md`
- [ ] CLI/extension parity table populated (every CLI capability either ✓, ✗, or partial)
- [ ] Boundary-notes section for DEV-B populated
- [ ] Uncommitted-state read paragraph written
- [ ] Every P1 has a file:line and suggested direction
## First action
1. Call `read_messages(for="dev-c")`.
2. Read project rules and spec.
3. Look at `manifest.json` and skim `wasm.d.ts` so you know your boundaries.
4. Run `git status` and `git diff` once for awareness of uncommitted state.
5. Emit a `## STATUS UPDATE` with `Phase: SETUP` confirming setup, listing area buckets you'll walk.
6. Begin the walk: service-worker first, then shared, then popup, then vault, then content, then setup, then relay tooling. Save findings as you go.

View File

@@ -0,0 +1,200 @@
# Dev A Kickoff Prompt — Architecture Review Followup, Stream A (Security & Docs Polish)
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement the plan task-by-task once it lands.
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream A for the Relicario architecture-review-followup work. Stream A is the small, security-flavored quick-win PR that goes first: SessionHandle Drop semantics, the matching JS-side audit, recovery_qr.rs documentation density, and the relay launcher fix for the dev-c fourth window. Goal is **under-a-day, all-S effort, ships first**.
A PM in another terminal coordinates you with two other senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. **Your plan does not exist yet.** The PM is drafting it as their first action. Set up your worktree, post acknowledgement, and wait for the PM's `PROCEED` directive containing the plan path before starting Task 1.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git worktree add /home/alee/Sources/relicario.arch-followup-stream-a -b feature/arch-followup-stream-a-security-polish
cd /home/alee/Sources/relicario.arch-followup-stream-a
pwd # should print /home/alee/Sources/relicario.arch-followup-stream-a
```
**Note on `git pull`:** main has uncommitted polish changes from in-flight post-v0.5.1 work. The setup above branches from local main HEAD without pulling, which is intentional. If `git fetch` reveals new upstream commits, surface them to the PM before incorporating.
**ALL subsequent work happens in `/home/alee/Sources/relicario.arch-followup-stream-a`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.arch-followup-stream-a` (project memory rule — without the force-cd, subagents may commit to main).
Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking).
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.1, the JS free-swallow fix, P1.7, P1.8 only**)
3. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — full DEV-A reviewer notes (Rust core; covers recovery_qr.rs context)
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — full DEV-B reviewer notes (covers SessionHandle context on the WASM side)
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — full DEV-C reviewer notes (covers the JS free-swallow + start.sh launcher context)
6. **Your plan (will land at):** `docs/superpowers/specs/2026-05-04-security-polish-design.md` — read once the PM directs you to PROCEED
## Execution mode
Use **superpowers:subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.arch-followup-stream-a
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:**
- P1.1 — `impl Drop for SessionHandle` in `crates/relicario-wasm/src/lib.rs` (and `session.rs`); `wasm-bindgen-test` for construct → drop → confirm `SESSIONS` registry empty; audit every `.free()` callsite under `extension/src/` to confirm `wasm.lock(handle)` happens first regardless
- JS partner fix at `extension/src/service-worker/session.ts:26` — remove the `try { current.free() }` swallow; let exceptions propagate or log + counter
- P1.7 — `crates/relicario-core/src/recovery_qr.rs` documentation pass to match `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs` density
- P1.8 — `tools/relay/start.sh:80` launcher fix for the dev-c fourth window (the queue.test.ts assertion is already fixed in `061facd`; only the launcher line remains)
**Out of scope (other DEVs own these):**
- P1.2, P1.3, P1.10 + the in-scope CLI P2s — DEV-B (Stream B)
- P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2s — DEV-C (Stream C)
- Any other P2/P3 not listed in your plan
- The 8 "Open architectural decisions" at the bottom of the synthesis — those are user-judgement calls
If you trip over an out-of-scope issue or a new bug while doing your work, file a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- **P1.1 is defense-in-depth crypto.** The `wasm-bindgen-test` covering construct → drop → registry-empty is a required acceptance gate. Do not skip or weaken it.
- **The `.free()` callsite audit is part of P1.1.** Every callsite under `extension/src/` must be checked to confirm `wasm.lock(handle)` is called before `.free()`. Document the audit results in the PR.
- **Do not remove the JS free-swallow before `impl Drop` lands.** The order matters: Rust fix first (so `.free()` becomes meaningful cleanup), JS swallow removal second (so failures surface).
- **`recovery_qr.rs` docs must match the existing core standard.** Multi-paragraph module-level `//!` rationale, ASCII diagram of the 109-byte layout, doc-comments on the four public functions, comment or `const` for the `production_params()` divergence. Read `crypto.rs` and `backup.rs` first to absorb the tone.
- **Test-only Argon2id params stay fast** (m=256, t=1, p=1) per project convention.
- Synthetic JPEG fixtures only — no binary blobs in the test suite (project rule).
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## 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"}'
```
## Coordination protocol
You are one of four terminals. Before starting each task, call `read_messages(for="dev-a")` to drain your inbox.
When posting a status update, call `post_message(from="dev-a", to="pm", kind="status", body="...")` with the body:
```
## STATUS UPDATE — DEV-A
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
Branch: feature/arch-followup-stream-a-security-polish
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:**
```
## 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
```
**You'll receive:** `## DIRECTIVE TO DEV-A` blocks from the PM. The first one will say `Action: PROCEED` with the plan path once the PM has drafted it. Acknowledge and start Task 1.
## Authority within the plan
You don't need PM permission to:
- Execute task-to-task per the plan once you have it
- Make implementation decisions consistent with the plan and synthesis
- 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 (per CLAUDE.md)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd /home/alee/Sources/relicario.arch-followup-stream-a
cargo test
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cd extension && bun run test && bun run build && cd ..
cd tools/relay && bun test && cd ../..
```
All four must be green. The wasm-bindgen-test for SessionHandle Drop is the headline acceptance.
Then push and open the PR:
```bash
git push -u origin feature/arch-followup-stream-a-security-polish
gh pr create --base main --head feature/arch-followup-stream-a-security-polish \
--title "fix(security/docs): SessionHandle Drop + free-swallow + recovery_qr docs + dev-c launcher (Stream A)" \
--body "$(cat <<'EOF'
## Summary
Stream A of the architecture-review-followup. Small security-flavored polish PR addressing four findings from the 2026-05-04 audit:
- **P1.1** — `impl Drop for SessionHandle` so `.free()` becomes a real cleanup safety net (Rust fix); `wasm-bindgen-test` for construct → drop → registry empty; audit of every `.free()` callsite under `extension/src/`
- **JS free-swallow** — removed `try { current.free() }` at `extension/src/service-worker/session.ts:26` so failures propagate
- **P1.7** — `crates/relicario-core/src/recovery_qr.rs` brought up to the documentation density of `crypto.rs` / `backup.rs` / `tar_safe.rs`
- **P1.8** — `tools/relay/start.sh:80` launcher fix for the dev-c fourth window
## Synthesis references
- `docs/superpowers/reviews/2026-05-04-architecture-review.md` — P1.1, P1.7, P1.8
- `docs/superpowers/specs/2026-05-04-security-polish-design.md` — Plan A
## Test plan
- [x] `cargo test` green
- [x] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
- [x] `cd extension && bun run test && bun run build` green
- [x] `cd tools/relay && bun test` green (4-pass after dev-c assertion fix in 061facd)
- [x] New wasm-bindgen-test: SessionHandle construct → drop → SESSIONS registry empty
- [x] `.free()` callsite audit under `extension/src/` — every site preceded by `wasm.lock(handle)`
- [x] `start.sh` opens 4 windows including dev-c
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
## First action
After reading the required inputs and setting up the worktree:
1. Call `read_messages(for="dev-a")` to drain any early inbox messages.
2. Emit a `## STATUS UPDATE` confirming setup complete:
```
## STATUS UPDATE — DEV-A
Time: <iso8601>
Branch: feature/arch-followup-stream-a-security-polish
Task: setup
Status: DONE
Last commit: <main HEAD sha + first line>
Tests: N/A
Notes: Worktree at /home/alee/Sources/relicario.arch-followup-stream-a. Synthesis + reviewer notes absorbed. Awaiting Plan A at docs/superpowers/specs/2026-05-04-security-polish-design.md.
```
3. **Wait** for the PM's `## DIRECTIVE TO DEV-A` with `Action: PROCEED` and the plan path.
4. Read the plan, then start Task 1 using `superpowers:subagent-driven-development`.

View File

@@ -0,0 +1,242 @@
# Dev B Kickoff Prompt — Architecture Review Followup, Stream B (CLI Restructure)
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement the plan task-by-task once it lands.
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream B for the Relicario architecture-review-followup work. Stream B is the **single biggest readability lift** in the bundle (per the synthesis): split the 2641-LOC `crates/relicario-cli/src/main.rs` into a `commands/` folder, centralize the duplicated git-shell error UX, migrate three pure parsers to `relicario-core`, and pick up four in-scope CLI P2s. Multi-day, M-L effort.
A PM in another terminal coordinates you with two other senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly. **Your plan does not exist yet.** The PM is drafting it as their first action. Set up your worktree, post acknowledgement, and wait for the PM's `PROCEED` directive containing the plan path before starting Task 1.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git worktree add /home/alee/Sources/relicario.arch-followup-stream-b -b feature/arch-followup-stream-b-cli-restructure
cd /home/alee/Sources/relicario.arch-followup-stream-b
pwd # should print /home/alee/Sources/relicario.arch-followup-stream-b
```
**Note on `git pull`:** main has uncommitted polish changes from in-flight post-v0.5.1 work. The setup above branches from local main HEAD without pulling, which is intentional. None of those uncommitted files are in Stream B's scope, so this should not produce conflicts at PR time.
**ALL subsequent work happens in `/home/alee/Sources/relicario.arch-followup-stream-b`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.arch-followup-stream-b` (project memory rule — without the force-cd, subagents may commit to main).
Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking).
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.2, P1.3, P1.10 + the four in-scope CLI P2s only**)
3. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — full DEV-B reviewer notes (your stream's primary source)
4. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — full DEV-A reviewer notes (covers the base32 dedup that pairs with P1.10)
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read the **"Boundary notes for DEV-B"** section in particular (covers the parity-relevant cross-references for the parser→core migration and WASM exports)
6. **Your plan (will land at):** `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — read once the PM directs you to PROCEED
## Execution mode
Use **superpowers:subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.arch-followup-stream-b
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:**
- P1.2 — split `crates/relicario-cli/src/main.rs` (2641 LOC) into:
- `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`
- `prompt.rs` (six `prompt_*` helpers + `prompt_secret`)
- `parse.rs` (the three pure parsers, before they migrate to core)
- `main.rs` keeps clap definitions + dispatcher (~470 lines)
- P1.3 — add `helpers::git_run(repo: &Path, args: &[&str], context: &str)` using `.output()` to capture stderr; print captured stderr unmodified on failure; embed a human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). Sweep ~16 duplicated bail sites listed in the synthesis (`main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` and others).
- P1.10 — migrate to `relicario-core`:
- `parse_month_year` (already partial in `time.rs`)
- `base32_decode_lenient` (rename / fold into a new `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648`; keep Steam's bespoke alphabet untouched in `item_types/totp.rs`)
- `guess_mime``mime::guess_for_extension`
- Re-export the new functions through `relicario-wasm` via `#[wasm_bindgen]` so the extension can consume them in a future round
- CLI keeps thin wrappers as needed
- In-scope CLI P2s:
- `build_*_item` helper compression (`main.rs:664-921`) with a `prompt_or_flag<T>` helper
- `refresh_groups_cache` discipline via `Vault::after_manifest_change(&self, manifest: &Manifest)` (7 manual sites at `main.rs:641, 998, 1123, 1197, 1414, 1432, 1474`)
- `ParamsFile` dedup between `main.rs:2287` (write side, has `aead`/`salt_path`/`format_version`) and `session.rs:114` (read side, only `kdf`)
- Batched purge in `cmd_purge` and `cmd_trash_empty` (`main.rs:1476-1480, 1896-1900`) — currently 50-item purge does 150 git invocations
**Out of scope (other DEVs own these):**
- P1.1, JS free-swallow, P1.7, P1.8 — DEV-A (Stream A)
- P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2s — DEV-C (Stream C)
- Any other P2/P3 not listed in your plan
- WASM JS-naming snake_case → camelCase rename (DEV-B/DEV-C P3) — explicitly deferred to a separate decision
- Server hardening items from DEV-B's P2 (generate-hook PATH, bootstrap permissiveness, stdin parsing, test gaps) — explicitly deferred
- Any of the 8 "Open architectural decisions" at the bottom of the synthesis
If you trip over an out-of-scope issue or a new bug while doing your work, file a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- **The P1.2 main.rs split MUST land first.** Every other phase in your plan touches files that don't exist yet until the split happens. Do not interleave.
- **The split is purely structural — no behavior changes.** Run `cargo test -p relicario-cli` before AND after the split commits to prove parity. Any behavior delta is a bug.
- **Keep the helper module visibility tight.** `commands/` modules are `pub(crate)` only; `main.rs` dispatches into them.
- **`helpers::git_run` signature** is `(repo: &Path, args: &[&str], context: &str)` and it captures stderr. The old `git_command` may stay temporarily during the sweep, but Phase B-N (sweep) deletes it.
- **`base32` extraction** must keep Steam's bespoke alphabet untouched at `item_types/totp.rs`. Add a neighbour comment pointing to the new core module and explaining why Steam stays separate.
- **WASM re-exports use snake_case** in JS for now (`#[wasm_bindgen]` defaults). Do not rename to camelCase — that's a separate decision (DEV-B/DEV-C P3 at the bottom of the synthesis).
- **Test-only Argon2id params stay fast** (m=256, t=1, p=1) per project convention.
- **No binary test fixtures.** Synthetic JPEGs only via `make_test_jpeg()`.
- **Git history is preserved.** No squash merges (project rule).
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination with Stream C
Your parser→core migration produces new `relicario-core` functions and re-exports them through `relicario-wasm`. Stream C does NOT need to consume these in this round. Document the new WASM surface in your PR body so a future round can pick it up. If you discover a parser signature change that would break the future extension consumer, raise it via `## QUESTION TO PM` before committing.
## 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"}'
```
## Coordination protocol
You are one of four terminals. Before starting each task, call `read_messages(for="dev-b")` to drain your inbox.
When posting a status update, call `post_message(from="dev-b", to="pm", kind="status", body="...")` with the body:
```
## STATUS UPDATE — DEV-B
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
Branch: feature/arch-followup-stream-b-cli-restructure
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:**
```
## 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
```
**You'll receive:** `## DIRECTIVE TO DEV-B` blocks from the PM. The first one will say `Action: PROCEED` with the plan path once the PM has drafted it. Acknowledge and start Task 1.
## Authority within the plan
You don't need PM permission to:
- Execute task-to-task per the plan once you have it
- Make implementation decisions consistent with the plan and synthesis
- 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 signature change in the parser→core migration that would affect the future extension consumer
- A test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- Anything destructive (per CLAUDE.md)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd /home/alee/Sources/relicario.arch-followup-stream-b
cargo build
cargo test
cargo test -p relicario-core
cargo test -p relicario-cli --test basic_flows
cargo test -p relicario-cli --test edit_and_history
cargo test -p relicario-cli --test attachments
cargo test -p relicario-cli --test settings
cargo test -p relicario-cli --test vault_detection
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cargo run -p relicario-cli -- --help
cargo run -p relicario-cli -- generate --length 32
```
All must be green. The CLI integration suite covers the surface area touched by the split + git_run sweep; pay particular attention to `basic_flows` and `edit_and_history` for regression coverage.
Then push and open the PR:
```bash
git push -u origin feature/arch-followup-stream-b-cli-restructure
gh pr create --base main --head feature/arch-followup-stream-b-cli-restructure \
--title "refactor(cli): split main.rs into commands/ + git_run helper + parsers→core (Stream B)" \
--body "$(cat <<'EOF'
## Summary
Stream B of the architecture-review-followup. The single biggest readability lift in the bundle:
- **P1.2** — `crates/relicario-cli/src/main.rs` (2641 LOC) split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs` + `prompt.rs` + `parse.rs`. `main.rs` keeps clap definitions + dispatcher.
- **P1.3** — `helpers::git_run(repo, args, context)` with stderr capture; ~16 duplicated bail sites swept.
- **P1.10** — `parse_month_year`, `base32_decode_lenient` (folded into a new `relicario_core::base32` module alongside DEV-A's P2 dedup), `guess_mime` migrated to `relicario-core`. Re-exported through `relicario-wasm` via `#[wasm_bindgen]` for future extension consumption.
- **CLI P2 cluster** — `build_*_item` helper compression, `Vault::after_manifest_change`, `ParamsFile` dedup, batched purge.
## Synthesis references
- `docs/superpowers/reviews/2026-05-04-architecture-review.md` — P1.2, P1.3, P1.10 + DEV-B's CLI P2 cluster + DEV-A's base32 dedup
- `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B
## New WASM surface (for future Stream C consumption)
The extension does NOT consume these in this round. A future plan should wire them up via `wasm.d.ts` regeneration:
- `parse_month_year` — for QR-import / smart-input flows
- `base32_encode_rfc4648` / `base32_decode_rfc4648` — for TOTP / future QR work
- `guess_mime_for_extension` — for attachment MIME detection
## Test plan
- [x] `cargo build` green
- [x] `cargo test` green (workspace-wide)
- [x] `cargo test -p relicario-core` green (new base32 module + parser tests)
- [x] `cargo test -p relicario-cli --test basic_flows` green (regression on the split)
- [x] `cargo test -p relicario-cli --test edit_and_history` green
- [x] `cargo test -p relicario-cli --test attachments` green
- [x] `cargo test -p relicario-cli --test settings` green
- [x] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green (new exports)
- [x] `cargo run -p relicario-cli -- --help` displays unchanged surface
- [x] `cargo run -p relicario-cli -- generate --length 32` smoke
- [x] git_run sweep: every git failure path now prints captured stderr + context
- [x] Steam alphabet at `item_types/totp.rs` untouched
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
## First action
After reading the required inputs and setting up the worktree:
1. Call `read_messages(for="dev-b")` to drain any early inbox messages.
2. Emit a `## STATUS UPDATE` confirming setup complete:
```
## STATUS UPDATE — DEV-B
Time: <iso8601>
Branch: feature/arch-followup-stream-b-cli-restructure
Task: setup
Status: DONE
Last commit: <main HEAD sha + first line>
Tests: N/A
Notes: Worktree at /home/alee/Sources/relicario.arch-followup-stream-b. Synthesis + DEV-B/DEV-A notes + DEV-C boundary section absorbed. Awaiting Plan B at docs/superpowers/specs/2026-05-04-cli-restructure-design.md.
```
3. **Wait** for the PM's `## DIRECTIVE TO DEV-B` with `Action: PROCEED` and the plan path.
4. Read the plan, then start Task 1 using `superpowers:subagent-driven-development`. Phase 1 is the main.rs split — finish it cleanly before any other phase.

View File

@@ -0,0 +1,275 @@
# Dev C Kickoff Prompt — Architecture Review Followup, Stream C (Extension Restructure)
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement the plan task-by-task once it lands.
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream C for the Relicario architecture-review-followup work. Stream C is the **largest plan in the bundle** (multi-day to multi-week): turn `setup.ts` into a UI that posts SW messages instead of orchestrating WASM directly, split `vault.ts` into focused modules, give `shared/state.ts` a real type contract, deduplicate the SW router helpers, and pick up the in-scope extension P2 cluster.
A PM in another terminal coordinates you with two other senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly. **Your plan does not exist yet.** The PM is drafting it as their first action. Set up your worktree, post acknowledgement, and wait for the PM's `PROCEED` directive containing the plan path before starting Task 1.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git worktree add /home/alee/Sources/relicario.arch-followup-stream-c -b feature/arch-followup-stream-c-extension-restructure
cd /home/alee/Sources/relicario.arch-followup-stream-c
pwd # should print /home/alee/Sources/relicario.arch-followup-stream-c
```
**Note on `git pull`:** main has uncommitted polish changes from in-flight post-v0.5.1 work, including edits to `extension/src/vault/vault.ts`, `vault.css`, and `shared/glyphs.ts` — files Stream C will touch heavily. **Surface this to the PM as a Question before starting Phase 1** so the user can choose to commit/stash the in-flight work, defer Stream C, or proceed and merge at PR time. The setup above branches from local main HEAD without pulling, so your worktree starts clean of those uncommitted edits.
**ALL subsequent work happens in `/home/alee/Sources/relicario.arch-followup-stream-c`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.arch-followup-stream-c` (project memory rule — without the force-cd, subagents may commit to main).
Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking).
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2 cluster only**)
3. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — full DEV-C reviewer notes (your stream's primary source)
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — read the **"Boundary notes for DEV-C"** section in particular (covers the parity-relevant cross-references, especially the new WASM surface that Plan B will produce)
5. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — light skim for context only
6. **Your plan (will land at):** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — read once the PM directs you to PROCEED
## Execution mode
Use **superpowers:subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.arch-followup-stream-c
```
…before any other instruction. This is non-negotiable per project memory.
## Your scope and boundaries
**In scope:**
- P1.4 — `extension/src/setup/setup.ts` (1220 LOC):
- Add `create_vault` and `attach_vault` SW messages to `service-worker/router/popup-only.ts` + the message-type union in `shared/messages.ts`
- Rewrite `setup.ts` as a UI that posts those messages with gathered config + image bytes (no direct `relicario-wasm` import)
- Convert the 6-step procedural wizard into a step-registry pattern: `Array<{ id: StepId; render: (host) => void; attach: (host) => () => void }>`
- `clearWizardState()` bound to `beforeunload` and to "return to step 0"
- Target ~500 LOC after the rewrite (down from 1220)
- P1.5 — split `extension/src/vault/vault.ts` (1027 LOC) into:
- `vault-shell.ts` (init, hash routing)
- `vault-sidebar.ts` (sidebar render)
- `vault-list.ts` (list pane render)
- `vault-drawer.ts` (drawer state + render)
- `vault-form-wrapper.ts` (form integration)
- `vault.ts` keeps only routing + state coordination
- **Lift the `vault_locked` RPC intercept into `shared/state.ts`** (or a wrapper around `sendMessage`) so popup and vault tab use the same channel — closes the synthesis P2 about RPC vs `session_expired` event divergence
- Reset `state.drawerOpen` at the start of `renderPane` for non-list views
- P1.6 — concrete `StateHost` interface in `extension/src/shared/state.ts`:
```ts
interface StateHost {
state: PopupState;
navigate: (view: View) => void;
popOutToTab(): void;
isInTab(): boolean;
openVaultTab(hash?: string): void;
}
```
- Make `getState`/`setState` generic over `keyof PopupState`
- Throw on `registerHost()` re-register
- Export `__resetHostForTests()` helper
- P1.9 — extract from both router files:
- `loadDeviceSettings`, `loadBlacklist`, `saveBlacklist` → `extension/src/service-worker/storage.ts`
- `itemToManifestEntry` (17-line projection) → `extension/src/service-worker/vault.ts`
- Import from both `popup-only.ts` and `content-callable.ts`
- In-scope extension P2 cluster:
- Inactivity-timer reset on content-callable messages (`service-worker/index.ts:76-78`)
- Null `state.gitHost` alongside `state.manifest` on session expiry (`service-worker/index.ts:51-58`)
- Teardown helper extraction: `settings.ts:56-65` and `settings-vault.ts:15-22` → `teardownSettingsCommon()`
- `Promise.allSettled` in `devices.ts:47-50` and `trash.ts:39-46`
- MutationObserver debounce in `content/detector.ts:96-103` (`requestIdleCallback` or 200ms timer)
- Vault-tab status indicator from new `get_vault_status` SW message returning `{ ahead, behind, lastSyncAt, pendingItems }` (closes the `relicario status` parity gap)
**Out of scope (other DEVs own these):**
- P1.1, JS free-swallow, P1.7, P1.8 — DEV-A (Stream A). **Note:** if A merges first, you'll inherit `impl Drop for SessionHandle`. That's expected and benign.
- P1.2, P1.3, P1.10 + the in-scope CLI P2s — DEV-B (Stream B). The new WASM exports (`parse_month_year`, `base32_*`, `guess_mime_for_extension`) that Plan B produces are NOT consumed in this round. Document the future consumer in your plan's "Out of scope" section; a later round wires them up.
- Other DEV-C P2/P3 not listed in your scope (e.g. shared-utilities response typing, group-autocomplete escaping, restore_backup payload extract, content-script `fillFields()` ack, content/icon outside-click leak) — explicitly deferred to a future round
- Setup-wizard manifest path constants (`VAULT_PATHS`) — folds into your P1.4 work; if convenient include it, else defer
- WASM JS-naming snake_case → camelCase rename — explicitly deferred
- Any of the 8 "Open architectural decisions" at the bottom of the synthesis
If you trip over an out-of-scope issue or a new bug while doing your work, file a `## QUESTION TO PM` block and keep moving.
**Hard rules:**
- **P1.6 (state.ts typing) MUST land first internally.** Both P1.4 and P1.5 push through the `StateHost` surface. Plan should call this out as Phase 1.
- **`setup.ts` MUST stop importing `relicario-wasm` directly after P1.4.** This is the architectural invariant the synthesis is fixing. After P1.4, the only file in `extension/src/` that imports `relicario-wasm` should be the service worker.
- **New SW message names are `create_vault` and `attach_vault`.** Use these exact names everywhere (`shared/messages.ts`, `service-worker/router/popup-only.ts`, `setup.ts`). The PM's coherence pass on the plans confirms naming consistency.
- **`vault_locked` unification goes in `shared/state.ts`** (or a wrapper around `sendMessage`). Popup AND vault tab use the same channel after this lands. Two channels for one outcome was the synthesis observation.
- **No emoji anywhere in `extension/src/`** (existing project rule from v0.5.1). If you see one while editing, replace with the monochrome glyph from `glyphs.ts`.
- **`glyphs.ts` is single source of truth** for icon characters. No inline Unicode literals at call sites.
- **Discriminated-union message contract is preserved.** New messages get added to `shared/messages.ts` and the appropriate capability set (`POPUP_ONLY_TYPES` or `CONTENT_CALLABLE_TYPES`). The boundary discipline holds.
- **WASM remains snake_case in JS** — no `#[wasm_bindgen(js_name = ...)]` renaming. That's a separate decision (DEV-B/DEV-C P3).
- **Test setup is vitest** for the extension (`extension/package.json: "test": "vitest run"`), `bun test` for `tools/relay/`. Do not change runners.
- **Synthetic test fixtures only.** No binary blobs.
- Do not merge your branch to main. The PM owns merges.
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
## Coordination with Stream B
Stream B will produce new `relicario-wasm` exports (`parse_month_year`, `base32_*`, `guess_mime_for_extension`). You do NOT consume these in this round — your plan's "Out of scope" section should explicitly call this out and reference the future consumer (likely a smart-input / QR-import / attachment-MIME round). If Stream B's WASM signature for any of these would NOT match what the extension would naturally consume, raise a `## QUESTION TO PM` so it can be reconciled before Stream B merges.
If Stream A merges first and you inherit `impl Drop for SessionHandle` plus the JS free-swallow removal: that's benign. Your `.free()` callsites should already be `wasm.lock(handle)` first per DEV-A's audit.
## 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"}'
```
## Coordination protocol
You are one of four terminals. Before starting each task, call `read_messages(for="dev-c")` to drain your inbox.
When posting a status update, call `post_message(from="dev-c", to="pm", kind="status", body="...")` with the body:
```
## STATUS UPDATE — DEV-C
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
Branch: feature/arch-followup-stream-c-extension-restructure
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:**
```
## 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
```
**You'll receive:** `## DIRECTIVE TO DEV-C` blocks from the PM. The first one will say `Action: PROCEED` with the plan path once the PM has drafted it. Acknowledge and start Task 1.
## Authority within the plan
You don't need PM permission to:
- Execute task-to-task per the plan once you have it
- Make implementation decisions consistent with the plan and synthesis
- 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 WASM signature mismatch with what Stream B is producing
- A test you can't make green after honest debugging (don't fudge — debug)
- A discovered bug not in your plan
- The in-flight uncommitted changes on `vault.ts`/`vault.css`/`glyphs.ts` need resolution before a merge
- Anything destructive (per CLAUDE.md)
- Before opening the PR for review
## Final steps before REVIEW-READY
Run the project's full validation:
```bash
cd /home/alee/Sources/relicario.arch-followup-stream-c
cd extension
bun run test
bun run build
bun run build:firefox
cd ..
cargo build -p relicario-wasm --target wasm32-unknown-unknown
```
All must be green. Vitest covers the new `StateHost` typing, the message-router dedup, and the wizard step registry.
Then sweep for emoji and in-flight collision:
```bash
grep -rn '\U0001F\|🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️\|🖥\|🔐\|📎' /home/alee/Sources/relicario.arch-followup-stream-c/extension/src/ 2>/dev/null
```
Expected: no output (project rule).
Then push and open the PR:
```bash
git push -u origin feature/arch-followup-stream-c-extension-restructure
gh pr create --base main --head feature/arch-followup-stream-c-extension-restructure \
--title "refactor(ext): setup-via-SW + vault.ts split + state.ts typing + SW router dedup (Stream C)" \
--body "$(cat <<'EOF'
## Summary
Stream C of the architecture-review-followup. The largest plan in the bundle — turns three "code lies about the architecture" surfaces into uniform readable modules:
- **P1.4** — `extension/src/setup/setup.ts` no longer imports `relicario-wasm` directly. New `create_vault` / `attach_vault` SW messages do the crypto orchestration. The 6-step wizard is now a step registry. ~500 LOC down from 1220.
- **P1.5** — `extension/src/vault/vault.ts` (1027 LOC) split into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`. `vault_locked` RPC intercept lifted into `shared/state.ts` so popup and vault tab share one channel.
- **P1.6** — `extension/src/shared/state.ts` has a concrete `StateHost` interface; `getState`/`setState` are generic over `keyof PopupState`; double-registration guard + `__resetHostForTests` helper.
- **P1.9** — duplicated SW router helpers extracted: `service-worker/storage.ts` for the three storage helpers, `service-worker/vault.ts` for `itemToManifestEntry`.
- **Extension P2 cluster** — inactivity-timer reset on content-callable messages, `state.gitHost` clear on session expiry, `teardownSettingsCommon()`, `Promise.allSettled` in devices/trash, MutationObserver debounce in content/detector, vault-tab status indicator (closes `relicario status` parity gap).
## Synthesis references
- `docs/superpowers/reviews/2026-05-04-architecture-review.md` — P1.4, P1.5, P1.6, P1.9 + DEV-C's extension P2 cluster
- `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — Plan C
## Future-round consumer (NOT in this PR)
Plan B produces new `relicario-wasm` exports (`parse_month_year`, `base32_*`, `guess_mime_for_extension`). A later round will wire them up via `wasm.d.ts` regeneration and add SW message handlers. Out of scope here.
## Test plan
- [x] `bun run test` green in `extension/`
- [x] `bun run build` green
- [x] `bun run build:firefox` green
- [x] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
- [x] No emoji anywhere in `extension/src/` (grep clean)
- [x] `setup.ts` no longer imports `relicario-wasm`
- [x] `vault.ts` LOC dropped substantially; new modules each under ~300 LOC
- [x] Vault tab and popup both use one channel for `vault_locked` (no RPC intercept divergence)
- [x] StateHost interface concrete; tests cover double-registration guard and reset helper
EOF
)"
```
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
## First action
After reading the required inputs and setting up the worktree:
1. Call `read_messages(for="dev-c")` to drain any early inbox messages.
2. Emit a `## STATUS UPDATE` confirming setup complete:
```
## STATUS UPDATE — DEV-C
Time: <iso8601>
Branch: feature/arch-followup-stream-c-extension-restructure
Task: setup
Status: DONE
Last commit: <main HEAD sha + first line>
Tests: N/A
Notes: Worktree at /home/alee/Sources/relicario.arch-followup-stream-c. Synthesis + DEV-C notes + DEV-B boundary section absorbed. Awaiting Plan C at docs/superpowers/specs/2026-05-04-extension-restructure-design.md.
```
3. **Also emit a `## QUESTION TO PM`** about the in-flight uncommitted edits to `vault.ts` / `vault.css` / `glyphs.ts` on main:
```
## QUESTION TO PM — DEV-C
Time: <iso8601>
Context: setup, before Phase 1
Options: A: PM/user commits or stashes the in-flight v0.5.x polish on main before I start, so my worktree has a stable baseline. B: I proceed and rebase/merge those changes at PR time. C: PM defers Stream C until the in-flight work is committed.
Recommended: A — clean baseline avoids merge churn during the largest plan in the bundle.
Blocker: no — I can read the plan and start Phase 1 (P1.6 state.ts typing) which doesn't touch the in-flight files. Resolution needed before Phase 2 (P1.5 vault.ts split).
```
4. **Wait** for the PM's `## DIRECTIVE TO DEV-C` with `Action: PROCEED` and the plan path.
5. Read the plan, then start Task 1 using `superpowers:subagent-driven-development`. Phase 1 is P1.6 (state.ts typing) — finish it cleanly before P1.4 / P1.5.

View File

@@ -0,0 +1,133 @@
# Planning Kickoff Prompt — Architecture-Review Follow-up
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are an architect drafting **three implementation plans** that follow up on the whole-codebase architecture review committed to `main` on 2026-05-04 (`061facd docs(reviews): whole-codebase architecture audit 2026-05-04`). Three reviewers (DEV-A: Rust core; DEV-B: Rust consumers; DEV-C: TypeScript) walked the codebase; the PM synthesized 10 P1s plus a long P2/P3 tail. Your job is to convert those findings into three discrete, well-scoped plans that the user (or future agents) can execute.
This is **planning-only work.** Do not modify the codebase being planned. Do not commit. Do not ship anything. Each plan is a markdown file in `docs/superpowers/specs/` and that is the only output.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Stay on `main`. Do not create branches or worktrees.
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
- The working tree has uncommitted changes from in-flight v0.5.x work (manifests, vault.ts/css, glyphs, relay tooling, Cargo.toml version bumps). Treat them as "in flight, ignore" — do not include them in any plan.
## Required reading (in order)
1. `CLAUDE.md` — project rules (Spanish flourish, capitalization, autonomy)
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — foundational design spec (threat model, crypto pipeline, format)
3. `docs/superpowers/reviews/2026-05-04-architecture-review.md`**PM synthesis. This is the canonical source. Every plan below must trace its scope back to specific findings here.**
4. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — DEV-A's full notes (Rust core)
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (CLI/server/WASM), especially the "Boundary notes for DEV-C" section
6. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — DEV-C's full notes (extension + relay), especially the "Boundary notes for DEV-B" section
Also skim two existing plan docs to match the format and tone:
- `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md`
- `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md`
## What to produce
Three plan files, all under `docs/superpowers/specs/`:
### Plan A — Security & docs polish PR
**Filename:** `docs/superpowers/specs/2026-05-04-security-polish-design.md`
**Scope (drawn from synthesis):** P1.1 (`SessionHandle` `impl Drop` + Rust `wasm-bindgen-test` + extension `.free()` callsite audit), the partner JS-side fix at `service-worker/session.ts:26` (remove the `try { current.free() }` swallow), P1.7 (`recovery_qr.rs` documentation to match `crypto.rs`/`backup.rs` density), and the launcher fix DEV-C suspected at `tools/relay/start.sh:80` (4-window dev-c support; queue.test.ts assertion already fixed in `061facd`). Goal: one short PR, all-S-effort items, ships in under a day. This is the security-flavored quick win that goes first; nothing in B or C should depend on it.
### Plan B — CLI restructure
**Filename:** `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
**Scope:** P1.2 (split `crates/relicario-cli/src/main.rs` 2641 LOC into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs` + `prompt.rs` + `parse.rs`, leaving `main.rs` as clap definitions + dispatcher), P1.3 (`helpers::git_run(repo, args, context)` with stderr capture; sweep the ~16 duplicated bail sites), P1.10 (migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` to `relicario-core` and re-export through `relicario-wasm` for the extension; pair with the core base32 dedup from DEV-A's P2), plus the in-scope CLI P2s (`build_*_item` helper compression, `refresh_groups_cache` discipline via `Vault::after_manifest_change`, `ParamsFile` dedup between `main.rs:2287` and `session.rs:114`, batched purge in `cmd_purge`/`cmd_trash_empty`). Sequencing matters here: the `main.rs` split must land first because every other P2 in this plan touches files that don't exist yet until the split happens. Goal: M-L effort, multi-day plan; "single biggest readability lift" per the synthesis.
### Plan C — Extension restructure
**Filename:** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
**Scope:** P1.4 (`extension/src/setup/setup.ts` 1220 LOC: add `create_vault` and `attach_vault` SW messages; rewrite setup as a UI that posts those messages with gathered config + image bytes; convert the 6-step procedural wizard into a step-registry pattern `{ id, render, attach }[]`; add `clearWizardState()` on `beforeunload`), P1.5 (split `extension/src/vault/vault.ts` 1027 LOC into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`; lift the `vault_locked` RPC intercept into `shared/state.ts` so popup and vault use one channel; reset `state.drawerOpen` on non-list `renderPane`), P1.6 (concrete `StateHost` interface in `shared/state.ts` with `state: PopupState`, `navigate`, `popOutToTab`, `isInTab`, `openVaultTab`; generic `getState`/`setState` over `keyof PopupState`; double-registration guard + `__resetHostForTests` helper), P1.9 (extract duplicated SW router helpers — `loadDeviceSettings`/`loadBlacklist`/`saveBlacklist` to `service-worker/storage.ts`; `itemToManifestEntry` to `service-worker/vault.ts`), plus the in-scope extension P2s (inactivity-timer reset on content-callable messages; `state.gitHost` clear on session expiry; teardown helper extraction; `Promise.allSettled` in devices/trash; mutation-observer debounce in content/detector.ts; vault-tab status indicator from `get_vault_status` for the `relicario status` parity gap). Sequencing: P1.6 (state.ts typing) is a precondition for P1.4 and P1.5 because lifting `vault_locked` and adding setup-via-SW both push through that surface. Goal: largest plan, multi-day to multi-week.
## What's explicitly NOT in these three plans
- The full P2/P3 tail outside what's listed above. The synthesis doc is canonical; if a P2 isn't named in one of the three scopes above, it doesn't go in. Future plans can pick those up.
- WASM JS-naming snake_case → camelCase (DEV-B/DEV-C P3) — defer to a separate decision, not these plans.
- The 8 "Open architectural decisions" at the bottom of the synthesis — those are user-judgement calls, not implementation tasks.
- Anything that requires touching the in-flight uncommitted v0.5.x work (manifests, glyphs, vault.css, relay tooling beyond start.sh).
## Plan format (match the existing specs)
Each plan file should have, at minimum:
```markdown
# <Title> — Design
**Date:** 2026-05-04
**Status:** Proposed
**Source:** docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.X, P1.Y, ...)
**Effort estimate:** S | M | L
## Summary
2-4 sentences: what this plan accomplishes and why it matters for the user's stated goal ("make this app's architecture logical and readable for someone who doesn't know Rust but wants to learn by tinkering").
## Findings addressed
Bullet list, each entry citing the synthesis P-tag, the reviewer who found it, and the file:line.
## Approach
The architectural shape of the work — what gets extracted, what gets created, what gets renamed. Include directory trees / module diagrams if it helps a reader follow the layout change.
## Implementation phases
Numbered phases. Each phase:
- **Goal:** one-sentence outcome
- **Changes:** specific files touched, new files created, with paths
- **Tests:** what gets added or moved (synthetic fixtures only — no binary blobs, per project convention)
- **Effort:** S/M/L for the phase
- **Depends on:** other phases, or "none"
## Risks and mitigations
What can break, especially across the CLI/extension parity boundary. Cite specific findings.
## Out of scope
Explicit list of adjacent things this plan does NOT touch.
## Done criteria
Checklist a reviewer can use to confirm the plan shipped.
```
Mirror the tone and depth of `2026-05-02-v0.5.0-polish-harden-design.md` and `2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md`. Use the same heading conventions.
## Execution approach
**Use subagents.** Per the user's standing preference (CLAUDE.md memory: "Default to subagent-driven execution — for any multi-task plan use subagent-driven mode without asking"), drafting these plans is parallel work. Spawn three `Plan` subagents in parallel (single message, three tool calls), one per plan file. Each subagent reads the same shared inputs (synthesis + relevant notes file(s)), then writes only its assigned plan file. Plan B and Plan C subagents should each receive the relevant cross-boundary section of the *other* reviewer's notes ("Boundary notes for DEV-C" goes to Plan C's drafter; "Boundary notes for DEV-B" goes to Plan B's drafter) so the parity boundary is respected.
After the three subagents return, do a coherence pass yourself:
- Confirm Plan A doesn't depend on B or C
- Confirm Plan B's `parse_month_year`/`base32` migration to core is reachable from Plan C's WASM consumers (or note explicitly that Plan C will pick up the WASM exports in a later phase)
- Confirm Plan C's setup-via-SW migration cites the same `create_vault`/`attach_vault` message names everywhere
Each subagent prompt **must** start with `cd /home/alee/Sources/relicario` (project memory rule — without the force-cd, subagents may write to the wrong tree).
## Hard constraints
- **Read-only on the codebase being planned.** No `cargo`, `bun`, or any other write commands beyond writing the three plan files.
- **No commits.** The user commits when ready.
- **No git operations.** Don't even `git status` unless you have a specific reason.
- **Three files, no fewer, no more.** If a finding doesn't fit any of the three scopes above, leave it out — future plans handle it.
- **Spanish flourish in replies (1-2 phrases per reply with `[translation]` brackets), per CLAUDE.md.** Do not put Spanish into the plan files themselves — those are project artifacts.
## Done criteria
Before posting your final summary:
- [ ] All three plan files exist under `docs/superpowers/specs/` with the exact filenames specified above
- [ ] Each plan cites at least one specific P1 from the synthesis in its "Findings addressed" section
- [ ] Each plan has phases, each phase has effort, each phase names specific files
- [ ] Plan A is independent (no dependencies on B or C)
- [ ] Plan B and Plan C call out the parity-relevant cross-references explicitly
- [ ] You did NOT modify any code, run any test, or commit anything
- [ ] You ask the user whether to commit the three plan files (do not commit unprompted)
## First action
1. Read the synthesis (`docs/superpowers/reviews/2026-05-04-architecture-review.md`) end-to-end. Internalize the 10 P1s and the cross-cutting themes.
2. Skim the three per-reviewer notes files for the file:line context the synthesis abbreviates.
3. Skim the two existing plan docs above to absorb the format.
4. Tell the user what you absorbed in 3-5 sentences.
5. Spawn three `Plan` subagents in parallel with the scopes specified above.
6. Do the coherence pass.
7. Ask the user whether to commit the three plan files.

View File

@@ -0,0 +1,211 @@
# PM Kickoff Prompt — Architecture Review Followup (2026-05-04)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the Relicario architecture-review-followup work. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals.
This release has no version tag — it's a structural-cleanup bundle from the 2026-05-04 architecture audit (commit `061facd`). The goal is to make the codebase uniformly readable for a smart developer who doesn't know Rust but wants to learn by tinkering. There is no merge freeze, no CHANGELOG entry needed, and no tag at the end unless the user requests one.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking, default to subagent-driven execution).
- **Working-tree note:** main has uncommitted polish changes from in-flight post-v0.5.1 work (glyphs/vault/relay/manifests/Cargo.toml version bumps, plus the four `architecture-review-*-prompt.md` files this session is generating). The synthesis explicitly tags these "in flight, ignore" — none of the three plans should include them. Also: Stream C touches `extension/src/vault/vault.ts`, `vault.css`, and `shared/glyphs.ts` which currently have uncommitted edits on main. Surface this to the user before unlocking DEV-C; either commit/stash the in-flight changes or note that C will need to merge them in at PR time.
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md`**PM synthesis. This is the canonical source. Every plan must trace its scope back to specific findings here.**
3. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — full DEV-A notes (Rust core)
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — full DEV-B notes (CLI/server/WASM), especially the "Boundary notes for DEV-C" section
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — full DEV-C notes (extension + relay), especially the "Boundary notes for DEV-B" section
6. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` and `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — format reference for the plan docs you will draft
## Stream overview
| Stream | Branch | Owner | Plan file (to be drafted) | Items |
|--------|--------|-------|---------------------------|-------|
| A — Security & docs polish | `feature/arch-followup-stream-a-security-polish` | DEV-A | `docs/superpowers/specs/2026-05-04-security-polish-design.md` | P1.1, JS free-swallow fix, P1.7, P1.8 |
| B — CLI restructure | `feature/arch-followup-stream-b-cli-restructure` | DEV-B | `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` | P1.2, P1.3, P1.10 + 4 in-scope CLI P2s |
| C — Extension restructure | `feature/arch-followup-stream-c-extension-restructure` | DEV-C | `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` | P1.4, P1.5, P1.6, P1.9 + in-scope ext P2s |
## Your authority
- Approve or deny scope changes from devs
- Review and merge PRs from each stream's feature branch
- **Draft the three plan docs as your first hands-on action.** This is the single biggest piece of work you own personally.
- Edit `docs/`, `CLAUDE.md`, or other doc artifacts as needed; do not write feature code
## Your boundaries
- Don't write feature code yourself. Edits to docs / `CLAUDE.md` are fine.
- Don't deviate from the synthesis scope 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 (none planned for this work).
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
## Cross-stream coordination
- **Stream A is independent** — no dependencies on B or C. Merge first if it's ready.
- **Stream B's parser→core migration** (`parse_month_year`, `base32_decode_lenient`, `guess_mime` + the base32 dedup from DEV-A's P2) produces new `relicario-core` functions and re-exports them through `relicario-wasm`. Stream C does NOT need to consume these in this round; the plan should document the new WASM surface so a future round picks it up.
- **Stream C's internal sequencing**: P1.6 (`shared/state.ts` typing) must land before P1.4 (setup-via-SW migration) and P1.5 (`vault.ts` split). This is internal to Plan C, not a cross-stream concern.
- **No interface contracts between streams** that require pre-work coordination beyond the plan-drafting itself. Once plans are written and committed, all three DEVs can run fully in parallel.
## 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"}'
```
## Coordination protocol
You are one of four 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.
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
**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, give a current rollup:
```
## RELEASE STATUS — Architecture Review Followup
Devs: <per-dev one-line state>
PM: <what you're working on>
Blockers: <list, or "none">
Next milestone: <e.g., "Plan A drafted", "DEV-B REVIEW-READY">
```
## 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 plan's "Done criteria" and the synthesis P-tags it claims to address
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash per project convention)
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-merge checklist (per stream)
Before each `MERGE-APPROVED`:
- [ ] Plan's "Done criteria" all checked
- [ ] Every synthesis P-tag the plan claims to address has a corresponding diff change
- [ ] Full test suite green for that stream's languages (`cargo test`, `bun run test` in `extension/`, etc.)
- [ ] No regression in CLI/extension parity (synthesis section "CLI/extension parity status")
- [ ] No emoji introduced anywhere in `extension/src/` (existing project rule)
## First action — draft the three plan docs
Before unlocking any DEV to start work, you must draft the three plan files. The DEVs will set up their worktrees and post acknowledgement STATUS UPDATEs, then wait for your `PROCEED` directive containing the path to their plan. Until the plans exist, the DEVs are blocked.
**Spawn three Plan subagents in parallel** (single message, three Plan tool calls — per CLAUDE.md memory rule "default to subagent-driven execution"). Each subagent prompt **must start with** `cd /home/alee/Sources/relicario` (project memory rule — without the force-cd, subagents may write to the wrong tree).
Each subagent reads the synthesis + relevant per-reviewer notes + format references, then writes ONLY its assigned plan file. None modify code, run tests, or commit.
### Plan A subagent scope
**Filename:** `docs/superpowers/specs/2026-05-04-security-polish-design.md`
**Effort:** S — under-a-day PR
**Items:**
- P1.1 — `impl Drop for SessionHandle { fn drop(&mut self) { session::remove(self.0); } }` in `crates/relicario-wasm/src/lib.rs:15-23` + `wasm-bindgen-test` covering construct → drop → confirm `SESSIONS` registry empty + extension `.free()` callsite audit confirming `wasm.lock(handle)` happens first regardless
- JS partner fix at `extension/src/service-worker/session.ts:26` — remove the `try { current.free() }` swallow so exceptions propagate (or log + counter)
- P1.7 — `crates/relicario-core/src/recovery_qr.rs` documentation pass: module-level `//!` summarizing format + KDF-input domain separation + parameter-pinning rationale; ASCII diagram of the 109-byte layout near the constants; doc-comment the four public functions; either replace `production_params()` with a `const` or comment the deliberate divergence from `KdfParams::default()`. Match the density of `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`.
- P1.8 — `tools/relay/start.sh:80` launcher fix for the dev-c fourth window (queue.test.ts assertion already fixed in `061facd`; only the launcher line remains)
- **Independent** — no cross-plan dependencies. Plan A goes first.
### Plan B subagent scope
**Filename:** `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
**Effort:** M-L — multi-day. "Single biggest readability lift" per synthesis.
**Items:**
- P1.2 — split `crates/relicario-cli/src/main.rs` (2641 LOC) into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs` + `prompt.rs` (six `prompt_*` helpers + `prompt_secret`) + `parse.rs` (the three pure parsers). `main.rs` keeps clap definitions + dispatcher (~470 lines).
- P1.3 — add `helpers::git_run(repo, args, context)` that uses `.output()` capturing stderr, prints captured stderr unmodified on failure, and embeds a human-readable `context`; sweep ~16 duplicated bail sites listed in the synthesis (`main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` and others)
- P1.10 — migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` to `relicario-core`; pair with DEV-A's P2 base32 dedup (extract `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648`, leave Steam's bespoke alphabet untouched); re-export through `relicario-wasm` via `#[wasm_bindgen]` so the extension can consume them in a later round
- In-scope CLI P2s:
- `build_*_item` helper compression with a `prompt_or_flag<T>` helper (`main.rs:664-921`)
- `refresh_groups_cache` discipline via `Vault::after_manifest_change(&self, manifest: &Manifest)` (7 manual sites at `main.rs:641, 998, 1123, 1197, 1414, 1432, 1474`)
- `ParamsFile` dedup between `main.rs:2287` (write side, has `aead`/`salt_path`/`format_version`) and `session.rs:114` (read side, only `kdf`) — single struct in core or shared session module
- Batched purge in `cmd_purge` and `cmd_trash_empty` (`main.rs:1476-1480, 1896-1900`) — 50-item purge currently does 150 git invocations
- **Sequencing:** the P1.2 main.rs split must land first because every other P2 in this plan touches files that don't exist yet until the split happens. Plan should call this out as Phase 1.
- **Receives:** the "Boundary notes for DEV-B" section from `dev-c-notes.md`
### Plan C subagent scope
**Filename:** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
**Effort:** L — multi-day to multi-week. Largest plan.
**Items:**
- P1.4 — `extension/src/setup/setup.ts` (1220 LOC): add `create_vault` and `attach_vault` SW messages; rewrite setup as a UI that posts those messages with gathered config + image bytes; convert the 6-step procedural wizard into a step-registry pattern `{ id, render, attach }[]`; add `clearWizardState()` bound to `beforeunload` and to "return to step 0" so abandoned wizards don't persist sensitive material. Setup must stop importing `relicario-wasm` directly.
- P1.5 — split `extension/src/vault/vault.ts` (1027 LOC) into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state. Lift the `vault_locked` RPC intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so popup and vault use one path; reset `state.drawerOpen` at the start of `renderPane` for non-list views.
- P1.6 — concrete `StateHost` interface in `shared/state.ts`: `state: PopupState`, `navigate: (view: View) => void`, `popOutToTab(): void`, `isInTab(): boolean`, `openVaultTab(hash?: string): void`. Make `getState`/`setState` generic over `keyof PopupState`. Throw on `registerHost()` re-register; export `__resetHostForTests()`.
- P1.9 — extract `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` from both router files to `service-worker/storage.ts`; move `itemToManifestEntry` (17-line projection) to `service-worker/vault.ts`. Import from both routers.
- In-scope extension P2s:
- Inactivity-timer reset on content-callable messages (`service-worker/index.ts:76-78`)
- Null `state.gitHost` alongside `state.manifest` on session expiry (`service-worker/index.ts:51-58`)
- Teardown helper extraction (`settings.ts:56-65` and `settings-vault.ts:15-22``teardownSettingsCommon()`)
- `Promise.allSettled` in `devices.ts:47-50` and `trash.ts:39-46`
- MutationObserver debounce in `content/detector.ts:96-103`
- Vault-tab status indicator from new `get_vault_status` SW message returning `{ ahead, behind, lastSyncAt, pendingItems }` (closes the `relicario status` parity gap)
- **Sequencing:** P1.6 (state.ts typing) is a precondition for P1.4 and P1.5 — both push through the StateHost surface. Plan should call this out as Phase 1.
- **Receives:** the "Boundary notes for DEV-C" section from `dev-b-notes.md`. Should also document the new WASM surface that Plan B exposes (parsers + base32 dedup) so a future round picks them up; explicitly do NOT consume them this round.
### Format reference
All three plans match the structure of `2026-05-02-v0.5.0-polish-harden-design.md`:
- `# <Title> — Design`
- Date / Status / Source (cite synthesis P-tags) / Effort estimate
- Summary (2-4 sentences)
- Findings addressed (bullet list, each citing P-tag + reviewer + file:line)
- Approach (architectural shape; module diagrams or directory trees if it helps)
- Implementation phases (numbered; each with Goal, Changes, Tests, Effort, Depends-on)
- Risks and mitigations
- Out of scope
- Done criteria (reviewer checklist)
### After the subagents return
1. **Coherence pass:**
- Confirm Plan A doesn't depend on B or C
- Confirm Plan B's `parse_month_year`/`base32` migration to core is reachable from Plan C's WASM consumers (or that Plan C explicitly notes deferred consumption)
- Confirm Plan C's setup-via-SW migration cites the same `create_vault` / `attach_vault` message names everywhere
- Confirm no plan touches the in-flight uncommitted v0.5.x work (vault.ts, vault.css, glyphs.ts, manifests, relay tooling beyond start.sh, Cargo.toml version bumps)
2. **Ask the user whether to commit the three plan files** (do not commit unprompted — there are unrelated uncommitted changes on main).
3. Once committed (or the user says "ship without committing"), post opening directives to all three devs:
- Confirm their plan path
- PROCEED to start Task 1
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing the queue.
## First action
1. Call `read_messages(for="pm")` to drain any early inbox messages.
2. Read the synthesis end-to-end. Internalize the 10 P1s and the cross-cutting themes.
3. Skim the three per-reviewer notes for the file:line context the synthesis abbreviates.
4. Skim the two existing plan docs above to absorb the format.
5. Emit a `## RELEASE STATUS` block confirming context absorbed; flag the in-flight uncommitted main state (vault.ts/glyphs.ts/etc.) for the user.
6. Spawn three `Plan` subagents in parallel with the scopes specified above.
7. Do the coherence pass.
8. Ask the user whether to commit the three plan files.
9. Post opening directives to all three devs unlocking their work.

View File

@@ -0,0 +1,168 @@
# PM Kickoff Prompt — Architecture Review (whole codebase)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for Relicario's whole-codebase architecture audit (2026-05-04). Three senior reviewers report to you, each working in their own terminal on a partition of the codebase. The user runs all four terminals; the relay server routes messages.
This is **not** a feature release — it is a one-shot architecture review. The user's stated goal: *"I really want to make this app's architecture logical. I don't know Rust but I want to be able to read and understand the code and learn by tinkering with it."* Treat that goal as the primary lens for everything: reviews are valuable when they reduce confusion for a smart non-Rust reader; they are not valuable when they restate what's already obvious.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Stay on `main`. **Do not check out branches, do not create worktrees.** This is a review-only operation. The PM may edit the synthesis doc; reviewers do not edit code.
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
## 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 `"pm"`
- `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, use the Python shim:
```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"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — foundational spec (threat model, crypto pipeline, format)
3. `docs/superpowers/coordination/architecture-review-dev-a-prompt.md` — DEV-A's scope (Rust core)
4. `docs/superpowers/coordination/architecture-review-dev-b-prompt.md` — DEV-B's scope (CLI, server, WASM)
5. `docs/superpowers/coordination/architecture-review-dev-c-prompt.md` — DEV-C's scope (TS extension + relay)
You should NOT walk every file yourself — the reviewers do that. Your job is to coordinate, then synthesize.
## Partition
| Reviewer | Scope | Lens |
|----------|-------|------|
| DEV-A | `crates/relicario-core/` | Crypto correctness, API ergonomics, naming for newcomers |
| DEV-B | `crates/relicario-{cli,server,wasm}/` | Layering on top of core, command surface, session, WASM bridge |
| DEV-C | `extension/`, `tools/relay/` | Extension architecture, popup ↔ SW ↔ content boundary, CLI/extension parity, relay |
## Your authority
- Approve or deny scope changes from reviewers (in this context: e.g. "DEV-A wants to also look at WASM" — usually deny, since DEV-B has it)
- Read each reviewer's notes file (`docs/superpowers/reviews/2026-05-04-dev-{a,b,c}-notes.md`) as they're updated
- Cross-reference findings to identify cross-cutting issues (e.g. a core API ergonomics issue that bites the WASM consumer)
- **Write the synthesis doc** at `docs/superpowers/reviews/2026-05-04-architecture-review.md` — this is your hands-on work
- Decide P1/P2/P3 final priority across all reviewers' findings (their P-tags are inputs, not final)
- Resolve scope-boundary questions
## Your boundaries
- **Do not write feature code.** Edits to the synthesis doc are fine. Edits to other docs / `CLAUDE.md` are fine if they're directly informed by the review (e.g. clarifying a project rule that's actually unwritten).
- Do not modify the codebase being reviewed. The reviewers don't either; act as a backstop on this rule.
- Do not redirect a reviewer mid-stream without good reason. They are doing depth work; context-switching is expensive.
- Do not synthesize prematurely. Wait for at least two reviewers to post `Phase: REVIEW-COMPLETE` before opening the synthesis doc. (You can pre-skim notes earlier, but don't write conclusions.)
- Project rule: ask the user before any git-destructive op.
## Coordination protocol
**Before each action:** `read_messages(for="pm")`.
**You receive:** `## STATUS UPDATE` and `## QUESTION TO PM` blocks from `dev-a`, `dev-b`, `dev-c`.
**You emit:** directives via `post_message(from="pm", to="dev-X", kind="directive", body="...")`. Body format:
```
## DIRECTIVE TO DEV-<letter>
Time: <iso8601>
Action: PROCEED | HOLD | RESCOPE | ANSWER | REVIEW-ACKED
Notes: <one paragraph max>
Next: <one concrete instruction>
```
**Status rollup** (when asked "status?" by the user):
```
## REVIEW STATUS — Architecture Audit 2026-05-04
DEV-A: <phase, files covered, findings count>
DEV-B: <phase, crates covered, findings count>
DEV-C: <phase, areas covered, findings count>
PM: <current action — coordinating | reading-notes | synthesizing | done>
Open questions: <list, or "none">
Next milestone: <e.g., "DEV-A REVIEW-COMPLETE", "synthesis draft", "user review">
```
## Synthesis doc
When at least two reviewers have posted `Phase: REVIEW-COMPLETE` (and ideally all three), write the final review doc to `docs/superpowers/reviews/2026-05-04-architecture-review.md` with this structure:
```markdown
# Relicario — Whole-Codebase Architecture Review
**Date:** 2026-05-04
**Reviewers:** DEV-A (Rust core), DEV-B (Rust consumers), DEV-C (TypeScript)
**Synthesis:** PM
**Goal lens:** "Make this app's architecture logical and readable for a smart developer who doesn't know Rust but wants to learn by tinkering."
## Executive summary
4-6 sentences: overall architectural shape, the 3 most important things to address, the 3 strongest aspects worth preserving.
## Top-priority recommendations (P1)
For each P1 (across all three reviewers — your final prioritization, not theirs):
### P1.N — <short title>
**Area:** <core | cli | server | wasm | extension | shared | tooling | cross-cutting>
**File(s):** `<path>:<line>`
**Found by:** DEV-A | DEV-B | DEV-C | (multiple)
**Observation:** <one paragraph>
**Why it matters for the user's goal:** <how this confuses a Rust newcomer or otherwise blocks "learn by tinkering">
**Suggested direction:** <one paragraph; specific enough to act on, not so prescriptive that it's a plan>
**Effort:** S | M | L (rough — S = under an hour, M = half a day, L = a day or more)
## P2 recommendations
Same format, lighter detail.
## P3 / nice-to-have
Bullets.
## Cross-cutting themes
2-4 paragraphs on patterns that show up in multiple areas (e.g. "error messages are inconsistent across crates", "naming for crypto types is opaque", "TS message types could be generated from the WASM bindings"). These are usually the highest-leverage things — flag them clearly.
## What's strong (preserve)
3-5 specific things that are well-done and that future changes should not erode.
## CLI/extension parity status
A short summary of DEV-C's parity table: gaps, intentional gaps, gaps to close.
## Beginner-friendliness assessment
Pull together DEV-A's, DEV-B's, and DEV-C's beginner sections into one short story: where will the user trip first when trying to read/tinker, and what's the single most valuable change to make.
## Appendix: pointers to per-reviewer notes
- [DEV-A notes — Rust core](./2026-05-04-dev-a-notes.md)
- [DEV-B notes — Rust consumers](./2026-05-04-dev-b-notes.md)
- [DEV-C notes — TypeScript](./2026-05-04-dev-c-notes.md)
```
You may use the `superpowers:requesting-code-review` skill to get an independent second-pass on your synthesis before declaring done — but only after the synthesis is in draft.
## Done criteria
Before posting `## REVIEW STATUS — Architecture Audit 2026-05-04` with `PM: done`:
- [ ] All three reviewers posted `Phase: REVIEW-COMPLETE`
- [ ] All three notes files exist and are non-empty under `docs/superpowers/reviews/`
- [ ] `docs/superpowers/reviews/2026-05-04-architecture-review.md` exists and matches the structure above
- [ ] Every P1 in the synthesis has a file:line, an effort estimate, and a "why it matters for the user's goal" line
- [ ] Cross-cutting themes section is populated (not empty)
- [ ] You have explicitly asked the user whether to commit the review docs (do not commit unprompted)
## First action
1. Call `read_messages(for="pm")`.
2. Read the project rules and the spec.
3. Read each dev's prompt (links above) so you know exactly what scope each owns.
4. Emit a `## REVIEW STATUS — Architecture Audit 2026-05-04` block to the user, confirming setup.
5. Send opening directives to all three devs via `post_message`, each saying `Action: PROCEED` and reaffirming their scope (so it's recorded in the relay log).
6. Wait for acknowledgement status updates from all three before settling into coordination mode.

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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
# PM Kickoff Prompt — v0.5.1 UX Polish + Recovery QR
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the Relicario v0.5.1 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four 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-03. Project rules in `CLAUDE.md` apply (Spanish flourish, capitalization, autonomy defaults, never run git-destructive commands without asking).
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — full spec
3. `docs/superpowers/coordination/v0.5.1-dev-a-prompt.md` — Dev A's plan (Stream A: fullscreen + popup layout)
4. `docs/superpowers/coordination/v0.5.1-dev-b-prompt.md` — Dev B's plan (Stream B: settings UX)
5. `docs/superpowers/coordination/v0.5.1-dev-c-prompt.md` — Dev C's plan (Stream C: recovery QR)
## Your authority
- Approve or deny scope changes from devs
- Review and merge PRs from all three feature branches
- **Drive the interface contract** between B and C (see below) — this is your first hands-on action
- Write the `CHANGELOG.md` entry for v0.5.1
- Tag `v0.5.1` once everything is integrated **— but only after explicit user approval**
## Your boundaries
- Don't write feature code yourself. Edits to docs / CHANGELOG / CLAUDE.md 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.
## Stream overview
| Stream | Branch | Owner | Core files |
|--------|--------|-------|-----------|
| A — Fullscreen + popup layout | `feature/v0.5.1-stream-a-layout` | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `item-form.ts`, `glyphs.ts`, `toast.ts` |
| B — Settings UX | `feature/v0.5.1-stream-b-settings` | DEV-B | `settings.ts`, `settings-vault.ts` (decomposed), `settings-security.ts` (stub only) |
| C — Recovery QR | `feature/v0.5.1-stream-c-recovery-qr` | DEV-C | `recovery_qr.rs`, WASM `session.rs`/`lib.rs`, `settings-security.ts`, `setup.ts` |
## Interface contracts (enforce before work starts)
### AB: Settings component signature
DEV-B's settings component is wired into vault.ts by DEV-A. Both must agree before either proceeds with their vault.ts / settings.ts work.
**Agreed interface** (post to both devs as your first directive):
```ts
// extension/src/popup/components/settings.ts
/**
* Render the full sectioned settings view into `container`.
* May be called from vault.ts (fullscreen, full-width pane) or popup.ts (popup).
*/
export async function renderSettings(container: HTMLElement): Promise<void>;
/**
* Teardown: close any open generator panel, remove keyboard listeners.
* Call before navigating away from the settings view.
*/
export function teardownSettings(): void;
```
DEV-A imports `{ renderSettings, teardownSettings }` from `settings.ts` in vault.ts.
DEV-B exports these names with these exact signatures.
### BC: Security section component signature
DEV-C owns and implements `settings-security.ts`. DEV-B imports it for the Security section. They must agree before DEV-B writes B4 (Security section) or DEV-C writes C8 (settings-security.ts).
**Agreed interface** (post to both devs as your first directive):
```ts
// extension/src/popup/components/settings-security.ts
/**
* Render the three-state Recovery QR + trusted devices security section
* into `container`. `sessionHandle` is the current WASM session handle value
* (from the service-worker's session), or null if the vault is locked.
*/
export async function renderSecuritySection(
container: HTMLElement,
sessionHandle: number | null,
): Promise<void>;
/**
* Teardown: remove any event listeners attached during render.
*/
export function teardownSecuritySection(): void;
```
DEV-B stubs this interface in `settings-security.ts` immediately after receiving this directive. DEV-C replaces it with the real implementation.
## Merge order and strategy
1. **C lands first** (or concurrently with A; no A or B dependency). Merge once DEV-C posts REVIEW-READY.
2. **A and B can merge in either order** after C is on main, since both will rebase/merge main before PR.
3. No squash merges — git history is preserved per project rule.
4. No force pushes. Each dev opens a PR; PM reviews diff; PM merges with `gh pr merge --merge`.
## 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="...")`.
## Coordination protocol
**Before each action:** call `read_messages(for="pm")` to drain your inbox.
**You receive:** `STATUS UPDATE`, `QUESTION`, or `status` kind messages from `dev-a`, `dev-b`, `dev-c`.
**You emit:** directives via `post_message(from="pm", to="dev-X", kind="directive", body="...")`. The body should follow this format:
```
## DIRECTIVE TO DEV-A (or B or C)
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:
```
## RELEASE STATUS — v0.5.1
Dev A: <task X of Y, status>
Dev B: <task X of Y, status>
Dev C: <task X of Y, status>
PM: <current action>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev C REVIEW-READY", "all three merged">
```
## 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 diff against the spec sections owned by that stream
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge`
5. If red: post `Action: HOLD` with specific concerns
## Pre-tag checklist
Before tagging v0.5.1:
- [ ] `feature/v0.5.1-stream-a-layout` merged to main
- [ ] `feature/v0.5.1-stream-b-settings` merged to main
- [ ] `feature/v0.5.1-stream-c-recovery-qr` merged to main
- [ ] `cargo test` green on main
- [ ] `bun run test` green (extension)
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
- [ ] `bun run build` + `bun run build:firefox` clean (extension)
- [ ] No emoji in any UI surface (grep: `'🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️'` in `extension/src/`)
- [ ] `GLYPH_VAULT_TAB` in glyphs.ts; no inline `&#x2934;` anywhere
- [ ] `recovery_qr_generated_at` is the only persisted QR artifact (grep: no QR SVG in chrome.storage calls)
- [ ] Settings left-nav sections all render without console errors
- [ ] `CHANGELOG.md` entry for v0.5.1 written
- [ ] Explicit user approval to tag
## First action
1. Call `read_messages(for="pm")` to drain any early inbox messages.
2. Emit a `## RELEASE STATUS — v0.5.1` block to the user.
3. Call `post_message(from="pm", to="dev-a", kind="directive", body="...")` — confirming the AB interface contract.
4. Call `post_message(from="pm", to="dev-b", kind="directive", body="...")` — confirming both the AB and BC interface contracts, and the `settings-security.ts` stub instruction.
5. Call `post_message(from="pm", to="dev-c", kind="directive", body="...")` — confirming the BC interface contract.
6. Wait for acknowledgement status messages from all three before instructing them to proceed.

Some files were not shown because too many files have changed in this diff Show More