Compare commits
35 Commits
dd0010db62
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9264e9ae | ||
|
|
03f2a1b58e | ||
|
|
e5d63ab196 | ||
|
|
b9bd152e9d | ||
|
|
89090a8f30 | ||
|
|
73a2579fa8 | ||
|
|
f3d6c0a880 | ||
|
|
97c8f994e1 | ||
|
|
f3cdbed7b6 | ||
|
|
2d1f0926ae | ||
|
|
0c9387fb1d | ||
|
|
f8296fa03b | ||
|
|
64275bc64f | ||
|
|
2d5b86bf20 | ||
|
|
08bdfbc7c4 | ||
|
|
3811b07014 | ||
|
|
6676d2502b | ||
|
|
615afd7483 | ||
|
|
229e483430 | ||
|
|
c2f3c35ac9 | ||
|
|
530c479f19 | ||
|
|
da7d7d162c | ||
|
|
03d0781c39 | ||
|
|
13c2fc2bd7 | ||
|
|
b9b07ec68d | ||
|
|
17bde162cd | ||
|
|
52400230e0 | ||
|
|
272b6a3845 | ||
|
|
02e05f7a05 | ||
|
|
1e858e1d1f | ||
|
|
bd3d53fddb | ||
|
|
3b09adf3b2 | ||
|
|
4f7ab91f14 | ||
|
|
4a726c2631 | ||
|
|
450de33c0a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,3 +8,9 @@ extension/wasm/
|
|||||||
reference.jpg
|
reference.jpg
|
||||||
ref.jpg
|
ref.jpg
|
||||||
tools/relay/node_modules/
|
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
|
||||||
|
|||||||
314
crates/relicario-cli/src/commands/add.rs
Normal file
314
crates/relicario-cli/src/commands/add.rs
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
//! `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_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.save_manifest(&manifest)?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
||||||
|
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
||||||
|
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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
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 = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
175
crates/relicario-cli/src/commands/attach.rs
Normal file
175
crates/relicario-cli/src/commands/attach.rs
Normal 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.save_manifest(&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.save_manifest(&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(())
|
||||||
|
}
|
||||||
303
crates/relicario-cli/src/commands/backup.rs
Normal file
303
crates/relicario-cli/src/commands/backup.rs
Normal 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: ¶ms_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(())
|
||||||
|
}
|
||||||
255
crates/relicario-cli/src/commands/device.rs
Normal file
255
crates/relicario-cli/src/commands/device.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
crates/relicario-cli/src/commands/edit.rs
Normal file
172
crates/relicario-cli/src/commands/edit.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
//! `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.save_manifest(&manifest)?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
68
crates/relicario-cli/src/commands/generate.rs
Normal file
68
crates/relicario-cli/src/commands/generate.rs
Normal 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(())
|
||||||
|
}
|
||||||
107
crates/relicario-cli/src/commands/get.rs
Normal file
107
crates/relicario-cli/src/commands/get.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//! `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()?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &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(())
|
||||||
|
}
|
||||||
88
crates/relicario-cli/src/commands/import.rs
Normal file
88
crates/relicario-cli/src/commands/import.rs
Normal 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.save_manifest(&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);
|
||||||
|
}
|
||||||
125
crates/relicario-cli/src/commands/init.rs
Normal file
125
crates/relicario-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
//! `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, ¶ms)?;
|
||||||
|
|
||||||
|
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(&ParamsFile {
|
||||||
|
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(),
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ParamsFile {
|
||||||
|
format_version: u32,
|
||||||
|
kdf: ParamsKdf,
|
||||||
|
aead: String,
|
||||||
|
salt_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
struct ParamsKdf {
|
||||||
|
algorithm: String,
|
||||||
|
argon2_m: u32,
|
||||||
|
argon2_t: u32,
|
||||||
|
argon2_p: u32,
|
||||||
|
}
|
||||||
103
crates/relicario-cli/src/commands/list.rs
Normal file
103
crates/relicario-cli/src/commands/list.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! `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()?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &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(())
|
||||||
|
}
|
||||||
60
crates/relicario-cli/src/commands/mod.rs
Normal file
60
crates/relicario-cli/src/commands/mod.rs
Normal 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(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/relicario-cli/src/commands/rate.rs
Normal file
28
crates/relicario-cli/src/commands/rate.rs
Normal 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(())
|
||||||
|
}
|
||||||
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal 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(())
|
||||||
|
}
|
||||||
98
crates/relicario-cli/src/commands/settings.rs
Normal file
98
crates/relicario-cli/src/commands/settings.rs
Normal 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(())
|
||||||
|
}
|
||||||
52
crates/relicario-cli/src/commands/status.rs
Normal file
52
crates/relicario-cli/src/commands/status.rs
Normal 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(())
|
||||||
|
}
|
||||||
11
crates/relicario-cli/src/commands/sync.rs
Normal file
11
crates/relicario-cli/src/commands/sync.rs
Normal 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(())
|
||||||
|
}
|
||||||
139
crates/relicario-cli/src/commands/trash.rs
Normal file
139
crates/relicario-cli/src/commands/trash.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//! 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.save_manifest(&manifest)?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &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.save_manifest(&manifest)?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner purge: assumes vault is already unlocked and manifest is loaded.
|
||||||
|
/// Caller is responsible for saving the manifest and committing afterwards.
|
||||||
|
pub(super) fn purge_item(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
manifest: &mut relicario_core::Manifest,
|
||||||
|
id: &relicario_core::ItemId,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let item_path = vault.item_path(id);
|
||||||
|
if item_path.exists() { fs::remove_file(&item_path)?; }
|
||||||
|
let att_dir = vault.root().join("attachments").join(id.as_str());
|
||||||
|
if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
|
||||||
|
manifest.remove(id);
|
||||||
|
|
||||||
|
let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
|
||||||
|
&format!("items/{}.enc", id.as_str()),
|
||||||
|
&format!("attachments/{}", id.as_str()),
|
||||||
|
]).status()?;
|
||||||
|
// Note: caller adds+commits manifest.enc after processing all purges.
|
||||||
|
eprintln!("Purged: {title}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
|
||||||
|
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||||
|
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 purged_titles = Vec::new();
|
||||||
|
for (id, title) in purgeable {
|
||||||
|
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||||
|
purged_titles.push(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
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_titles.len())],
|
||||||
|
"trash empty: git commit",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Emptied trash: {} item(s)", purged_titles.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -55,6 +55,37 @@ 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(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -220,6 +251,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
19
crates/relicario-cli/src/parse.rs
Normal file
19
crates/relicario-cli/src/parse.rs
Normal 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)?)
|
||||||
|
}
|
||||||
65
crates/relicario-cli/src/prompt.rs
Normal file
65
crates/relicario-cli/src/prompt.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// `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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||||
|
eprint!("{label}: ");
|
||||||
|
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();
|
||||||
|
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||||
|
Ok(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||||
|
eprint!("{label} (leave blank to skip): ");
|
||||||
|
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(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"))
|
||||||
|
}
|
||||||
132
crates/relicario-core/src/base32.rs
Normal file
132
crates/relicario-core/src/base32.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,17 @@ pub enum RelicarioError {
|
|||||||
/// Recovery QR generation or parsing failed.
|
/// Recovery QR generation or parsing failed.
|
||||||
#[error("recovery QR: {0}")]
|
#[error("recovery QR: {0}")]
|
||||||
RecoveryQr(String),
|
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.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
49
crates/relicario-core/src/mime.rs
Normal file
49
crates/relicario-core/src/mime.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,107 @@
|
|||||||
|
//! 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 chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use unicode_normalization::UnicodeNormalization;
|
use unicode_normalization::UnicodeNormalization;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
|
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 MAGIC: &[u8; 4] = b"RREC";
|
||||||
const VERSION: u8 = 0x01;
|
const VERSION: u8 = 0x01;
|
||||||
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
|
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 {
|
pub struct RecoveryQrPayload {
|
||||||
bytes: [u8; PAYLOAD_LEN],
|
bytes: [u8; PAYLOAD_LEN],
|
||||||
}
|
}
|
||||||
@@ -24,15 +118,12 @@ fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
|
|||||||
let prefix = b"relicario-recovery-v1\0";
|
let prefix = b"relicario-recovery-v1\0";
|
||||||
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
|
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
|
||||||
input.extend_from_slice(prefix);
|
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.len() as u64).to_be_bytes());
|
||||||
input.extend_from_slice(nfc_bytes);
|
input.extend_from_slice(nfc_bytes);
|
||||||
input
|
input
|
||||||
}
|
}
|
||||||
|
|
||||||
fn production_params() -> KdfParams {
|
|
||||||
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_wrap_key(
|
fn derive_wrap_key(
|
||||||
passphrase: &str,
|
passphrase: &str,
|
||||||
kdf_salt: &[u8; 32],
|
kdf_salt: &[u8; 32],
|
||||||
@@ -42,11 +133,38 @@ fn derive_wrap_key(
|
|||||||
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
|
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(
|
pub fn generate_recovery_qr(
|
||||||
passphrase: &str,
|
passphrase: &str,
|
||||||
image_secret: &[u8; 32],
|
image_secret: &[u8; 32],
|
||||||
) -> Result<RecoveryQrPayload> {
|
) -> Result<RecoveryQrPayload> {
|
||||||
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
|
generate_recovery_qr_with_params(passphrase, image_secret, &RECOVERY_PRODUCTION_PARAMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
@@ -78,11 +196,39 @@ pub fn generate_recovery_qr_with_params(
|
|||||||
Ok(RecoveryQrPayload { bytes })
|
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(
|
pub fn unwrap_recovery_qr(
|
||||||
payload_bytes: &[u8],
|
payload_bytes: &[u8],
|
||||||
passphrase: &str,
|
passphrase: &str,
|
||||||
) -> Result<Zeroizing<[u8; 32]>> {
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
|
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &RECOVERY_PRODUCTION_PARAMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
@@ -104,9 +250,9 @@ pub fn unwrap_recovery_qr_with_params(
|
|||||||
format!("unsupported version 0x{:02x}", payload_bytes[4])
|
format!("unsupported version 0x{:02x}", payload_bytes[4])
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().expect("slice length validated above");
|
let kdf_salt: &[u8; 32] = payload_bytes[KDF_SALT_RANGE].try_into().expect("slice length validated above");
|
||||||
let wrap_nonce = &payload_bytes[37..61];
|
let wrap_nonce = &payload_bytes[WRAP_NONCE_RANGE];
|
||||||
let ciphertext = &payload_bytes[61..109];
|
let ciphertext = &payload_bytes[CIPHERTEXT_RANGE];
|
||||||
|
|
||||||
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
|
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
|
||||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||||
@@ -119,6 +265,15 @@ pub fn unwrap_recovery_qr_with_params(
|
|||||||
Ok(out)
|
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 {
|
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
|
||||||
use qrcode::{QrCode, EcLevel};
|
use qrcode::{QrCode, EcLevel};
|
||||||
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
|
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
|
||||||
|
|||||||
@@ -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,7 +17,7 @@ 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");
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,28 @@ impl MonthYear {
|
|||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ 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);
|
||||||
|
|
||||||
@@ -22,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,
|
||||||
@@ -307,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]
|
||||||
@@ -533,6 +582,19 @@ mod session_tests {
|
|||||||
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();
|
||||||
@@ -588,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`.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ where
|
|||||||
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
16
crates/relicario-wasm/tests/session_drop.rs
Normal file
16
crates/relicario-wasm/tests/session_drop.rs
Normal 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));
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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`).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal 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.
|
||||||
199
docs/superpowers/coordination/RELAY.md
Normal file
199
docs/superpowers/coordination/RELAY.md
Normal 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
168
docs/superpowers/coordination/architecture-review-pm-prompt.md
Normal file
168
docs/superpowers/coordination/architecture-review-pm-prompt.md
Normal 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.
|
||||||
@@ -69,8 +69,22 @@ Your vault.ts should call `renderSettings(pane)` when the `#settings` route is a
|
|||||||
- `glyphs.ts` is the single source of truth. No inline Unicode literals at call sites.
|
- `glyphs.ts` is the single source of truth. No inline Unicode literals at call sites.
|
||||||
- Don't merge to main. The PM owns merges.
|
- Don't merge to main. The PM owns merges.
|
||||||
|
|
||||||
|
## 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="...")`.
|
||||||
|
|
||||||
## Coordination protocol
|
## Coordination protocol
|
||||||
|
|
||||||
|
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
|
## STATUS UPDATE — DEV-A
|
||||||
Time: <iso8601>
|
Time: <iso8601>
|
||||||
@@ -1438,6 +1452,8 @@ gh pr create --title "feat: fullscreen 3-column layout + popup polish (Stream A)
|
|||||||
|
|
||||||
- [ ] **Step 5: Post status to PM**
|
- [ ] **Step 5: Post status to PM**
|
||||||
|
|
||||||
|
Call `post_message(from="dev-a", to="pm", kind="status", body="...")` with:
|
||||||
|
|
||||||
```
|
```
|
||||||
## STATUS UPDATE — DEV-A
|
## STATUS UPDATE — DEV-A
|
||||||
Time: <iso8601>
|
Time: <iso8601>
|
||||||
|
|||||||
@@ -83,8 +83,22 @@ export function teardownSecuritySection(): void;
|
|||||||
- Device sections read/write `chrome.storage.local`. Vault sections call `sendMessage` to the service worker.
|
- Device sections read/write `chrome.storage.local`. Vault sections call `sendMessage` to the service worker.
|
||||||
- Don't merge to main. The PM owns merges.
|
- Don't merge to main. The PM owns merges.
|
||||||
|
|
||||||
|
## 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="...")`.
|
||||||
|
|
||||||
## Coordination protocol
|
## Coordination protocol
|
||||||
|
|
||||||
|
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
|
## STATUS UPDATE — DEV-B
|
||||||
Time: <iso8601>
|
Time: <iso8601>
|
||||||
@@ -1064,6 +1078,8 @@ gh pr create --title "feat: settings UX redesign — left-nav sectioned layout (
|
|||||||
|
|
||||||
- [ ] **Step 6: Post status to PM**
|
- [ ] **Step 6: Post status to PM**
|
||||||
|
|
||||||
|
Call `post_message(from="dev-b", to="pm", kind="status", body="...")` with:
|
||||||
|
|
||||||
```
|
```
|
||||||
## STATUS UPDATE — DEV-B
|
## STATUS UPDATE — DEV-B
|
||||||
Time: <iso8601>
|
Time: <iso8601>
|
||||||
|
|||||||
@@ -72,8 +72,22 @@ export function teardownSecuritySection(): void;
|
|||||||
|
|
||||||
DEV-B has a stub. Your Task 9 provides the real implementation.
|
DEV-B has a stub. Your Task 9 provides the real implementation.
|
||||||
|
|
||||||
|
## 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="...")`.
|
||||||
|
|
||||||
## Coordination protocol
|
## Coordination protocol
|
||||||
|
|
||||||
|
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
|
## STATUS UPDATE — DEV-C
|
||||||
Time: <iso8601>
|
Time: <iso8601>
|
||||||
@@ -1349,5 +1363,5 @@ git commit -m "feat(ext/setup): recovery QR banner in final wizard step"
|
|||||||
## Final steps
|
## Final steps
|
||||||
|
|
||||||
- [ ] Open PR: `gh pr create --title "feat: recovery QR (Stream C)" --base main`
|
- [ ] Open PR: `gh pr create --title "feat: recovery QR (Stream C)" --base main`
|
||||||
- [ ] Post `## STATUS UPDATE — DEV-C / Action: REVIEW-READY` with PR URL to PM
|
- [ ] Call `post_message(from="dev-c", to="pm", kind="status", body="## STATUS UPDATE — DEV-C\nTime: <iso8601>\nTask: 13 of 13\nStatus: REVIEW-READY\nSummary: All 13 tasks complete. PR open. Recovery QR implemented end-to-end.\nNext: waiting for PM")`
|
||||||
- [ ] Respond to any PM review comments
|
- [ ] Respond to any PM review comments
|
||||||
|
|||||||
@@ -105,13 +105,23 @@ DEV-B stubs this interface in `settings-security.ts` immediately after receiving
|
|||||||
3. No squash merges — git history is preserved per project rule.
|
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`.
|
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
|
## Coordination protocol
|
||||||
|
|
||||||
You are one of four terminals. The user relays messages.
|
**Before each action:** call `read_messages(for="pm")` to drain your inbox.
|
||||||
|
|
||||||
**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-X` blocks.
|
**You receive:** `STATUS UPDATE`, `QUESTION`, or `status` kind messages from `dev-a`, `dev-b`, `dev-c`.
|
||||||
|
|
||||||
**You emit:** a `## DIRECTIVE TO DEV-X` block. Format:
|
**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)
|
## DIRECTIVE TO DEV-A (or B or C)
|
||||||
@@ -162,4 +172,9 @@ Before tagging v0.5.1:
|
|||||||
|
|
||||||
## First action
|
## First action
|
||||||
|
|
||||||
After reading: post a `## RELEASE STATUS — v0.5.1` block, then post your first directive to all three devs simultaneously — confirming the A–B and B–C interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.
|
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 A–B interface contract.
|
||||||
|
4. Call `post_message(from="pm", to="dev-b", kind="directive", body="...")` — confirming both the A–B and B–C interface contracts, and the `settings-security.ts` stub instruction.
|
||||||
|
5. Call `post_message(from="pm", to="dev-c", kind="directive", body="...")` — confirming the B–C interface contract.
|
||||||
|
6. Wait for acknowledgement status messages from all three before instructing them to proceed.
|
||||||
|
|||||||
215
docs/superpowers/specs/2026-05-04-cli-restructure-design.md
Normal file
215
docs/superpowers/specs/2026-05-04-cli-restructure-design.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# CLI Restructure — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Status:** Proposed
|
||||||
|
**Source:** `docs/superpowers/reviews/2026-05-04-architecture-review.md` (P1.2, P1.3, P1.10) + folded P2s from DEV-B's CLI section + DEV-A's P2 base32 dedup
|
||||||
|
**Effort estimate:** M-L
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Plan B is the single biggest readability lift in the whole-codebase architecture review. `crates/relicario-cli/src/main.rs` is a 2641-line flat file: every `cmd_*`, every `build_*_item`, every `edit_*`, six prompt helpers, three pure parsers, the `ParamsFile` writer, and 24+ git shell-outs all live as peers. This plan splits that file into a `commands/` folder plus `prompt.rs` and `parse.rs`, then builds on top of that split to centralize the duplicated git-error UX, unify the manifest-after-mutation cache discipline, deduplicate the on-disk `params.json` shape, batch the `cmd_purge` git invocations, and finally migrate the pure parsers (and a third copy of base32) into `relicario-core` so the extension can consume them through new WASM exports. After the file is navigable rather than scrollable, a tinkerer who opens `cmd_add` can follow it end-to-end in one screenful — which is the user's stated learning-by-tinkering goal.
|
||||||
|
|
||||||
|
## Findings addressed
|
||||||
|
|
||||||
|
- **P1.2** — `crates/relicario-cli/src/main.rs:1-2641` is a 2641-line monolith with no submodule boundaries (DEV-B). The clap surface (lines 1-455) is excellent; lines 456-2641 are flat code.
|
||||||
|
- **P1.3** — Git invocation boilerplate duplicated ~16× with one-line `bail!("git X failed")` errors at `crates/relicario-cli/src/main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` (and others) (DEV-B).
|
||||||
|
- **P1.10** — Pure parsers (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) live only in the CLI at `crates/relicario-cli/src/main.rs:942-980` despite the extension needing them (DEV-B). Pairs with DEV-A's P2 finding that three base32 implementations live in core (`crates/relicario-core/src/item.rs:255-275`, `crates/relicario-core/src/import_lastpass.rs:202-220`, and intentionally separate Steam at `crates/relicario-core/src/item_types/totp.rs:13`).
|
||||||
|
- **CLI P2 — `build_*_item` complexity** at `crates/relicario-cli/src/main.rs:664-921` (DEV-B). Each builder mixes prompting, parsing, and core construction.
|
||||||
|
- **CLI P2 — `refresh_groups_cache` discipline** at `crates/relicario-cli/src/main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` (DEV-B). The "every mutating handler must call this" rule is unenforced.
|
||||||
|
- **CLI P2 — `ParamsFile` defined twice with mismatching shapes** at `crates/relicario-cli/src/main.rs:2287` (writer with `aead`, `salt_path`, `format_version`) versus `crates/relicario-cli/src/session.rs:114` (reader with only `kdf`) (DEV-B).
|
||||||
|
- **CLI P2 — Batched purge needed** at `crates/relicario-cli/src/main.rs:1476-1480` and `:1896-1900` (DEV-B). A 50-item `trash empty` currently fires 150 git invocations.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
The architectural shape is "one file per top-level subcommand, plus two cross-cutting helpers, plus a thin dispatcher." The clap definitions stay at the top of `main.rs` because they read like a tour of the product and that's exactly what a learner wants to land on first; everything past the dispatcher moves out. Each new module becomes self-contained enough that a tinkerer following a single command does not need to scroll through neighbours.
|
||||||
|
|
||||||
|
Post-split layout under `crates/relicario-cli/src/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # clap surface (Cli, Commands, AddKind, …) + dispatch match (~470 lines)
|
||||||
|
├── helpers.rs # vault_dir, git_command, git_run (NEW), iso8601, humanize_age,
|
||||||
|
│ # groups_cache_path, sanitize_for_commit, decode_totp_qr
|
||||||
|
├── session.rs # UnlockedVault, ParamsFile (single canonical), after_manifest_change (NEW)
|
||||||
|
├── device.rs # ed25519 keypair storage, configure_git_signing
|
||||||
|
├── gitea.rs # GiteaClient (deploy-key API)
|
||||||
|
├── prompt.rs # NEW — prompt, prompt_optional, prompt_secret, prompt_or_flag<T>,
|
||||||
|
│ # and the four other small prompt_* helpers
|
||||||
|
├── parse.rs # NEW — thin wrappers around the migrated core parsers
|
||||||
|
│ # (until phase 7, the parsers themselves live here)
|
||||||
|
└── commands/
|
||||||
|
├── mod.rs # NEW — pub use re-exports for the dispatcher
|
||||||
|
├── add.rs # cmd_add + the seven build_*_item helpers
|
||||||
|
├── get.rs # cmd_get
|
||||||
|
├── list.rs # cmd_list, cmd_history, resolve_query
|
||||||
|
├── edit.rs # cmd_edit + per-type edit_* helpers
|
||||||
|
├── trash.rs # cmd_rm, cmd_restore, cmd_purge, cmd_trash, cmd_trash_empty,
|
||||||
|
│ # purge_item
|
||||||
|
├── backup.rs # cmd_backup, cmd_backup_export, cmd_backup_restore
|
||||||
|
├── import.rs # cmd_import (LastPass)
|
||||||
|
├── attach.rs # cmd_attach, cmd_attachments, cmd_extract, cmd_detach
|
||||||
|
├── settings.rs # cmd_settings (and its subaction handlers)
|
||||||
|
├── sync.rs # cmd_sync (push/pull/fetch)
|
||||||
|
├── status.rs # cmd_status (vault state summary)
|
||||||
|
├── device.rs # cmd_device + register/revoke/list, load_gitea_client
|
||||||
|
├── recovery_qr.rs # cmd_recovery_qr, cmd_recovery_qr_generate, cmd_recovery_qr_unwrap
|
||||||
|
├── init.rs # cmd_init (also writes ParamsFile via session module)
|
||||||
|
├── generate.rs # cmd_generate
|
||||||
|
└── rate.rs # cmd_rate
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale for the boundaries:
|
||||||
|
|
||||||
|
- **One file per top-level subcommand** is the navigation invariant: `relicario add` ⇒ `commands/add.rs`. `cmd_add` and the seven `build_*_item` helpers it calls are the ~260-line vertical slice a tinkerer wants in front of them at once. Same shape for every other subcommand.
|
||||||
|
- **`prompt.rs` is the input layer**: every command that reads from the user funnels through one module, so swapping in a different prompt strategy (richer TTY widgets, scripted-test mode) is a one-file change. The `prompt_or_flag<T>` helper introduced in phase 3 lives here.
|
||||||
|
- **`parse.rs` is the validation layer** during phases 1–6 — it owns `parse_month_year`, `base32_decode_lenient`, `guess_mime`. In phase 7 the bodies move to `relicario-core` and `parse.rs` becomes a thin re-export so callers don't have to change imports twice.
|
||||||
|
- **`helpers.rs` keeps `git_command` (the hardened `Command` builder) and gains `git_run`** (the bail-on-failure wrapper). Both are flat utilities; they belong with `vault_dir` and `iso8601`.
|
||||||
|
- **`session.rs` keeps `UnlockedVault` and absorbs the canonical `ParamsFile`** so the on-disk shape has one definition, not two. The `after_manifest_change` wrapper introduced in phase 4 also lives here, because it's logically a session method.
|
||||||
|
- **`commands/mod.rs`** re-exports each `cmd_*` so the dispatch match in `main.rs` reads as `Commands::Add { kind } => commands::add::cmd_add(kind)` — the dispatch surface still fits on one screen.
|
||||||
|
|
||||||
|
Boundary discipline: every `cmd_*` becomes `pub fn` in its module file; every `build_*_item` and `edit_*` stays `pub(super)` (only its sibling dispatcher needs it). Helpers shared across `commands/` (e.g. `commit_paths`, `resolve_query`) live in `commands/mod.rs` as `pub(crate)`. The compile error if a sibling moves and a caller forgets the visibility bump is exactly the safety net we want.
|
||||||
|
|
||||||
|
**WASM/extension parity seam (P1.10).** The three CLI parsers move into `relicario-core` in phase 7, get re-exported as `#[wasm_bindgen]` functions in phase 8, and the `extension/src/wasm.d.ts` file is updated in the same commit (per DEV-C's boundary note, that file is hand-maintained — every export change must be mirrored manually). Plan C (extension restructure) then wires SW message handlers that call those WASM exports; **the SW handlers themselves are not designed in this plan** — phase 8 only delivers the seam Plan C consumes.
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
### Phase 1 — Mechanical split of `main.rs`
|
||||||
|
|
||||||
|
- **Goal:** turn 2641 LOC of flat code into a navigable directory tree without changing any behaviour.
|
||||||
|
- **Changes:**
|
||||||
|
- New: `crates/relicario-cli/src/commands/{mod,add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr,init,generate,rate}.rs`.
|
||||||
|
- New: `crates/relicario-cli/src/prompt.rs` (lifts `prompt`, `prompt_optional`, `prompt_secret` from `main.rs:508-940`).
|
||||||
|
- New: `crates/relicario-cli/src/parse.rs` (lifts `parse_month_year`, `base32_decode_lenient`, `guess_mime` from `main.rs:942-980`).
|
||||||
|
- Modified: `crates/relicario-cli/src/main.rs` — keeps `mod` declarations, the clap `Cli`/`Commands`/`AddKind` enums (lines 1-455), the dispatch `match` (lines 405-454), the three `test_*_override()` shims (lines 475-503), and the `refresh_groups_cache` shim (lines 457-473) until phase 4 collapses it.
|
||||||
|
- Modified: every other file that referenced `crate::session::UnlockedVault::unlock_interactive`, `crate::helpers::*`, `crate::test_passphrase_override` keeps working unchanged (visibility bumps only).
|
||||||
|
- **Tests:** the existing CLI integration tests at `crates/relicario-cli/tests/{basic_flows,attachments,backup,edit_and_history,settings,smart_inputs,vault_detection,import_lastpass}.rs` are the regression budget. They test from the binary surface (via `assert_cmd`), so a pure relocation must not change a single assertion. Run between every sub-step. No new tests in this phase; logic is unchanged.
|
||||||
|
- **Effort:** M (largest single phase by LOC moved; mechanical).
|
||||||
|
- **Depends on:** none.
|
||||||
|
|
||||||
|
### Phase 2 — `helpers::git_run` and the 16-site sweep
|
||||||
|
|
||||||
|
- **Goal:** replace 16 `bail!("git X failed")` one-liners with `git_run(repo, args, context)?`, so a failing git subprocess prints actionable stderr instead of just the verb.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-cli/src/helpers.rs` — adds `pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()>` that uses `.output()` (capturing stderr), prints the captured stderr unmodified to the user's stderr on failure, and bails with `"<context>: git failed (status N)"`. Keeps `git_command` for the rare interactive callsite that needs a `Command` (phase decides per call).
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/init.rs` — three sites collapse (`main.rs:601, 602, 610` post-split). Contexts: `"init: git init"`, `"init: git add"`, `"init: git commit"`.
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/add.rs` — two sites in `commit_paths` (`main.rs:986, 988` post-split moves to `commands/mod.rs`). Context: `"add: git add <id>"`, `"add: git commit"`.
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/trash.rs` — `cmd_purge` and `cmd_trash_empty` sites (`main.rs:1477, 1480, 1897, 1900` post-split). Contexts include the item title for trace clarity. (Phase 6 batches them further.)
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/edit.rs` — `main.rs:1767` site post-split. Context: `"edit: git commit"`.
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/sync.rs` — `main.rs:2432, 2438, 2533, 2540` sites post-split. Contexts: `"sync: git fetch"`, `"sync: git pull"`, `"sync: git push"`.
|
||||||
|
- Modified: any other `git_command(...).status()? + bail!` site identified by grep during phase 1 (DEV-B's "and others" list).
|
||||||
|
- **Tests:** add a unit test in `crates/relicario-cli/src/helpers.rs`'s `mod tests` that invokes `git_run` against a deliberately-failing git subcommand in a `tempfile::TempDir` and asserts both that the bail message contains the context string and that the captured stderr is reproduced. Existing integration tests must still pass.
|
||||||
|
- **Effort:** S.
|
||||||
|
- **Depends on:** Phase 1.
|
||||||
|
|
||||||
|
### Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression
|
||||||
|
|
||||||
|
- **Goal:** collapse the seven `build_*_item` builders so each one takes already-validated values, with a single `prompt_or_flag<T>` helper handling the "use this flag if Some, otherwise prompt" pattern.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-cli/src/prompt.rs` — adds `pub fn prompt_or_flag<T>(flag: Option<T>, label: &str, parser: impl FnOnce(&str) -> Result<T>) -> Result<T>` and a `prompt_or_flag_optional<T>` sibling for fields where the empty-string case maps to `None`.
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/add.rs` — every `build_*_item` (post-split locations of `main.rs:664-921`) drops its `Option<T>::map(Ok).unwrap_or_else(|| prompt(...))?` chains in favour of `prompt_or_flag(title, "Title", |s| Ok(s.into()))?` etc. Per-type bodies shrink by ~30%.
|
||||||
|
- **Tests:** existing `tests/basic_flows.rs::add_login_then_list_shows_it`, `tests/smart_inputs.rs` cover the prompt path; `tests/attachments.rs` covers the document builder. Add one focused test: `prompt_or_flag_uses_flag_value_when_some` and `prompt_or_flag_prompts_when_none` (synthetic stdin via `Cursor`).
|
||||||
|
- **Effort:** S-M.
|
||||||
|
- **Depends on:** Phase 1.
|
||||||
|
|
||||||
|
### Phase 4 — `Vault::after_manifest_change` and the seven-site sweep
|
||||||
|
|
||||||
|
- **Goal:** make "every mutating handler must call `refresh_groups_cache`" a compile-time invariant rather than a discipline rule.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-cli/src/session.rs` — adds `pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()>` that calls `self.save_manifest(manifest)?` and then writes the groups cache. Marks the existing `save_manifest` as `pub(crate)` (or renames to `save_manifest_raw` and makes it `pub(crate)`) so callers funnel through the wrapper. Cache writing logic moves out of `main.rs:457-473` into this method.
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/{add,edit,trash,attach,settings,import}.rs` — the seven sites (`main.rs:641, 998, 1123, 1197, 1414, 1432, 1474` post-split) collapse from `vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest);` to `vault.after_manifest_change(&manifest)?;`.
|
||||||
|
- Removed: the standalone `refresh_groups_cache` function in `main.rs:463`. The cache-write closure inside `after_manifest_change` keeps the existing "failures are silently swallowed" semantics (preserve the `let _ =` and the doc comment from `main.rs:457-462`).
|
||||||
|
- **Tests:** `tests/smart_inputs.rs::groups_cache_*` already exercises the write/suppress paths from the binary surface; rerun. Add a unit test in `session.rs` confirming `after_manifest_change` writes both the manifest and the cache file.
|
||||||
|
- **Effort:** S.
|
||||||
|
- **Depends on:** Phase 1.
|
||||||
|
|
||||||
|
### Phase 5 — Single canonical `ParamsFile`
|
||||||
|
|
||||||
|
- **Goal:** one definition of the on-disk `params.json` shape, used by both the `init` writer and the `unlock_interactive` reader.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-cli/src/session.rs` — promotes the inner `ParamsFile` struct (`session.rs:114`) to a module-level `pub(crate) struct ParamsFile { pub format_version: u32, pub kdf: KdfParams, pub aead: String, pub salt_path: String }`. Adds `Serialize` + `Deserialize` derives. Provides constructors: `ParamsFile::for_new_vault(params: &KdfParams) -> Self` and inversion `pub fn into_kdf_params(self) -> KdfParams`. Verifies field-rename compatibility against the existing on-disk JSON (read a temp `params.json` written by current `main.rs` to confirm round-trip).
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/init.rs` — replaces the inline `ParamsFile`/`ParamsKdf` structs at the post-split equivalent of `main.rs:2287-2301` with `session::ParamsFile::for_new_vault(¶ms)`. Removes the duplicate writer-side definition.
|
||||||
|
- Decision note in the module doc-comment: keep `ParamsFile` in `session.rs` rather than `relicario-core`. Rationale: the struct describes a *file on disk*, and `relicario-core` is bytes-in/bytes-out (no filesystem). The `KdfParams` value inside is core's already; the envelope is the CLI's I/O concern.
|
||||||
|
- **Tests:** add `tests/common/mod.rs` (or extend an existing test) round-trip: write via `ParamsFile::for_new_vault`, parse via `read_params`, confirm the resulting `KdfParams` matches the input. The on-disk schema must not change (`tests/basic_flows.rs::init_creates_expected_layout` indirectly covers this).
|
||||||
|
- **Effort:** S.
|
||||||
|
- **Depends on:** Phase 1.
|
||||||
|
|
||||||
|
### Phase 6 — Batched purge in `cmd_purge` and `cmd_trash_empty`
|
||||||
|
|
||||||
|
- **Goal:** a 50-item `trash empty` should fire 3 git invocations total, not 150.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-cli/src/commands/trash.rs` — `purge_item` (post-split of `main.rs:1450-1462`) is renamed `purge_item_filesystem` and **only** mutates the working tree + manifest (filesystem `remove_file`/`remove_dir_all`, manifest `remove`). It does **not** invoke `git rm`. Returns `Vec<String>` of the paths it removed (so the caller can stage them).
|
||||||
|
- `cmd_purge` (post-split `main.rs:1464-1482`): collects the paths from `purge_item_filesystem`, calls `vault.after_manifest_change(&manifest)?`, then a single `git_run(vault.root(), &["rm", "-rf", "--ignore-unmatch", … paths …, "manifest.enc"], "purge: git rm")?` followed by `git_run(... ["add", "manifest.enc"], "purge: git add")?` (manifest is staged separately because it was rewritten not removed) and `git_run(... ["commit", "-m", &msg], "purge: git commit")?`. Three invocations, fixed.
|
||||||
|
- `cmd_trash_empty` (post-split `main.rs:1885-1903`): same pattern over the loop of purgeables — accumulate all paths, then one `git rm`, one `git add manifest.enc`, one `git commit`. The commit message keeps `"trash empty: purged N item(s)"`.
|
||||||
|
- **Tests:** existing `tests/basic_flows.rs::rm_restore_purge_cycle` covers the single-item purge contract. Add `tests/basic_flows.rs::trash_empty_batches_git_invocations` (or extend `tests/settings.rs`) — purge ≥3 items via `trash empty`, then assert via `git log --oneline --since=…` that exactly **one** new commit appeared (a strict invariant that catches accidental re-introduction of per-item commits). Synthetic fixtures only; no binary blobs.
|
||||||
|
- **Effort:** S-M.
|
||||||
|
- **Depends on:** Phases 1, 2, 4 (uses `git_run` and `after_manifest_change`).
|
||||||
|
|
||||||
|
### Phase 7 — Migrate parsers to `relicario-core` + `pub(crate) mod base32`
|
||||||
|
|
||||||
|
- **Goal:** close DEV-A's three-base32-implementations finding and DEV-B's parsers-in-CLI-only finding in one stroke. The CLI keeps thin wrappers (no caller-side import changes); core becomes the single source of truth.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-core/src/time.rs` — adds `impl MonthYear { pub fn parse(s: &str) -> Result<Self, RelicarioError> { … } }` accepting `MM/YYYY`, `MM-YYYY`, and `MM/YY` (lifts the body of `parse_month_year` from `crates/relicario-cli/src/parse.rs`). Note: `MonthYear::new` currently returns `Result<_, &'static str>` (DEV-A P3); this phase has the option to either bring `new` to `RelicarioError` for consistency, or leave it and have `parse` call `new` and re-wrap. Recommendation: re-wrap only — the `new` migration is DEV-A's P3 to keep this plan focused.
|
||||||
|
- New: `crates/relicario-core/src/base32.rs` — `pub(crate) mod base32 { pub fn encode_rfc4648(bytes: &[u8]) -> String; pub fn decode_rfc4648_lenient(s: &str) -> Result<Vec<u8>, RelicarioError>; }`. Body lifted/unified from `crates/relicario-core/src/item.rs:255-275` (the encoder) and `crates/relicario-core/src/import_lastpass.rs:202-220` (the decoder), plus the lenient input handling (case-insensitive, padding-optional, whitespace-stripped) from `crates/relicario-cli/src/parse.rs`. Steam's `STEAM_ALPHABET` at `crates/relicario-core/src/item_types/totp.rs:13` stays put with a neighbour comment: `// not RFC 4648 — Steam Guard's de-ambiguated alphabet; see crate::base32 for the standard impl.`
|
||||||
|
- Modified: `crates/relicario-core/src/item.rs` — removes the inline `base32_encode` (lines 255-275); call sites switch to `crate::base32::encode_rfc4648`.
|
||||||
|
- Modified: `crates/relicario-core/src/import_lastpass.rs` — removes the inline `decode_base32_totp` (lines 202-220); call sites switch to `crate::base32::decode_rfc4648_lenient`.
|
||||||
|
- New: `crates/relicario-core/src/item_types/totp.rs` — adds `impl TotpConfig { pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>, RelicarioError> { Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?)) } }`. The `Zeroizing` wrap is the value-add over a raw base32 call.
|
||||||
|
- New: `crates/relicario-core/src/mime.rs` — `pub fn guess_for_extension(filename: &str) -> &'static str` (lifts `guess_mime` from CLI). Default `application/octet-stream`. The match is small enough that exhaustive enum tagging (PDF/PNG/JPEG/TXT/JSON) is overkill; keep the string-match shape.
|
||||||
|
- Modified: `crates/relicario-core/src/lib.rs` — re-exports `MonthYear` (already exported), `mime`, `base32` (`pub(crate)` only, not in the public API surface). `Totp::parse_secret` is reachable via `ItemCore::Totp(_).parse_secret(...)` already.
|
||||||
|
- Modified: `crates/relicario-cli/src/parse.rs` — becomes a thin shim re-exporting the new core API. `pub fn parse_month_year(s: &str) -> Result<MonthYear, RelicarioError> { MonthYear::parse(s) }` (so the existing CLI callsites need zero edits in this phase). Same for `base32_decode_lenient` and `guess_mime`. Once Plan C lands and consumers have all migrated, a follow-up plan can delete `parse.rs` entirely.
|
||||||
|
- Verification step: before phase 7, grep `grep -RIn "parse_month_year\|base32_decode_lenient\|guess_mime\|base32_encode\|decode_base32_totp" crates/ extension/` to confirm the only consumers are the CLI files about to be touched. The Cargo workspace currently has no other consumer.
|
||||||
|
- **Tests:** move CLI's existing parser unit tests (if any) into `crates/relicario-core/src/`. Add: `MonthYear::parse` round-trip for `01/2026`, `12/30`, `12-2026`, plus error cases (`13/2026`, `01/1999`, `garbage`); `base32::encode_rfc4648`/`decode_rfc4648_lenient` round-trip on a known TOTP vector (the RFC 6238 test vector already lives in `item_types/totp.rs`); `mime::guess_for_extension` table — `pdf.PDF`, `image.jpg`, `image.jpeg`, `unknown.xyz`. Keep `tests/import_lastpass.rs` green (it'll go through the new shared decoder). Existing CLI integration tests at `crates/relicario-cli/tests/*` must still pass; the public CLI surface is unchanged.
|
||||||
|
- **Effort:** M.
|
||||||
|
- **Depends on:** Phase 1.
|
||||||
|
|
||||||
|
### Phase 8 — WASM exports for the migrated parsers + wasm.d.ts mirror
|
||||||
|
|
||||||
|
- **Goal:** make the new core parsers reachable from the extension via the SW. Deliver only the seam; Plan C wires the SW handlers.
|
||||||
|
- **Changes:**
|
||||||
|
- Modified: `crates/relicario-wasm/src/lib.rs` — adds three `#[wasm_bindgen]` exports:
|
||||||
|
- `pub fn parse_month_year(s: &str) -> Result<JsValue, JsError>` returning the serialized `MonthYear` (`{ month, year }` plain object via the existing `Serializer::new().serialize_maps_as_objects(true)` pattern; reuse `js_value_for`).
|
||||||
|
- `pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError>` returning the decoded bytes as a `Uint8Array` on the JS side (wasm-bindgen converts `Vec<u8>` into a `Uint8Array` copy — same convention as `attachment_decrypt`).
|
||||||
|
- `pub fn guess_mime(filename: &str) -> String`.
|
||||||
|
- Modified: `extension/src/wasm.d.ts` — adds the three matching declarations under the existing `declare module 'relicario-wasm'` block. Per DEV-C's boundary note, this file is hand-maintained; this commit must update both files together. Suggested placement: directly after `extract_image_secret`/`embed_image_secret` (line ~60), grouped under a `// Pure parsers (no session needed)` comment so the extension-side reader sees they require no `SessionHandle`.
|
||||||
|
- Naming convention call-out: phase 8 keeps **snake_case** JS names (consistent with every existing export, e.g. `manifest_encrypt`, `extract_image_secret`). 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.
|
||||||
|
- **Tests:** add `wasm-bindgen-test` cases (or extend the existing `session_tests` mod at `crates/relicario-wasm/src/lib.rs:522-591`) covering the three new exports — at minimum, a round-trip for `base32_decode_lenient` against a known input, an error case for `parse_month_year("not-a-date")`, and a `guess_mime("doc.pdf") == "application/pdf"` smoke. Confirm `cargo build -p relicario-wasm --target wasm32-unknown-unknown` is clean.
|
||||||
|
- **Cross-boundary cite:** **after Phase 8 ships, Plan C can wire SW message handlers** for `parse_month_year`, `base32_decode_lenient`, and `guess_mime` (e.g. messages `parse_month_year`, `decode_totp_secret`, `guess_attachment_mime` in `extension/src/shared/messages.ts`, dispatched by `extension/src/service-worker/router/popup-only.ts`). This plan does **not** design those handlers; phase 8 only delivers the WASM seam Plan C consumes. Coordinate the wasm.d.ts update commit with Plan C so both crates' callers see the new surface in the same merge.
|
||||||
|
- **Effort:** S.
|
||||||
|
- **Depends on:** Phase 7.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
- **Mechanical splits drift if a function calls a sibling that moved.** Mitigation: `cargo check -p relicario-cli` between every sub-step of phase 1 (i.e. between every file-extraction); explicit `pub(crate)` discipline so the compiler enforces visibility. The integration tests at `crates/relicario-cli/tests/*` are the regression budget — they exercise the binary surface, not internal modules, so a pure relocation cannot break them silently.
|
||||||
|
- **`helpers::git_run` semantic change: switching from `.status()` (inherited stderr to TTY) to `.output()` (captured) means interactive flows lose live stderr streaming.** A user who runs `relicario sync` in a terminal sees git's progress today; with `git_run` they see only the captured chunk on failure. Mitigation options: (a) `git_run` could keep `inherit` semantics by detecting `stderr.is_terminal()` and switching strategy; (b) accept the trade — captured-and-printed-on-failure is uniformly better for the CI/test/scripted-tooling case which is the failure-mode that matters. Recommendation: option (b) for phase 2, with a follow-up TODO if the live-streaming loss is reported. Keep `git_command` available for the rare site that needs a manual `Command` (pipe/stdin/etc.) so neither contract has to be perfect.
|
||||||
|
- **`Vault::after_manifest_change` adds a method to the session module; risk that callers forget to use the wrapper.** Mitigation: in phase 4, mark `save_manifest` as `pub(crate)` (or rename to `save_manifest_raw`) so all `commands/*` files are forced through `after_manifest_change`. The clap dispatcher calls only `cmd_*` functions, none of which need the raw method.
|
||||||
|
- **`ParamsFile` migration is on-disk-format-sensitive.** A change in the `Serialize` derive's field order or a missed `Deserialize` field tolerance would break existing vaults. Mitigation: phase 5's test round-trip reads a `params.json` written by the current code (committed as a fixture *string literal* in the test, not a binary blob) and confirms the new code parses it identically. Field names match exactly (`format_version`, `kdf`, `aead`, `salt_path`); the existing `read_params` only reads `kdf`, so adding tolerance for the other fields is the natural step.
|
||||||
|
- **Parser migration to core changes the public API surface of `relicario-core`.** Any in-flight work consuming the old CLI helpers must adapt. Mitigation: the grep step at the top of phase 7 confirms zero non-CLI consumers exist today; the CLI's `parse.rs` shim keeps callsites unchanged through phases 1–6, so this is a one-pass migration with no follow-up required from other plans. The Steam alphabet stays untouched (intentional non-RFC-4648 alphabet) with a neighbour comment.
|
||||||
|
- **WASM export rename / addition could break the extension at the `wasm.d.ts` boundary.** Per DEV-C's boundary notes, `extension/src/wasm.d.ts` is hand-maintained; every change to `crates/relicario-wasm/src/lib.rs`'s `#[wasm_bindgen]` surface must be mirrored manually. Mitigation: phase 8 updates both files in the same commit; a future CI guard (DEV-C's WASM P2 — comparing wasm-pack–generated `.d.ts` against the hand-maintained one) is out of scope here but would close this hole permanently. The boundary notes also flag the BigInt typing care that `attachment_encrypt` already requires (DEV-B item 3 in DEV-C's boundary notes); the three new exports take only `&str` and return primitives, so they avoid that class of footgun.
|
||||||
|
- **Cross-plan coupling.** Phase 8 lands a wasm.d.ts change; Plan C will land SW handlers consuming it. If the merge order interleaves — e.g. Plan C ships its handler stub before phase 8 lands the WASM export — the extension build breaks. Mitigation: phase 8 ships first; Plan C's SW-handler phase explicitly depends on phase 8's WASM exports (cite this seam in Plan C). The user's release-train coordination is the enforcement mechanism.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Plan A** (security/docs polish) and **Plan C** (extension restructure) entirely.
|
||||||
|
- **All CLI P3 nits** that DEV-B enumerates: `let _ = entry;` pattern (`main.rs:1170, 1407, 1426, 1469, 1913, 2030`); `Lock` subcommand visibility; `Display` for `ItemType` (the `format!("{:?}", e.r#type)` site at `main.rs:1158`); `helpers::relicario_dir` adoption sweep; `gitea::GiteaClient` per-call construction; scripted-prompt test layer for `tests/edit_and_history.rs`; dead `blob_path` variable in `tests/attachments.rs:69-76`; the three-test-env-var macro consolidation; `cmd_recovery_qr_unwrap` empty-input check; `cmd_recovery_qr_generate` stdout/stderr split; the Task-12 `cmd_backup_export` cleanup. Each is a one-line edit appropriate for a follow-up sweep, not for this M-L plan.
|
||||||
|
- **Server findings** (P2/P3 in DEV-B's relicario-server section): `generate-hook` `$PATH`, bootstrap-branch tightening, `verify-commit --from-stdin`, server test coverage gaps. These belong in a server-focused plan.
|
||||||
|
- **WASM findings beyond the parser exports needed for P1.10**: DEV-B's WASM P2 list (`need_key`/`with(...).unwrap()` double-lookup, `Vec<u8>` getter clones, `wasm_*_recovery_qr` rename, `device.rs`/`session.rs` concurrency-primitive split). Not in this plan.
|
||||||
|
- **The 8 "Open architectural decisions"** in the synthesis appendix.
|
||||||
|
- **WASM JS-naming** (snake_case → camelCase) — defer to a separate plan, as called out in phase 8.
|
||||||
|
- **In-flight uncommitted v0.5.x work** (`extension/src/vault/vault.ts`, `vault.css`, `glyphs.ts`, manifest version bumps, relay `queue.ts`/`server.ts`/`start.sh`). Treat as in-flight; this plan touches none of them.
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
A reviewer can confirm Plan B has shipped by checking, in order:
|
||||||
|
|
||||||
|
- [ ] `crates/relicario-cli/src/main.rs` is ≤ 500 LOC and contains only the clap surface, the dispatch `match`, and the `mod` declarations + `test_*_override` shims.
|
||||||
|
- [ ] The `crates/relicario-cli/src/commands/` directory contains the 17 files listed in the post-split tree, each owning exactly one top-level subcommand or a tightly-scoped helper.
|
||||||
|
- [ ] `crates/relicario-cli/src/prompt.rs` and `crates/relicario-cli/src/parse.rs` exist, with the 6 prompt helpers and the 3 parser shims respectively.
|
||||||
|
- [ ] `cargo test --workspace` passes (CLI integration tests, core unit tests, server tests, wasm `wasm-bindgen-test`s).
|
||||||
|
- [ ] `cargo clippy --workspace` is silent.
|
||||||
|
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` is clean.
|
||||||
|
- [ ] The 16 `bail!("git X failed")` sites listed in P1.3 (and any others surfaced by grep) are now `git_run(repo, args, "<context>")?` one-liners. `grep -n 'bail!("git ' crates/relicario-cli/src/` returns zero matches.
|
||||||
|
- [ ] The 7 `refresh_groups_cache` callsites all collapse to `vault.after_manifest_change(&manifest)?`. `grep -n 'refresh_groups_cache' crates/relicario-cli/src/` returns zero matches outside `session.rs`.
|
||||||
|
- [ ] One canonical `ParamsFile` definition in `crates/relicario-cli/src/session.rs`. `grep -n 'struct ParamsFile' crates/relicario-cli/src/` returns one match.
|
||||||
|
- [ ] A 50-item `relicario trash empty` produces exactly **one** new git commit (verified by `git log --oneline` in the test); a single-item `relicario purge` produces exactly one commit. Per-purge git invocations: 3 (rm + add manifest + commit), down from 3-per-item.
|
||||||
|
- [ ] `MonthYear::parse`, `Totp::parse_secret`, `mime::guess_for_extension` exist in `relicario-core`. `crates/relicario-core/src/base32.rs` is `pub(crate)` and is the only RFC-4648 implementation in the crate (Steam alphabet at `item_types/totp.rs:13` stays, with a neighbour comment).
|
||||||
|
- [ ] `crates/relicario-wasm/src/lib.rs` exports `parse_month_year`, `base32_decode_lenient`, `guess_mime`. `extension/src/wasm.d.ts` mirrors the three declarations.
|
||||||
|
- [ ] All existing CLI integration tests at `crates/relicario-cli/tests/*` still pass without modification (regression budget held).
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
# Extension Restructure — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Status:** Proposed
|
||||||
|
**Source:** docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.4, P1.5, P1.6, P1.9) + folded P2s from DEV-C's extension sections + the `relicario status` parity gap
|
||||||
|
**Effort estimate:** L
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This is the largest of the three architecture-review followups — multi-day to multi-week — and it eliminates the two steepest learning cliffs in the extension. After this plan ships, a tinkerer who opens `extension/src/setup/setup.ts` no longer sees WASM imported directly (it isn't the pattern; it was the exception); a tinkerer who opens `extension/src/vault/vault.ts` sees ~200 LOC of routing and state instead of 1027 LOC of shell + sidebar + list + drawer + form-wrapper inlined together; the `shared/state.ts` bridge between popup and vault tab becomes type-checked end to end; and the duplicated service-worker router helpers consolidate into one home each. As a side effect, the extension closes its last CLI-parity gap (`relicario status` → vault-sidebar status indicator).
|
||||||
|
|
||||||
|
## Findings addressed
|
||||||
|
|
||||||
|
- **P1.4** (DEV-C; `extension/src/setup/setup.ts:28-37`, `:1118-1120`, whole 1220-LOC file) — setup wizard imports `relicario-wasm` directly and orchestrates `unlock` / `embed_image_secret` / `register_device` / `manifest_encrypt` itself, bypassing the SW abstraction every other surface uses; ~400 LOC of crypto orchestration duplicated.
|
||||||
|
- **P1.5** (DEV-C; `extension/src/vault/vault.ts:1-1027`) — single 1027-LOC file owns shell + hash routing + sidebar + list + drawer + type-picker + form-wrapper + deep-link routing + teardown. Contains the `vault_locked` RPC intercept (lines 47-74) and the drawer-state leak (lines 495-536, 648-695).
|
||||||
|
- **P1.6** (DEV-C; `extension/src/shared/state.ts:10-35`) — `StateHost` contract is fully `any`-typed; `host` singleton has no double-registration guard.
|
||||||
|
- **P1.9** (DEV-C; `extension/src/service-worker/router/popup-only.ts:687-703`, `:~169`; `extension/src/service-worker/router/content-callable.ts:187-205`, `:~169`) — `loadDeviceSettings`, `loadBlacklist`, `saveBlacklist`, and `itemToManifestEntry` duplicated across both router files.
|
||||||
|
- **P2 — inactivity-timer reset on content-callable messages** (DEV-C; `extension/src/service-worker/index.ts:76-78`) — active autofiller never opens popup, gets force-locked despite continuous use.
|
||||||
|
- **P2 — `state.gitHost` clear on session expiry** (DEV-C; `extension/src/service-worker/index.ts:51-58`) — expiry callback clears manifest but leaves the cached git-host client.
|
||||||
|
- **P2 — duplicated teardown helpers** (DEV-C; `extension/src/popup/components/settings.ts:56-65` and `settings-vault.ts:15-22`) — two near-identical cleanup paths in a regression class with known prior leaks.
|
||||||
|
- **P2 — `Promise.all` without per-promise error handling** (DEV-C; `extension/src/popup/components/devices.ts:47-50`, `trash.ts:39-46`) — single rejected RPC fails the whole render.
|
||||||
|
- **P2 — MutationObserver `scan()` not debounced** (DEV-C; `extension/src/content/detector.ts:96-103`) — SPA churn re-runs the full scan many times per second.
|
||||||
|
- **CLI/extension parity gap — no equivalent to `relicario status`** (DEV-C parity table; PM kickoff) — extension surfaces nothing comparable to ahead/behind/lastSyncAt/pendingItems.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Architectural shape: **types first, then extract, then split**. The extension already has a clean message-router/SW boundary; this plan finishes the job for the three surfaces that don't yet honor it (state.ts, setup.ts, vault.ts) and pulls duplicated SW helpers into one home.
|
||||||
|
|
||||||
|
### Post-split `extension/src/vault/`
|
||||||
|
|
||||||
|
```
|
||||||
|
extension/src/vault/
|
||||||
|
├── vault.html (unchanged)
|
||||||
|
├── vault.css (unchanged)
|
||||||
|
├── vault.ts (~200 LOC; routing + state only — owns
|
||||||
|
│ hash parsing, RouterState, render() entry,
|
||||||
|
│ imports the modules below)
|
||||||
|
├── vault-shell.ts (DOM scaffolding, color-scheme apply,
|
||||||
|
│ onMessage wiring, applyShellViewClass)
|
||||||
|
├── vault-sidebar.ts (renderSidebarCategories, search input
|
||||||
|
│ wiring with 50-100ms debounce, nav buttons,
|
||||||
|
│ global keydown shortcuts)
|
||||||
|
├── vault-list.ts (renderListPane, row rendering, type icons)
|
||||||
|
├── vault-drawer.ts (openDrawer/closeDrawer, renderDrawer,
|
||||||
|
│ drawer field grid, drawer event wiring;
|
||||||
|
│ state.drawerOpen reset is owned here)
|
||||||
|
├── vault-form-wrapper.ts (renderFormWrapped, sticky bar, header;
|
||||||
|
│ __test__ export migrates with it)
|
||||||
|
├── vault-status.ts (NEW — renders the get_vault_status indicator
|
||||||
|
│ in the sidebar; phase 6)
|
||||||
|
└── components/
|
||||||
|
├── backup-panel.ts (unchanged)
|
||||||
|
└── import-panel.ts (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: each module owns one concern that has its own teardown, its own DOM rectangle, and its own subset of `RouterState`. Adding a new pane view today is a 5-place edit (`teardownPaneComponents` lines 813-820 is the symptom DEV-C flagged); after the split, it is one new module + one entry in `vault.ts`'s render switch. The `vault_locked` RPC intercept at lines 47-74 lifts out entirely (see `shared/state.ts` below).
|
||||||
|
|
||||||
|
### Post-split `extension/src/service-worker/`
|
||||||
|
|
||||||
|
```
|
||||||
|
extension/src/service-worker/
|
||||||
|
├── index.ts (entry point — onMessage, initWasm,
|
||||||
|
│ inactivity-timer wiring; phase 5 touches
|
||||||
|
│ the content-callable timer-reset rule
|
||||||
|
│ and the gitHost clear-on-expiry)
|
||||||
|
├── session.ts (unchanged in scope; Plan A removes the
|
||||||
|
│ try{free()} swallow at :26)
|
||||||
|
├── session-timer.ts (unchanged; phase 5 documents the
|
||||||
|
│ exclusion list inline)
|
||||||
|
├── storage.ts (NEW — phase 2: loadDeviceSettings,
|
||||||
|
│ loadBlacklist, saveBlacklist, plus the
|
||||||
|
│ config + image-base64 + setup-state loaders
|
||||||
|
│ if natural; both router files import here)
|
||||||
|
├── vault.ts (gains: itemToManifestEntry (moved from
|
||||||
|
│ both routers; phase 2), create_vault and
|
||||||
|
│ attach_vault handlers (phase 3), and the
|
||||||
|
│ get_vault_status handler (phase 6))
|
||||||
|
├── git-host.ts (unchanged)
|
||||||
|
├── github.ts / gitea.ts (unchanged)
|
||||||
|
├── devices.ts (unchanged)
|
||||||
|
└── router/
|
||||||
|
├── index.ts (unchanged)
|
||||||
|
├── popup-only.ts (imports from ../storage and ../vault;
|
||||||
|
duplicated definitions deleted)
|
||||||
|
└── content-callable.ts (imports from ../storage and ../vault;
|
||||||
|
duplicated definitions deleted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: `service-worker/storage.ts` becomes the single source for `chrome.storage.local` reads/writes the SW does. `service-worker/vault.ts` already owns vault-tier WASM orchestration, so `itemToManifestEntry`, `create_vault`, `attach_vault`, and `get_vault_status` all belong there.
|
||||||
|
|
||||||
|
### `StateHost` interface contract (P1.6)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// extension/src/shared/state.ts (post-rewrite)
|
||||||
|
import type { Request, Response } from './messages';
|
||||||
|
import type { PopupState, View } from '../popup/popup';
|
||||||
|
|
||||||
|
export interface StateHost {
|
||||||
|
getState(): PopupState;
|
||||||
|
setState(partial: Partial<PopupState>): void;
|
||||||
|
navigate(view: View, extras?: Partial<PopupState>): void;
|
||||||
|
sendMessage(request: Request): Promise<Response>;
|
||||||
|
escapeHtml(s: string): string;
|
||||||
|
popOutToTab(): void;
|
||||||
|
isInTab(): boolean;
|
||||||
|
openVaultTab(hash?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let host: StateHost | null = null;
|
||||||
|
|
||||||
|
export function registerHost(h: StateHost): void {
|
||||||
|
if (host) throw new Error('state host already registered');
|
||||||
|
host = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */
|
||||||
|
export function __resetHostForTests(): void { host = null; }
|
||||||
|
|
||||||
|
export function getState(): PopupState {
|
||||||
|
if (!host) throw new Error('No state host registered');
|
||||||
|
return host.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setState<K extends keyof PopupState>(
|
||||||
|
partial: Pick<PopupState, K> | Partial<PopupState>,
|
||||||
|
): void {
|
||||||
|
if (!host) throw new Error('No state host registered');
|
||||||
|
host.setState(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigate / sendMessage / escapeHtml / popOutToTab / isInTab / openVaultTab
|
||||||
|
// keep their generic-friendly signatures (View, Request, Response).
|
||||||
|
//
|
||||||
|
// vault_locked unification: shared/state.ts wraps sendMessage so a
|
||||||
|
// `{ ok: false, error: 'vault_locked' }` response (for any request other
|
||||||
|
// than is_unlocked / unlock) flips host state to locked + dispatches a
|
||||||
|
// `session_expired`-equivalent event the popup also listens for.
|
||||||
|
// Both popup.ts and vault.ts consume from this single channel — the
|
||||||
|
// vault.ts:47-74 RPC intercept is removed in phase 4.
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `View` and `PopupState` are currently defined in `extension/src/popup/popup.ts`. To avoid a popup→shared circular import, they migrate to `extension/src/shared/types.ts` (or `extension/src/shared/popup-state.ts`) before `state.ts` re-imports them. This migration is part of phase 1.
|
||||||
|
|
||||||
|
### Setup wizard step-registry shape (P1.4)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// extension/src/setup/setup.ts (post-rewrite)
|
||||||
|
interface StepContext {
|
||||||
|
state: WizardState;
|
||||||
|
rerender: () => void;
|
||||||
|
goto: (id: StepId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupStep {
|
||||||
|
id: StepId;
|
||||||
|
/** Pure render — returns innerHTML for #setup-step-host. */
|
||||||
|
render: (ctx: StepContext) => string;
|
||||||
|
/** Wire DOM events; return a teardown the wizard runs on step change. */
|
||||||
|
attach: (root: HTMLElement, ctx: StepContext) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done';
|
||||||
|
|
||||||
|
const STEPS: ReadonlyArray<SetupStep> = [
|
||||||
|
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Cleared on `beforeunload` and on `goto('mode')`. */
|
||||||
|
function clearWizardState(): void {
|
||||||
|
// Best-effort wipe: zero-fill Uint8Arrays before drop where reachable;
|
||||||
|
// null out passphrase + token strings (JS strings are GC-only — see Risks).
|
||||||
|
if (state.carrierImageBytes) state.carrierImageBytes.fill(0);
|
||||||
|
if (state.referenceImageBytes) state.referenceImageBytes.fill(0);
|
||||||
|
if (state.referenceImageBytesAttach) state.referenceImageBytesAttach.fill(0);
|
||||||
|
// Reset every field of `state` to its initial value.
|
||||||
|
// NOTE: setup no longer holds a SessionHandle (the SW does); the
|
||||||
|
// phase 1 sweep deletes `verifiedHandle` from WizardState entirely.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.ts` no longer imports `relicario-wasm` and no longer touches `wasm.lock` / `.free()`. The two new SW handlers do that work (see Plan A coordination below).
|
||||||
|
|
||||||
|
The 1220 LOC drops to ~500: the ~400 LOC of crypto orchestration (image-secret extract / embed, KDF gating, manifest_encrypt, attachment_encrypt) moves to the SW; the remaining UI logic compresses by ~300 LOC because the step-registry pattern collapses the six `renderStepN` / `attachStepN` pairs into six `SetupStep` objects.
|
||||||
|
|
||||||
|
### New SW message handlers
|
||||||
|
|
||||||
|
Added to `extension/src/shared/messages.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
|
||||||
|
carrierImageBytes: ArrayBuffer; deviceName: string }
|
||||||
|
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
|
||||||
|
referenceImageBytes: ArrayBuffer; deviceName: string }
|
||||||
|
| { type: 'get_vault_status' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `create_vault` and `attach_vault` are added to `POPUP_ONLY_TYPES`. `get_vault_status` joins the same set. Response shapes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface CreateVaultResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { referenceImageBytes: Uint8Array; deviceName: string;
|
||||||
|
recoveryQrAvailable: true };
|
||||||
|
}
|
||||||
|
export interface AttachVaultResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { deviceName: string };
|
||||||
|
}
|
||||||
|
export interface GetVaultStatusResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { ahead: number; behind: number; lastSyncAt: number | null;
|
||||||
|
pendingItems: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The SW handlers live in `service-worker/vault.ts`. `create_vault` and `attach_vault` hold their own internal session reference for the duration of the operation — they do not depend on the user-facing inactivity timer (see Risks).
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
Each phase keeps the existing vitest test suite green throughout. Regression budget per phase: **zero** test failures introduced; new tests added per phase (synthetic fixtures only, no binary blobs — `make_test_jpeg` style equivalents on the JS side).
|
||||||
|
|
||||||
|
### Phase 1 — `StateHost` typing + `__resetHostForTests` (P1.6)
|
||||||
|
|
||||||
|
- **Goal:** Make `extension/src/shared/state.ts` type-checked end to end so phases 3 and 4 can refactor against a real contract.
|
||||||
|
- **Changes:**
|
||||||
|
- Move `View` and `PopupState` from `extension/src/popup/popup.ts` to `extension/src/shared/types.ts` (or new `extension/src/shared/popup-state.ts`).
|
||||||
|
- Rewrite `extension/src/shared/state.ts` to the snippet in **Approach**: typed `StateHost`, generic `getState`/`setState`, double-registration throw, `__resetHostForTests` export. No `any` in the public surface.
|
||||||
|
- Sweep all callers of `getState()` / `setState()` / `navigate()`. Existing `as any` casts surface as TS errors and get fixed (typed access where the field is known; an explicit narrowing where it isn't).
|
||||||
|
- The `vault_locked` channel collapse is **not** in this phase — the wrapper around `sendMessage` lands here as a no-op signature change; its body (the RPC intercept) lifts in phase 4.
|
||||||
|
- **Tests:**
|
||||||
|
- New `extension/src/shared/__tests__/state.test.ts` covering: register-then-getState round-trip; double-register throws; `__resetHostForTests` clears the singleton; `getState()` without a registered host throws.
|
||||||
|
- Adjust any existing test that accidentally relied on a leaked host (none expected; the existing suites already register-then-tear-down per-test).
|
||||||
|
- **Effort:** S-M
|
||||||
|
- **Depends on:** none
|
||||||
|
|
||||||
|
### Phase 2 — Extract `service-worker/storage.ts` + move `itemToManifestEntry` (P1.9)
|
||||||
|
|
||||||
|
- **Goal:** Eliminate the duplicated SW helpers so blacklist mutations and manifest-projection refactors happen in one place.
|
||||||
|
- **Changes:**
|
||||||
|
- Create `extension/src/service-worker/storage.ts` exporting `loadDeviceSettings`, `saveDeviceSettings`, `loadBlacklist`, `saveBlacklist`. Migrate the bodies from `extension/src/service-worker/router/popup-only.ts:687-703` (and `saveDeviceSettings` from the same file) and delete the `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` definitions in `extension/src/service-worker/router/content-callable.ts:187-205`.
|
||||||
|
- Move `itemToManifestEntry` from both router files (`popup-only.ts:707` and `content-callable.ts:169`) into `extension/src/service-worker/vault.ts` as a named export. Both routers import it from there.
|
||||||
|
- Optionally also fold `loadConfig` / `loadImageBase64` / `loadSetupState` into `storage.ts` since they're chrome.storage.local readers; keep the boundary clean.
|
||||||
|
- **Tests:**
|
||||||
|
- New `extension/src/service-worker/__tests__/storage.test.ts` covering load/save round-trips for each helper, default-value fallback when the key is absent.
|
||||||
|
- The existing `extension/src/service-worker/router/__tests__/router.test.ts` keeps passing; the dispatch behavior is unchanged.
|
||||||
|
- **Effort:** S
|
||||||
|
- **Depends on:** none (independent of phase 1; can ship parallel)
|
||||||
|
|
||||||
|
### Phase 3 — Setup wizard SW migration + step registry (P1.4)
|
||||||
|
|
||||||
|
- **Goal:** Setup wizard becomes UI-only. SW owns `create_vault` and `attach_vault` end to end. Wizard restructures to a step-registry pattern. Sensitive material clears on abandon.
|
||||||
|
- **Changes:**
|
||||||
|
- Add `create_vault` and `attach_vault` types to `extension/src/shared/messages.ts` (request union, response interfaces, capability set).
|
||||||
|
- Implement handlers in `extension/src/service-worker/vault.ts`. Each handler:
|
||||||
|
1. Computes salt + params, calls `unlock` (create) or verifies (attach), calls `embed_image_secret` / `extract_image_secret`, calls `register_device`, calls `manifest_encrypt` for the empty manifest, writes the resulting bytes to the configured remote via the existing `git-host` abstraction.
|
||||||
|
2. Holds its own `SessionHandle` for the duration. On success, transitions the SW into the unlocked state (replaces the popup-driven `unlock` path's outcome); on failure, calls `wasm.lock(handle)` then `.free()` (see Plan A coordination).
|
||||||
|
3. Returns the reference image bytes (`create_vault`) or just the device name (`attach_vault`) so the wizard's "Done" step can offer a download.
|
||||||
|
- Rewrite `extension/src/setup/setup.ts`:
|
||||||
|
- Delete the WASM dynamic-import block at lines 28-37; delete the `loadWasm()` helper; delete the `wasm` module variable; delete `verifiedHandle` from `WizardState`.
|
||||||
|
- Convert each of the six `renderStepN` / `attachStepN` pairs into a `SetupStep` object (`modeStep`, `hostStep`, `connectionStep`, `vaultStep`, `deviceStep`, `doneStep`) per the step-registry snippet in **Approach**. The wizard's render loop becomes `STEPS[state.step].render(ctx)` + `STEPS[state.step].attach(root, ctx)`.
|
||||||
|
- Replace direct WASM orchestration in the vault step with `sendMessage({ type: 'create_vault', ... })` / `sendMessage({ type: 'attach_vault', ... })`.
|
||||||
|
- Add `clearWizardState()` per the snippet; bind to `window.addEventListener('beforeunload', clearWizardState)` and call from the `goto('mode')` path.
|
||||||
|
- Update `extension/src/wasm.d.ts` only if the new SW handlers need a WASM entry point that isn't already declared (verify by reading `extension/src/service-worker/vault.ts` — they likely don't, since `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt` are already declared; the SW just orchestrates them). If no new WASM entry, this file is **not touched** by Plan C.
|
||||||
|
- The `recovery_qr_generated_at` direct chrome.storage.local write at `setup.ts:1056-1062` is **out of scope** per the kickoff (defer to a P3 cleanup) — it stays as-is in this phase.
|
||||||
|
- **Tests:**
|
||||||
|
- Update `extension/src/setup/__tests__/setup.test.ts` to assert on the step registry shape (each `SetupStep.id`, `render` returns non-empty HTML, `attach` returns a callable teardown).
|
||||||
|
- New SW-side test in `extension/src/service-worker/__tests__/vault.test.ts` (or extend an existing one) covering `create_vault` happy path with a stubbed `git-host` and the WASM stub from `__stubs__/relicario_wasm.stub.ts` (round out the stub for `embed_image_secret`, `register_device`, `manifest_encrypt` if they aren't there yet — DEV-C P2 noted only 7 of ~25 are stubbed).
|
||||||
|
- New test for `clearWizardState`: simulate `beforeunload`, assert `Uint8Array` contents are zero-filled.
|
||||||
|
- **Effort:** L
|
||||||
|
- **Depends on:** Phase 1 (the wizard's UI uses `getState`/`setState` against a typed `StateHost`)
|
||||||
|
|
||||||
|
### Phase 4 — Split `vault.ts` + lift `vault_locked` channel (P1.5)
|
||||||
|
|
||||||
|
- **Goal:** `extension/src/vault/vault.ts` shrinks to ~200 LOC of routing + state. Each pane concern lives in its own module. The `vault_locked` RPC intercept disappears from vault.ts and runs in `shared/state.ts` instead.
|
||||||
|
- **Changes:**
|
||||||
|
- Create the five new files (`vault-shell.ts`, `vault-sidebar.ts`, `vault-list.ts`, `vault-drawer.ts`, `vault-form-wrapper.ts`) per the directory tree in **Approach**. Migrate the corresponding code blocks from the existing `vault.ts` into them. Each module exports a `render*` function plus, where stateful, an explicit `teardown()`.
|
||||||
|
- In `vault.ts`, retain only: `RouterState` declaration, hash parsing (`parseHash`, `setHash`), `loadManifest`, the `render()` entry point, the `renderPane()` switch, and the imports that wire the modules together.
|
||||||
|
- In `vault-drawer.ts`: include an `ensureDrawerClosedForRoute(route)` helper that the `renderPane` switch calls before any non-list view. This implements the P2 fix for `vault.ts:495-536` (drawer state no longer leaks across navigation).
|
||||||
|
- In `vault-sidebar.ts`: wrap the search input handler in a 50-100ms debounce (DEV-C P2 — `vault.ts:648-695`).
|
||||||
|
- Lift the `vault_locked` RPC intercept (`vault.ts:47-74`) into the `sendMessage` wrapper in `extension/src/shared/state.ts` (the wrapper signature landed in phase 1; phase 4 fills the body). Both popup and vault now consume the same `session_expired`-equivalent flow. **Migration discipline:** keep both signals firing for one merge cycle (the SW continues to dispatch `session_expired` and the wrapper still fires the intercept) until both surfaces have been verified as consuming from `shared/state.ts`; then collapse.
|
||||||
|
- **Tests:**
|
||||||
|
- Existing `extension/src/vault/__tests__/form-wrapper.test.ts` and `sidebar-glyphs.test.ts` continue to pass (the symbols they import move from `vault.ts` to `vault-form-wrapper.ts` / `vault-sidebar.ts`; update import paths).
|
||||||
|
- New `extension/src/vault/__tests__/drawer-state.test.ts` covering: drawer auto-closes when navigating from list to trash/devices/settings.
|
||||||
|
- New `extension/src/shared/__tests__/state-vault-locked.test.ts` covering: a `{ ok: false, error: 'vault_locked' }` response (for a request other than `is_unlocked`/`unlock`) flips host state to locked.
|
||||||
|
- **Effort:** M
|
||||||
|
- **Depends on:** Phase 1 (uses the typed `StateHost` surface and the `sendMessage` wrapper)
|
||||||
|
|
||||||
|
### Phase 5 — Extension P2 cluster
|
||||||
|
|
||||||
|
- **Goal:** Sweep five small extension P2s that share the same "small fix, real correctness win" shape.
|
||||||
|
- **Changes:**
|
||||||
|
- **Inactivity timer reset on content-callable messages** (`extension/src/service-worker/index.ts:76-78`): invert the current condition. Reset on **all** messages except a small documented exclusion set (`get_autofill_candidates` is the only known read-only content call). Define `READ_ONLY_CONTENT_CALLABLE` in `service-worker/session-timer.ts` with a doc comment listing each excluded type and the rationale; `index.ts` consults that set.
|
||||||
|
- **`state.gitHost` clear on session expiry** (`extension/src/service-worker/index.ts:51-58`): in the `sessionTimer.onExpired` callback, also set `state.gitHost = null`. The initializer rebuilds it on demand.
|
||||||
|
- **Teardown helper extraction** (`extension/src/popup/components/settings.ts:56-65` and `settings-vault.ts:15-22`): extract `teardownSettingsCommon()` exported from `settings.ts` (or a new `settings-shared.ts`); both `settings.ts:teardownSettings` and `settings-vault.ts:teardown` call it. Single source for the closeGeneratorPanel + activeKeyHandler removal pattern.
|
||||||
|
- **`Promise.allSettled` in devices/trash** (`extension/src/popup/components/devices.ts:47-50`, `trash.ts:39-46`): swap `Promise.all` for `Promise.allSettled`; render each settled response defensively (`.status === 'fulfilled' && r.value.ok`); fall back to "couldn't load" copy per failed slot.
|
||||||
|
- **MutationObserver debounce** (`extension/src/content/detector.ts:96-103`): wrap the existing `() => scan()` callback in a 200ms trailing-edge debounce (or `requestIdleCallback` if available; `setTimeout(..., 200)` fallback). Reset on every observer fire.
|
||||||
|
- **Tests:**
|
||||||
|
- Existing `extension/src/service-worker/__tests__/session-timer.test.ts` extended with a "popup-only message resets, content-callable message does not reset (except listed exclusions)" case.
|
||||||
|
- Existing `extension/src/popup/components/__tests__/devices.test.ts` and `trash.test.ts` extended with a "one RPC fails, the other still renders" case.
|
||||||
|
- Existing `extension/src/popup/components/__tests__/settings.test.ts` and `settings-vault.test.ts` extended to confirm both call paths invoke `teardownSettingsCommon`.
|
||||||
|
- **Effort:** M
|
||||||
|
- **Depends on:** none (all five are independent of phases 1-4)
|
||||||
|
|
||||||
|
### Phase 6 — `get_vault_status` SW message + vault-sidebar status indicator
|
||||||
|
|
||||||
|
- **Goal:** Close the last CLI/extension parity gap (`relicario status` ↔ extension status indicator).
|
||||||
|
- **Changes:**
|
||||||
|
- Add `{ type: 'get_vault_status' }` to `extension/src/shared/messages.ts` and to `POPUP_ONLY_TYPES`. Add `GetVaultStatusResponse` per the **Approach** snippet.
|
||||||
|
- Implement the handler in `extension/src/service-worker/vault.ts`. It returns `{ ahead, behind, lastSyncAt, pendingItems }` from cached state on `state.gitHost` (no actual sync). A "last sync" timestamp is recorded in `service-worker/index.ts` (or `state.gitHost`) on each successful `sync` handler return.
|
||||||
|
- Create `extension/src/vault/vault-status.ts` rendering a small indicator in the sidebar footer: glyph + "in sync" / "N ahead" / "N pending" / "last sync 2m ago". Polls on sidebar mount and on a manual refresh button (NOT every render — see Risks).
|
||||||
|
- **Tests:**
|
||||||
|
- New `extension/src/service-worker/__tests__/vault-status.test.ts` covering the four state combinations and the no-sync invariant (handler does not touch the network).
|
||||||
|
- New `extension/src/vault/__tests__/status-indicator.test.ts` for the renderer.
|
||||||
|
- **Effort:** S-M
|
||||||
|
- **Depends on:** Phase 4 (the indicator lives inside the new `vault-sidebar.ts` boundary)
|
||||||
|
|
||||||
|
### Future / deferred (Plan B coordination)
|
||||||
|
|
||||||
|
Plan B (CLI restructure) migrates `parse_month_year`, `base32_decode_lenient`, and `guess_mime` from the CLI into `relicario-core`, then re-exports them through `relicario-wasm`. Once those WASM exports land, the extension can consume them via new SW message handlers (e.g. `parse_month_year`, `decode_totp_secret`, `guess_mime_for_filename`) — this is a natural follow-up and explicitly **deferred to a future plan**. The seam to consume them is `extension/src/service-worker/vault.ts` (or a new `service-worker/parse.ts`) plus a new entry in `extension/src/wasm.d.ts`. Plan C does **not** design those handlers in detail.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
- **State.ts typing changes ripple.** Every consumer of `getState`/`setState` becomes type-checked; existing `as any` casts will surface as TS errors. *Mitigation:* phase 1 includes a sweep + targeted TS error fix as part of the phase, not as a follow-up. Run `tsc --noEmit` per file class to triage; expect ~15-30 errors clustered in `popup/components/*.ts` and `vault/*.ts`.
|
||||||
|
- **Setup-via-SW migration changes the crypto state machine.** Today setup orchestrates WASM directly; after phase 3, the SW owns vault creation. If the SW's user-facing inactivity timer fires mid-creation, the user could lose progress. *Mitigation:* design `create_vault` / `attach_vault` to be transactional from the SW's perspective — 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.
|
||||||
|
- **`vault.ts` split + `vault_locked` channel unification.** The popup currently uses `session_expired` event; vault tab uses RPC intercept. Unifying onto one channel means popup behavior must continue to work after phase 4. *Mitigation:* keep both signals firing during phase 4 (the SW continues to dispatch `session_expired`; the new wrapper in `shared/state.ts` also fires the intercept); collapse only after both surfaces are verified to consume from `shared/state.ts` in the same merge cycle. Add a regression test asserting the popup's lock screen renders on `session_expired` and the vault tab's lock screen renders on the SW response intercept.
|
||||||
|
- **`.free()` callsite policy.** Plan A (security/docs polish) handles the Rust-side `impl Drop for SessionHandle` and removes the `try { current.free() }` swallow at `extension/src/service-worker/session.ts:26`. Plan C does **not** redo that work, but wherever this refactor *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 whether Plan A's Rust-side `impl Drop` lands. Cite Plan A as the source of the policy.
|
||||||
|
- **WASM boundary coordination.** Plan B (CLI restructure) will touch `extension/src/wasm.d.ts` for the new parser exports (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) once they land in `relicario-wasm`. Plan C should **not** touch `extension/src/wasm.d.ts` unless `create_vault` / `attach_vault` need WASM entry points that aren't already declared (verify by reading `service-worker/vault.ts`; the SW already orchestrates `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt`, so likely no new entries needed). If both plans must touch `wasm.d.ts`, sequence Plan B's edits first and rebase Plan C on top.
|
||||||
|
- **`clearWizardState()` semantics.** Clearing on `beforeunload` is best-effort in browsers (the event can be skipped if the tab crashes or is killed). JS strings (passphrase, API token) are also GC-only — there is no `Zeroize` for them. *Mitigation:* explicit zero-fill of `Uint8Array` fields where possible (carrier image bytes, reference image bytes); document the best-effort contract in the function header comment; do not over-promise in user docs. The same caveat already applies to existing strings in `WizardState` today, so this is a maintenance of the existing contract, not a regression.
|
||||||
|
- **`get_vault_status` design.** Needs to call into the git-host abstraction without triggering an actual sync. *Mitigation:* cache the last-sync state in `state.gitHost` (add `lastSyncAt: number | null`, `ahead: number`, `behind: number` fields populated by the existing `sync` handler) and have `get_vault_status` read those cached values; the sidebar indicator polls on mount + on a manual refresh button rather than on every render. Any UI element that wants live status calls `sync` explicitly.
|
||||||
|
- **Phase ordering risk.** Phase 1 is the blocker — phases 3 and 4 both depend on the typed `StateHost`. If phase 1 takes longer than estimated, run phase 2 (independent) and phase 5 (independent) in parallel to avoid total stall.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Plan C does NOT touch:
|
||||||
|
|
||||||
|
- Anything in **Plan A** (security/docs polish): Rust `impl Drop for SessionHandle`, the `service-worker/session.ts:26` swallow removal, `.free()` callsite audit, `recovery_qr.rs` documentation, server hardening, env-var audit.
|
||||||
|
- Anything in **Plan B** (CLI restructure): `cli/main.rs` split, git-shell error UX helper, `parse_month_year`/`base32_decode_lenient`/`guess_mime` migration to core (Plan C only consumes them, deferred to a future phase).
|
||||||
|
- 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 comment, glyphs.ts partial adoption, `types.ts` `TotpKind` flat-union refactor, `totp-tools.ts:39-46` swallowed rejections, generator-panel cleanup idempotence guard, `item-list.ts` popover listener reuse path, popup `popup.ts:178-181` unconditional teardowns.
|
||||||
|
- Other CLI/extension parity items: per-attachment `delete_attachment` SW message (P3), `list --tag` filter doc note (P3) — only `get_vault_status` is in scope.
|
||||||
|
- Cross-cutting items not explicitly listed: `chrome.storage.local` direct reads outside the setup migration (e.g. `settings-security.ts:112-113`), `bun test` runner doc note, manifest version sync.
|
||||||
|
- The full P2/P3 tail outside the items folded above.
|
||||||
|
- The 8 "Open architectural decisions" from the synthesis appendix.
|
||||||
|
- WASM JS-naming snake_case → camelCase decision (defer to a separate plan).
|
||||||
|
- Anything touching the in-flight uncommitted v0.5.x work (vault.ts +151/-99, vault.css +238/-99, manifest version, glyphs additions). Plan C executes against committed `main` post-061facd.
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
A reviewer confirms the plan shipped by checking each item:
|
||||||
|
|
||||||
|
- [ ] `extension/src/shared/state.ts` `StateHost` interface defines every field; no `any` in the public surface.
|
||||||
|
- [ ] `getState` returns `PopupState`; `setState` is generic over `keyof PopupState`.
|
||||||
|
- [ ] `registerHost` throws on second registration; `__resetHostForTests` is exported and used in vitest setup.
|
||||||
|
- [ ] `extension/src/service-worker/router/popup-only.ts` and `content-callable.ts` no longer contain `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` definitions (only imports from `service-worker/storage.ts`).
|
||||||
|
- [ ] `itemToManifestEntry` defined once in `extension/src/service-worker/vault.ts`, imported by both router files.
|
||||||
|
- [ ] `extension/src/setup/setup.ts` LOC ≤ ~500.
|
||||||
|
- [ ] `extension/src/setup/setup.ts` does not import `relicario-wasm` (no dynamic import, no `loadWasm`, no module-level `wasm` binding).
|
||||||
|
- [ ] SW handles `create_vault` and `attach_vault` messages (entries in `messages.ts`, `POPUP_ONLY_TYPES`, and `service-worker/vault.ts`).
|
||||||
|
- [ ] `clearWizardState()` exists, is bound to `beforeunload`, and is called from the `goto('mode')` path; sensitive `Uint8Array` fields are zero-filled.
|
||||||
|
- [ ] `extension/src/vault/vault.ts` is split into `vault.ts` + `vault-shell.ts` + `vault-sidebar.ts` + `vault-list.ts` + `vault-drawer.ts` + `vault-form-wrapper.ts`; `vault.ts` is ~200 LOC of routing + state only.
|
||||||
|
- [ ] Single `vault_locked` channel: the RPC intercept is in `extension/src/shared/state.ts`'s `sendMessage` wrapper; `vault.ts` no longer contains the intercept block at the old lines 47-74.
|
||||||
|
- [ ] Drawer auto-closes on navigation to non-list views (`state.drawerOpen = false` reset in `renderPane` / `ensureDrawerClosedForRoute`).
|
||||||
|
- [ ] Sidebar search input is debounced (50-100ms).
|
||||||
|
- [ ] Inactivity timer resets on **all** messages except a documented exclusion set (currently `get_autofill_candidates`); the exclusion set is defined and commented in `service-worker/session-timer.ts`.
|
||||||
|
- [ ] `state.gitHost = null` runs alongside `state.manifest = null` in the `sessionTimer.onExpired` callback.
|
||||||
|
- [ ] One `teardownSettingsCommon` helper exists; both `settings.ts` and `settings-vault.ts` call it.
|
||||||
|
- [ ] `devices.ts` and `trash.ts` use `Promise.allSettled` and render each settled response defensively.
|
||||||
|
- [ ] `extension/src/content/detector.ts` MutationObserver `scan()` is debounced (200ms or `requestIdleCallback`).
|
||||||
|
- [ ] `get_vault_status` SW message and response interface exist; vault sidebar renders an indicator on mount and on manual refresh.
|
||||||
|
- [ ] All existing vitest tests under `extension/src/**/__tests__/` pass; new tests added per phase pass.
|
||||||
|
- [ ] No `extension/src/wasm.d.ts` change introduced by Plan C unless coordinated with Plan B (verify the file's diff is empty post-merge, or coordinated explicitly with Plan B's parser exports).
|
||||||
|
- [ ] Every `.free()` callsite moved by this plan is preceded by `wasm.lock(handle)` (per Plan A's policy, regardless of whether Plan A's Rust `impl Drop` has landed).
|
||||||
135
docs/superpowers/specs/2026-05-04-security-polish-design.md
Normal file
135
docs/superpowers/specs/2026-05-04-security-polish-design.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Security & Docs Polish — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Status:** Proposed
|
||||||
|
**Source:** `docs/superpowers/reviews/2026-05-04-architecture-review.md` (P1.1, P1.7, P1.8)
|
||||||
|
**Effort estimate:** S (whole-PR; phase totals below)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This is the one-day security and documentation PR that ships first in the v0.5.x architecture-followup train, independent of Plan B (CLI restructure) and Plan C (extension restructure). It closes the single defense-in-depth crypto gap the 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 the one undocumented security-critical core file (`recovery_qr.rs`) up to the documentation density of `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`, and confirms the relay test/launcher polish items. Together these are mechanical, low-risk changes that fix the only finding in the review where "what the code looks like it does" diverges dangerously from "what it actually does" — which is exactly the wall a tinkerer learning Rust would hit hardest.
|
||||||
|
|
||||||
|
## Findings addressed
|
||||||
|
|
||||||
|
- **P1.1 — `SessionHandle` has no `impl Drop`; `.free()` is a cleanup no-op** — DEV-B (Rust headline) + DEV-C (JS partner finding). Files: `crates/relicario-wasm/src/lib.rs:15-23`, `crates/relicario-wasm/src/session.rs:1-58`, `extension/src/service-worker/session.ts:26`.
|
||||||
|
- **Partner JS swallow** — DEV-C P2 in service-worker. File: `extension/src/service-worker/session.ts:26` (the `try { current.free(); } catch { /* already freed */ }` block).
|
||||||
|
- **P1.7 — `recovery_qr.rs` is undocumented relative to the rest of core** — DEV-A. File: `crates/relicario-core/src/recovery_qr.rs:1-130`.
|
||||||
|
- **P1.8 — `tools/relay/queue.test.ts` assertion update** — DEV-C. File: `tools/relay/queue.test.ts:54`. Confirmed during planning: **already fixed in `061facd`** — line 54 now reads `assert.ok(isRole("dev-c"));` and the negative case `assert.ok(!isRole("dev-d"));` is on line 55. No code work remains; the plan tracks this as a verification step.
|
||||||
|
- **Launcher follow-up — `tools/relay/start.sh` still references three roles** — DEV-C P2. File: `tools/relay/start.sh:30-86`. Confirmed during planning: **not yet fixed.** Both `launch_tmux` and `launch_kitty` open only PM, Dev-A, Dev-B (no Dev-C); the manual-mode banner still says "Open 3 new terminals"; the post-launch summary lines (`:61, :81`) print "3 windows (PM, Dev-A, Dev-B)"; no `*-dev-c-prompt.md` is discovered or used. This phase is real.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
The PR is mechanical and uniform across four phases:
|
||||||
|
|
||||||
|
- **Adds** one `impl Drop for SessionHandle` block in `crates/relicario-wasm/src/lib.rs`, one Rust test that exercises construct → drop → registry-empty, one module-level `//!` doc-block in `recovery_qr.rs`, one ASCII layout diagram next to the magic constants there, four per-function doc-comments on the public API there, and one `DEV_C_PROMPT` discovery line + a fourth window in each launcher mode of `start.sh`.
|
||||||
|
- **Removes** the `try { current.free(); } catch { /* already freed */ }` swallow at `extension/src/service-worker/session.ts:26`.
|
||||||
|
- **Verifies** every `.free()` callsite under `extension/src/` (the audit deliverable for P1.1's JS side; today the audit itself is small — only one match — but the deliverable is the recorded grep + a check that every code path that holds a `SessionHandle` calls `wasm.lock(handle)` before `free()`).
|
||||||
|
- **Confirms in passing** that `tools/relay/queue.test.ts:54` already matches the new role union (it does — committed in `061facd`).
|
||||||
|
|
||||||
|
No public API changes on the Rust side beyond the `Drop` impl (which is observably equivalent to "calling `lock(handle)` was previously the only way to clean up"). No JS public API changes. No file moves. No new dependencies (`wasm-bindgen-test` is already in `[dev-dependencies]` for the wasm crate).
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
### Phase 1 — Rust-side `impl Drop for SessionHandle` + test
|
||||||
|
|
||||||
|
- **Goal:** make wasm-bindgen's auto-generated `.free()` actually clear the master key from WASM linear memory, regardless of whether JS also called `lock(handle)`.
|
||||||
|
- **Changes:**
|
||||||
|
- `crates/relicario-wasm/src/lib.rs` — add `impl Drop for SessionHandle { fn drop(&mut self) { let _ = session::remove(self.0); } }` immediately after the existing `#[wasm_bindgen] impl SessionHandle { ... value() ... }` block (around line 23). Update the doc-comment on `SessionHandle` (line 15) to document the contract: "Dropping (or `.free()` from JS) removes the entry from the session registry and zeroizes the wrapped key/image_secret. `lock(handle)` remains available as the explicit early-cleanup path; `Drop` is the safety net."
|
||||||
|
- `crates/relicario-wasm/src/session.rs` — no functional change required, but add a one-line module-level note above `pub fn remove(...)` explicitly stating that `Drop for SessionHandle` is the second caller of this function alongside `lock()`, so that a future reader removing `lock()` doesn't accidentally orphan the cleanup path.
|
||||||
|
- **Tests:**
|
||||||
|
- Add a `#[wasm_bindgen_test]` (the `wasm-bindgen-test` crate is already in `[dev-dependencies]`, line 25 of `Cargo.toml`) under `crates/relicario-wasm/tests/session_drop.rs`. The test constructs a `SessionHandle` (via the public `unlock` path with synthetic JPEG + tiny `KdfParams` for fast key derivation, mirroring the existing native `session_tests::manifest_round_trip_via_handle` shape), records the underlying handle id via `handle.value()`, drops the handle, then asserts `session::with(id, |_| ()).is_none()`. The `session::with` symbol must be re-exported from `tests/` scope; if it isn't, expose `pub fn with_for_tests(handle: u32) -> bool` on the `session` module guarded by `#[cfg(test)]` so the test does not need to reach into thread-locals.
|
||||||
|
- As a fallback / belt-and-suspenders, add a parallel native `#[test]` under the existing `mod session_tests` in `lib.rs` that constructs a `SessionHandle(session::insert(...))`, drops it explicitly with `drop(handle)`, then asserts `session::with(h, |_| ()).is_none()`. Native tests run on every `cargo test`; the wasm-bindgen-test runs only when someone invokes `wasm-pack test`, so the native variant is the one that catches regressions in CI.
|
||||||
|
- **Effort:** S
|
||||||
|
- **Depends on:** none
|
||||||
|
|
||||||
|
### Phase 2 — JS-side `.free()` audit + remove the swallow
|
||||||
|
|
||||||
|
- **Goal:** make the JS side stop hiding crypto-state-transition errors, and prove (via a recorded audit) that there are no other `.free()` callsites on `SessionHandle` (or on `EncryptedAttachment` / `TotpCode`) that bypass `wasm.lock(handle)` first.
|
||||||
|
- **Changes:**
|
||||||
|
- `extension/src/service-worker/session.ts:24-28` — replace the body of `clearCurrent()` so that `current.free()` is called unconditionally (no `try`/`catch`) and `current` is set to `null` afterwards. The doc-block at the top of the file (lines 1-9) gets one extra paragraph noting that with `impl Drop` on the Rust side (Phase 1), `free()` now removes the registry entry — `lock(handle)` becomes a redundant early-cleanup option, but the SW currently does not call it. Document whether to keep `clearCurrent()` minimal (just `free()`) or add an explicit `wasm.lock(current.value)` immediately before `free()` for symmetry with the CLI's session lifecycle. Recommended: just `free()` post-Phase-1 — calling `lock` first is now belt-and-suspenders that adds a redundant HashMap remove. Document the rationale in the comment.
|
||||||
|
- **Audit deliverable:** run `grep -rn "\.free\b" extension/src/` and record the result in the PR description. Today this returns exactly one match (`extension/src/service-worker/session.ts:26`), confirming the SW is the only surface that calls `.free()` on a wasm-bindgen handle. If a future surface (e.g. an offscreen document, a future setup-flow refactor) adds a second callsite, this PR's grep becomes the baseline that flags the new one.
|
||||||
|
- **Lifecycle audit (no code change required, but record findings in PR description):** confirm every code path that constructs a `SessionHandle` via `unlock(...)` ultimately reaches `clearCurrent()`. The two paths are (a) explicit user lock via the popup `lock` action → `service-worker/router/popup-only.ts` `lock` handler → `clearCurrent()`, and (b) inactivity timer expiry → `service-worker/index.ts:51-58` session-expired callback → `clearCurrent()`. Both are already wired; this audit just records that the wiring is complete.
|
||||||
|
- **Tests:**
|
||||||
|
- Vitest in `extension/`: add a small unit test in `extension/src/service-worker/__tests__/session.test.ts` (create if absent) that asserts `clearCurrent()` is a no-op when `current` is `null`, calls `setCurrent({ free: spy, value: 1 } as unknown as SessionHandle)` then `clearCurrent()`, and asserts `spy` was called exactly once and `getCurrent()` returns `null`. Use the existing `__stubs__/relicario_wasm.stub.ts` pattern for the handle shape if convenient.
|
||||||
|
- Optionally extend the lifecycle test: a second case where `free()` throws — assert the exception now propagates out of `clearCurrent()` (regression coverage for the swallow removal).
|
||||||
|
- **Effort:** S
|
||||||
|
- **Depends on:** Phase 1 (the swallow removal is only safe once the Rust side actually does the cleanup work that `.free()` is now supposed to do)
|
||||||
|
|
||||||
|
### Phase 3 — `recovery_qr.rs` documentation pass
|
||||||
|
|
||||||
|
- **Goal:** raise `crates/relicario-core/src/recovery_qr.rs` to the documentation density of `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs` so a reader landing in the file does not assume it is out-of-the-documented-zone scratch code.
|
||||||
|
- **Changes:** all in `crates/relicario-core/src/recovery_qr.rs`:
|
||||||
|
- **Module-level `//!` header** at the top of the file (above the `use` block on line 1). Three short paragraphs: (1) *what* the module produces (a 109-byte payload encoded as a QR v40 EcLevel::M SVG that, given the same passphrase, recovers the 32-byte image_secret); (2) *why the format is structured this way* — XChaCha20-Poly1305 wrapping over an Argon2id-derived "wrap key" derived from the recovery passphrase (`b"relicario-recovery-v1\0"` domain-separation prefix prevents the wrap key from ever colliding with a vault master key, even if the user reuses their vault passphrase here); (3) *parameter-pinning rationale* — `production_params()` deliberately re-states `KdfParams::default()`'s values rather than referencing them, because changing the default at the type level must not silently invalidate every recovery QR a user has printed.
|
||||||
|
- **ASCII layout diagram** above the magic-constant block (lines 7-9):
|
||||||
|
```
|
||||||
|
// 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
|
||||||
|
```
|
||||||
|
Pair the diagram with a `const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48);` static assertion so a future edit that changes the layout cannot drift from the documented total without a compile error.
|
||||||
|
- **Doc-comments on the four public items:**
|
||||||
|
- `RecoveryQrPayload` (line 11): one-paragraph summary; note that `as_bytes()` is the only accessor and that the bytes are an opaque sealed package — they should travel together (printing them as a QR is the supported transport, but a hex string also works for paranoid sneakernet).
|
||||||
|
- `generate_recovery_qr` (line 45): documents inputs (passphrase, image_secret), output (the 109-byte payload), and the security properties (random kdf_salt + random wrap_nonce per call ⇒ two QRs from the same passphrase + image_secret are different bytes). Cross-reference `recovery_qr_to_svg` for rendering.
|
||||||
|
- `unwrap_recovery_qr` (line 81): documents inputs/output, return-type rationale (`Zeroizing<[u8; 32]>` so the recovered image_secret zeroes when dropped), and the error vocabulary (`RecoveryQr` for format errors, `Decrypt` for AEAD failure / wrong passphrase — the deliberate non-distinguishing rejection from audit M4).
|
||||||
|
- `recovery_qr_to_svg` (line 122): documents the rendering choice (QR v40, EcLevel::M, 140×140 minimum dimension) and the rationale (109 bytes fits comfortably in v40 at M, leaving recovery-error-correction headroom for a smudged print).
|
||||||
|
- **`production_params` (line 32):** add a doc-comment explaining the deliberate divergence-tolerance from `KdfParams::default()` — wording: "Pinned at QR-format authoring time. `KdfParams::default()` may evolve as we re-tune Argon2 cost; this function MUST NOT change values that have ever shipped, because doing so silently invalidates every recovery QR printed under a previous parameter set." Either keep the function-with-comment shape, or per the synthesis suggestion, replace it with a `const RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };` if `KdfParams` is `const`-constructible (it is — checked via `crypto.rs`). Either is acceptable; the `const` form is preferred because it makes the "this is not derived from defaults" property visible at the use site.
|
||||||
|
- **Optional micro-cleanups while in the file** (cite explicitly so they aren't surprising in code review):
|
||||||
|
- Replace the hand-counted `payload_bytes[5..37]`, `[37..61]`, `[61..109]` slice indices with named constants (`KDF_SALT_RANGE`, `WRAP_NONCE_RANGE`, `CIPHERTEXT_RANGE`) derived from the layout offsets. Improves diff-readability if the layout ever extends.
|
||||||
|
- Add a one-line comment to `recovery_kdf_input` documenting that the length-prefix on `nfc_bytes` mirrors the same convention as `crypto::derive_master_key` (audit H1 in the design spec) — this is the connective tissue that explains "why is there a `(len as u64).to_be_bytes()` here too?"
|
||||||
|
- **Tests:**
|
||||||
|
- No new test logic needed — the existing `tests/recovery_qr.rs` and the `RecoveryQr`/`Decrypt` round-trips in `tests/integration.rs` already pin behaviour.
|
||||||
|
- The `const _: () = assert!(...)` static assertion is itself the contract test for the layout diagram.
|
||||||
|
- **Effort:** S
|
||||||
|
- **Depends on:** none
|
||||||
|
|
||||||
|
### Phase 4 — Relay launcher (queue.test.ts already done in `061facd`)
|
||||||
|
|
||||||
|
- **Goal:** confirm the test fix that already shipped, and bring the launcher script up to the four-role world.
|
||||||
|
- **Changes:**
|
||||||
|
- `tools/relay/queue.test.ts` — **no change.** Confirmed during planning: line 54 is already `assert.ok(isRole("dev-c"));` and line 55 is already `assert.ok(!isRole("dev-d"));` (committed in `061facd`). This phase records the verification step in the PR description so a reviewer can see the file was checked.
|
||||||
|
- `tools/relay/start.sh` — real changes:
|
||||||
|
- Add a `DEV_C_PROMPT` discovery line at lines 30-33 alongside the existing `PM_PROMPT` / `DEV_A_PROMPT` / `DEV_B_PROMPT` definitions: `DEV_C_PROMPT="$(ls -t "$COORD_DIR"/*-dev-c-prompt.md 2>/dev/null | head -1 || echo "(none found)")"`.
|
||||||
|
- Manual mode (`print_manual_instructions`, lines 35-51) — change "Open 3 new terminals" to "Open 4 new terminals" and add the `Terminal 4 (Dev C): cat '$DEV_C_PROMPT'` line.
|
||||||
|
- tmux mode (`launch_tmux`, lines 53-69) — add a fourth `tmux new-window -t "$SESSION:" -n "dev-c" "cd '$REPO_ROOT' && claude"` line; update the summary print on line 61 from "4 windows: relay, pm, dev-a, dev-b" to "5 windows: relay, pm, dev-a, dev-b, dev-c"; add `Dev C: $DEV_C_PROMPT` to the prompts list (currently lines 64-66).
|
||||||
|
- kitty mode (`launch_kitty`, lines 71-86) — add a fourth `kitty @ launch --type=tab --tab-title "Dev-C" --hold -- bash -l -i -c "cd '$REPO_ROOT' && claude"` line; update the summary print on line 81 from "3 windows (PM, Dev-A, Dev-B)" to "4 windows (PM, Dev-A, Dev-B, Dev-C)"; add `Dev C: $DEV_C_PROMPT` to the prompts list.
|
||||||
|
- **Tests:** none directly. The relay launcher has no automated tests; verification is "run `bash tools/relay/start.sh --manual` and confirm the banner mentions four terminals + the Dev-C prompt path; run `bash tools/relay/start.sh --kitty` on a kitty terminal and confirm a fourth tab opens." Both checks belong in the PR's manual-test plan.
|
||||||
|
- **Effort:** S
|
||||||
|
- **Depends on:** none
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
- **Adding `impl Drop` could change observable behaviour for any JS code that today holds two references to the same `SessionHandle` and frees one of them.** wasm-bindgen does not in fact let JS hold two refs to the same Rust struct without explicit cloning — the `SessionHandle` is moved into JS at construction and `.free()` consumes the handle pointer. The audit in Phase 2 confirmed only one `.free()` callsite under `extension/src/`. The CLI does not touch the WASM crate at all (Rust-side; the CLI uses `relicario-core` directly). So the realistic blast radius is exactly the one SW callsite this plan also touches. Mitigation: Phase 1's native test explicitly covers "drop the handle, registry is empty," and Phase 2's vitest covers "free is called exactly once."
|
||||||
|
- **Removing the `try { current.free() } catch { /* already freed */ }` swallow could expose latent bugs that were silently tolerated.** Specifically, if any code path today double-frees the handle (calls `clearCurrent()` twice without a `setCurrent()` in between), the catch is currently hiding it. Reading the SW lifecycle: `clearCurrent()` is called from (a) the `lock` message handler, (b) the inactivity-timer session-expired callback, and (c) potentially the unlock-failure cleanup path. The current `if (!current) return;` early-out at line 25 already guards the simplest double-free case (calling `clearCurrent()` twice in a row is safe because the second call no-ops). The only way the catch fires today is if something in WASM raises during `free()` — which post-Phase-1 should be impossible (Drop is infallible; `session::remove` returns a bool, not a Result). Mitigation: the swallow removal happens *after* Phase 1's Drop lands, so the catch should now have nothing to catch. If a regression test surfaces a real bug, the right fix is to find and fix the bug, not re-add the swallow.
|
||||||
|
- **The `recovery_qr.rs` static assertion (`const _: () = assert!(PAYLOAD_LEN == ...);`) requires Rust 1.79+** for `const` panic in test position. The workspace's `rust-toolchain.toml` (if present) or the `Cargo.toml` `rust-version` field needs to be checked; if the repo is on an older toolchain, fall back to a runtime `#[test] fn payload_len_matches_layout() { assert_eq!(PAYLOAD_LEN, 4 + 1 + 32 + 24 + 48); }` — same effect, slightly less elegant. Verify during implementation; not a blocker.
|
||||||
|
- **Phase 4's launcher change is untested by automation.** A typo in the kitty/tmux command lines won't surface until someone runs `start.sh --kitty` or `start.sh --tmux`. Mitigation: the PR description includes an explicit manual-test plan ("run all three modes and confirm 4 windows + Dev-C prompt resolution") and the change pattern is symmetric to the existing Dev-A/Dev-B lines, minimizing typo surface.
|
||||||
|
- **The `wasm-bindgen-test` variant in Phase 1 only runs under `wasm-pack test --node`, which is not part of the project's default `cargo test` cycle.** The native `#[test]` in `mod session_tests` is the one that protects against regressions in CI. The wasm-bindgen-test is included for symmetry and to exercise the actual WASM target's Drop behaviour — it should be invoked at least once during PR review and added to a follow-up CI workflow if the project's test infrastructure ever expands to wasm-pack.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
This plan deliberately does not address, and explicitly leaves to other plans or the P2/P3 tail:
|
||||||
|
|
||||||
|
- Plan B's CLI restructure work in its entirety (P1.2 main.rs split, P1.3 git-shell error UX, P1.10 parser migration to core, plus the CLI P2/P3 entries). These touch `crates/relicario-cli/` and are independent of this PR.
|
||||||
|
- Plan C's extension restructure work in its entirety (P1.4 setup.ts WASM extraction, P1.5 vault.ts split, P1.6 shared/state.ts typing, P1.9 SW router helper extraction, plus the extension P2/P3 entries). These touch `extension/src/` and are independent of this PR — except for the single SW file that Phase 2 of this plan touches, which is small and disjoint from Plan C's surfaces.
|
||||||
|
- The P2 WASM cleanups DEV-B flagged: redundant `need_key + with(...).unwrap()` double-lookup, `Vec<u8>` getter clones, the `wasm_*_recovery_qr` naming inconsistency, and the `device.rs` vs `session.rs` concurrency-primitive split. Each is independently correct and worth doing, but each is its own one-paragraph change and the PR is already covering its theme.
|
||||||
|
- DEV-C's other relay P2s: queue TTL/persistence/cap, the `tools/relay/call.py` and `call.ts` tracking decision, `server.ts` per-connection factory comment. None block this PR.
|
||||||
|
- The eight "Open architectural decisions" at the bottom of the synthesis (deliberate-Drop-omission, parser migration timing, bootstrap rule, `Lock` subcommand visibility, Task 12 status, call.py tracking, snake_case-vs-camelCase WASM naming, `.gitea_env_vars` gitignore). Most are user-judgement calls; this PR confirms #1 (the omission was not deliberate; the fix lands here) but takes no position on #2-#8.
|
||||||
|
- Anything touching the in-flight uncommitted v0.5.x work — manifests, glyphs, vault.css, relay tooling beyond `start.sh`. Those changes are on parallel branches and are explicitly hands-off for this PR.
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
A reviewer can confirm this plan shipped by checking:
|
||||||
|
|
||||||
|
- [ ] `crates/relicario-wasm/src/lib.rs` contains `impl Drop for SessionHandle` whose body removes the registry entry.
|
||||||
|
- [ ] `crates/relicario-wasm/tests/session_drop.rs` (or equivalent native `#[test]` inside `mod session_tests`) asserts that dropping a `SessionHandle` removes its handle id from the session registry. `cargo test -p relicario-wasm` passes the new test.
|
||||||
|
- [ ] `extension/src/service-worker/session.ts` no longer contains `try { current.free(); } catch`. The body of `clearCurrent()` calls `current.free()` unconditionally, then sets `current = null`.
|
||||||
|
- [ ] `grep -rn "\.free\b" extension/src/` returns exactly one match (the SW callsite). PR description records this audit.
|
||||||
|
- [ ] `extension/src/service-worker/__tests__/session.test.ts` covers `clearCurrent()` with both the no-op (no current handle) and the propagating-throw cases. `npm test` from `extension/` passes.
|
||||||
|
- [ ] `crates/relicario-core/src/recovery_qr.rs` has a module-level `//!` header explaining format + domain-separation + parameter-pinning, an ASCII layout diagram next to the magic constants, doc-comments on `RecoveryQrPayload`, `generate_recovery_qr`, `unwrap_recovery_qr`, and `recovery_qr_to_svg`, plus either a `const RECOVERY_PRODUCTION_PARAMS` or a doc-commented `production_params()` explaining why it deliberately diverges from `KdfParams::default()`. `cargo test -p relicario-core` still passes.
|
||||||
|
- [ ] `tools/relay/queue.test.ts:54-55` verified to already match the new role union (no change required, recorded in PR description).
|
||||||
|
- [ ] `tools/relay/start.sh` discovers a `*-dev-c-prompt.md`, opens a fourth window/terminal in tmux and kitty modes, and the manual-mode banner mentions all four roles. `bash tools/relay/start.sh --manual` shows "Open 4 new terminals" and lists the Dev-C prompt path.
|
||||||
37
extension/src/service-worker/__tests__/session.test.ts
Normal file
37
extension/src/service-worker/__tests__/session.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import type { SessionHandle } from '../../../wasm/relicario_wasm';
|
||||||
|
import { clearCurrent, getCurrent, setCurrent } from '../session';
|
||||||
|
|
||||||
|
describe('session', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset module-scope `current` between tests. Overwrite first with a
|
||||||
|
// benign no-throw fake so a prior test's throwing handle can't escape.
|
||||||
|
setCurrent({ free: vi.fn(), value: 0 } as unknown as SessionHandle);
|
||||||
|
clearCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCurrent() is a no-op when no handle is set', () => {
|
||||||
|
expect(() => clearCurrent()).not.toThrow();
|
||||||
|
expect(getCurrent()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCurrent() calls free() exactly once and clears current', () => {
|
||||||
|
const free = vi.fn();
|
||||||
|
setCurrent({ free, value: 1 } as unknown as SessionHandle);
|
||||||
|
|
||||||
|
clearCurrent();
|
||||||
|
expect(free).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCurrent()).toBeNull();
|
||||||
|
|
||||||
|
clearCurrent();
|
||||||
|
expect(free).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCurrent()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCurrent() propagates exceptions from free()', () => {
|
||||||
|
const free = vi.fn(() => { throw new Error('boom'); });
|
||||||
|
setCurrent({ free, value: 2 } as unknown as SessionHandle);
|
||||||
|
|
||||||
|
expect(() => clearCurrent()).toThrow(/boom/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,12 @@
|
|||||||
/// Future multi-vault (β+) would replace `current` with
|
/// Future multi-vault (β+) would replace `current` with
|
||||||
/// `Map<vaultId, SessionHandle>` and thread `vaultId` through every
|
/// `Map<vaultId, SessionHandle>` and thread `vaultId` through every
|
||||||
/// handler. Deliberate α simplicity — not an oversight.
|
/// handler. Deliberate α simplicity — not an oversight.
|
||||||
|
///
|
||||||
|
/// As of Phase 1 of the security-polish series, `impl Drop for SessionHandle`
|
||||||
|
/// on the Rust side makes `.free()` the meaningful cleanup call: it removes
|
||||||
|
/// the entry from the SESSIONS registry and zeroizes `master_key` and
|
||||||
|
/// `image_secret`. Calling `wasm.lock(handle.value)` before `.free()` would
|
||||||
|
/// be redundant belt-and-suspenders; this module intentionally does not.
|
||||||
|
|
||||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||||
|
|
||||||
@@ -23,6 +29,6 @@ export function requireCurrent(): SessionHandle {
|
|||||||
|
|
||||||
export function clearCurrent(): void {
|
export function clearCurrent(): void {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
try { current.free(); } catch { /* already freed */ }
|
current.free();
|
||||||
current = null;
|
current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
5
extension/src/wasm.d.ts
vendored
5
extension/src/wasm.d.ts
vendored
@@ -59,6 +59,11 @@ declare module 'relicario-wasm' {
|
|||||||
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
||||||
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||||
|
|
||||||
|
// Pure parsers (no session needed)
|
||||||
|
export function parse_month_year(s: string): { month: number; year: number };
|
||||||
|
export function base32_decode_lenient(s: string): Uint8Array;
|
||||||
|
export function guess_mime(filename: string): string;
|
||||||
|
|
||||||
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
||||||
|
|
||||||
export function register_device(name: string): {
|
export function register_device(name: string): {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
|||||||
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||||
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
DEV_C_PROMPT="$(ls -t "$COORD_DIR"/*-dev-c-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
|
||||||
print_manual_instructions() {
|
print_manual_instructions() {
|
||||||
echo ""
|
echo ""
|
||||||
@@ -38,12 +39,13 @@ print_manual_instructions() {
|
|||||||
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
echo "Open 4 new terminals. In each, start Claude Code and paste"
|
||||||
echo "the content BELOW the '---' line from the corresponding file."
|
echo "the content BELOW the '---' line from the corresponding file."
|
||||||
echo ""
|
echo ""
|
||||||
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||||
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||||
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||||
|
echo " Terminal 4 (Dev C): cat '$DEV_C_PROMPT'"
|
||||||
echo ""
|
echo ""
|
||||||
echo "This terminal becomes the relay log. Keep it open."
|
echo "This terminal becomes the relay log. Keep it open."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -57,13 +59,15 @@ launch_tmux() {
|
|||||||
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||||
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||||
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-c" "cd '$REPO_ROOT' && claude"
|
||||||
echo ""
|
echo ""
|
||||||
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
echo "[relay] Opened tmux session '$SESSION' with 5 windows: relay, pm, dev-a, dev-b, dev-c."
|
||||||
echo "[relay] Paste the kickoff prompt into each Claude window."
|
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||||
echo " Prompts:"
|
echo " Prompts:"
|
||||||
echo " PM: $PM_PROMPT"
|
echo " PM: $PM_PROMPT"
|
||||||
echo " Dev A: $DEV_A_PROMPT"
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
echo " Dev B: $DEV_B_PROMPT"
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
echo " Dev C: $DEV_C_PROMPT"
|
||||||
echo ""
|
echo ""
|
||||||
tmux attach-session -t "$SESSION"
|
tmux attach-session -t "$SESSION"
|
||||||
}
|
}
|
||||||
@@ -77,12 +81,15 @@ launch_kitty() {
|
|||||||
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||||
kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \
|
kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \
|
||||||
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --type=tab --tab-title "Dev-C" --hold -- \
|
||||||
|
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||||
echo ""
|
echo ""
|
||||||
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
echo "[relay] Opened kitty tab 'relay' + 4 windows (PM, Dev-A, Dev-B, Dev-C)."
|
||||||
echo " Paste the kickoff prompts into each Claude window."
|
echo " Paste the kickoff prompts into each Claude window."
|
||||||
echo " PM: $PM_PROMPT"
|
echo " PM: $PM_PROMPT"
|
||||||
echo " Dev A: $DEV_A_PROMPT"
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
echo " Dev B: $DEV_B_PROMPT"
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
echo " Dev C: $DEV_C_PROMPT"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
|
|||||||
Reference in New Issue
Block a user