14 Commits

Author SHA1 Message Date
adlee-was-taken
8e791e4853 refactor(cli): compress build_*_item with prompt_or_flag
Plan B Phase 3 sub-step 2. Replaces the
title.map(Ok).unwrap_or_else(|| prompt("Title"))? chain in all
seven build_*_item functions with prompt_or_flag, and folds
login's or_else(|| prompt_optional(...).ok().flatten()) for
username and url into prompt_or_flag_optional. prompt_secret
sites and the parse-on-Some-only patterns (expiry, dob, card
kind, totp algorithm) stay as-is per spec. Removes the
#[allow(dead_code)] attributes from the four helpers in
prompt.rs now that callers exist.

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

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

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

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

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

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

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

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

Plan A complete.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:53:20 -04:00
adlee-was-taken
229e483430 docs(core): bring recovery_qr.rs to the documented-zone standard
Phase 3 of the security-polish series. Brings recovery_qr.rs up to
the documentation density of crypto.rs / imgsecret.rs / backup.rs /
tar_safe.rs. No runtime behaviour change: just module-level //! header
explaining the format + KDF domain separation + parameter-pinning
rationale, an ASCII diagram of the 109-byte payload layout pinned by
a static assertion, doc-comments on the four public items, and named
slice-range constants for the offset arithmetic.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:33:40 -04:00
adlee-was-taken
03d0781c39 fix(ext): unswallow free() errors in SW session.clearCurrent + vitest
Phase 1 added impl Drop for SessionHandle on the Rust side so .free()
now actually removes the SESSIONS registry entry. The JS-side
try { current.free() } catch { /* already freed */ } swallow was
hiding the fact that .free() wasn't doing the cleanup at all;
post-Phase-1 it has to go so failures surface instead of being lost.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:36:53 -04:00
adlee-was-taken
1e858e1d1f fix(wasm): impl Drop for SessionHandle clears registry entry
Closes the P1.1 defense-in-depth gap: wasm-bindgen's auto-generated
.free() previously dropped the SessionHandle wrapper (a u32) without
removing the SESSIONS HashMap entry, leaving the master key and
image_secret in WASM linear memory until JS explicitly called
lock(handle). Drop now wires .free() to session::remove, and the
new native test pins the contract.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 01:52:24 -04:00
22 changed files with 1661 additions and 375 deletions

View 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_or_flag, prompt_or_flag_optional, prompt_secret};
pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
AddKind::SecureNote { title, body_prompt, group, tags } =>
build_secure_note_item(title, body_prompt, group, tags)?,
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
AddKind::Card { title, holder, expiry, kind, group, tags } =>
build_card_item(title, holder, expiry, kind, group, tags)?,
AddKind::Key { title, label, algorithm, group, tags } =>
build_key_item(title, label, algorithm, group, tags)?,
AddKind::Document { title, file, group, tags } =>
build_document_item(&vault, title, file, group, tags)?,
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
};
vault.save_item(&item)?;
manifest.upsert(&item);
vault.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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
})
} else {
None
};
let mut item = Item::new(title, ItemCore::Login(LoginCore {
username, password, url: parsed_url, totp,
}));
item.group = group;
item.tags = tags;
item.favorite = favorite;
Ok(item)
}
fn build_secure_note_item(
title: Option<String>,
body_prompt: bool,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::SecureNoteCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_identity_item(
title: Option<String>,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
date_of_birth: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore};
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_card_item(
title: Option<String>,
holder: Option<String>,
expiry: Option<String>,
kind: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{CardCore, CardKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_key_item(
title: Option<String>,
label: Option<String>,
algorithm: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::KeyCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label, public_key, algorithm,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_document_item(
vault: &crate::session::UnlockedVault,
title: Option<String>,
file: PathBuf,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename, mime_type,
size: bytes.len() as u64,
created: item.created,
});
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
Ok(item)
}
#[allow(clippy::too_many_arguments)]
fn build_totp_item(
title: Option<String>,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
period: u32,
digits: u8,
algorithm: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let secret_b32 = match secret {
Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer, label,
}));
item.group = group;
item.tags = tags;
Ok(item)
}

View File

@@ -282,8 +282,7 @@ pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()>
}
} else {
// No history bundled — start a fresh git repo.
let status = crate::helpers::git_command(&target, &["init"]).status()?;
if !status.success() { anyhow::bail!("git init failed"); }
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
// .gitignore — exclude reference image if present.
if target.join("reference.jpg").exists() {

View File

@@ -104,20 +104,17 @@ pub fn cmd_device(action: DeviceAction) -> Result<()> {
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Commit the update.
let status = crate::helpers::git_command(
crate::helpers::git_run(
&root,
&["add", ".relicario/devices.json"],
)
.status()?;
if !status.success() {
anyhow::bail!("git add .relicario/devices.json failed");
}
&format!("device register \"{name}\": git add .relicario/devices.json"),
)?;
let msg = format!("device: register {}", name);
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
.status()?;
if !status.success() {
anyhow::bail!("git commit failed");
}
crate::helpers::git_run(
&root,
&["commit", "-m", &msg],
&format!("device register \"{name}\": git commit"),
)?;
eprintln!("Device '{}' registered.", name);
eprintln!("Signing public key:");
@@ -209,16 +206,17 @@ pub fn cmd_device(action: DeviceAction) -> Result<()> {
".relicario/devices.json",
".relicario/revoked.json",
];
let status = crate::helpers::git_command(&root, &add_args).status()?;
if !status.success() {
anyhow::bail!("git add failed");
}
crate::helpers::git_run(
&root,
&add_args,
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
)?;
let msg = format!("device: revoke {}", name);
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
.status()?;
if !status.success() {
anyhow::bail!("git commit failed");
}
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);

View File

@@ -90,16 +90,16 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
fs::write(root.join(".gitignore"), gitignore)?;
// git init + initial commit via hardened wrapper.
let status = crate::helpers::git_command(&root, &["init"]).status()?;
if !status.success() { anyhow::bail!("git init failed"); }
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()?;
let status = crate::helpers::git_command(&root, &[
"commit", "-m", "init: new Relicario vault (format v2)",
]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
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());

View File

@@ -6,6 +6,7 @@
//! 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;
@@ -31,10 +32,12 @@ pub(crate) fn commit_paths(
) -> Result<()> {
let mut args: Vec<&str> = vec!["add"];
args.extend_from_slice(paths);
let status = crate::helpers::git_command(vault.root(), &args).status()?;
if !status.success() { anyhow::bail!("git add failed"); }
let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
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(())
}

View File

@@ -4,10 +4,8 @@ use anyhow::Result;
pub fn cmd_sync() -> Result<()> {
let root = crate::helpers::vault_dir()?;
let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?;
if !pull.success() { anyhow::bail!("git pull --rebase failed"); }
let push = crate::helpers::git_command(&root, &["push"]).status()?;
if !push.success() { anyhow::bail!("git push failed"); }
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
eprintln!("Sync complete.");
Ok(())
}

View File

@@ -78,11 +78,13 @@ pub fn cmd_purge(query: String) -> Result<()> {
vault.save_manifest(&manifest)?;
crate::refresh_groups_cache(vault.root(), &manifest);
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
let status = crate::helpers::git_command(vault.root(),
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
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(())
}
@@ -121,11 +123,16 @@ pub fn cmd_trash_empty() -> Result<()> {
}
vault.save_manifest(&manifest)?;
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
let status = crate::helpers::git_command(vault.root(),
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
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(())

View File

@@ -55,6 +55,37 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command {
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.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string.
@@ -220,6 +251,24 @@ mod tests {
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]
fn humanize_age_buckets() {
assert_eq!(humanize_age(0), "just now");

View File

@@ -12,14 +12,10 @@ mod session;
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use crate::commands::commit_paths;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
use crate::prompt::{prompt, prompt_optional, prompt_secret};
#[derive(Parser)]
#[command(
name = "relicario",
@@ -428,7 +424,7 @@ fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { image, output } => commands::init::cmd_init(image, output),
Commands::Add { kind } => cmd_add(kind),
Commands::Add { kind } => commands::add::cmd_add(kind),
Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy),
Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed),
Commands::Edit { query, totp_qr } => commands::edit::cmd_edit(query, totp_qr),
@@ -509,310 +505,5 @@ pub(crate) fn test_backup_passphrase_override() -> Option<String> {
None
}
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)?;
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();
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(())
}
// --- Per-type item builders, one per AddKind variant. Each returns a
// fully-populated Item; cmd_add handles the common save/manifest/commit
// wrap-up. Document is the only builder that needs the unlocked vault
// (for attachment-cap settings + writing the encrypted blob alongside
// the item).
#[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)
}

View File

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

View File

@@ -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 rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
// Recovery QR payload — 109 bytes total:
//
// byte field length
// ------ -------------- ------
// 0..4 MAGIC = "RREC" 4
// 4..5 VERSION = 0x01 1
// 5..37 kdf_salt 32 (random per QR)
// 37..61 wrap_nonce 24 (random per QR)
// 61..109 ciphertext 48 (32 image_secret + 16 AEAD tag)
// ------------------------------
// total 109
const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
// Static assertion that the documented layout above and the PAYLOAD_LEN
// constant cannot drift apart. If a future edit changes one without the other,
// this fails to compile.
const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48);
// Named slice ranges derived from the layout offsets above. Used by
// `unwrap_recovery_qr_with_params` so the byte-position arithmetic at the
// parse site is self-documenting.
const KDF_SALT_RANGE: std::ops::Range<usize> = 5..37;
const WRAP_NONCE_RANGE: std::ops::Range<usize> = 37..61;
const CIPHERTEXT_RANGE: std::ops::Range<usize> = 61..109;
/// Pinned recovery-QR Argon2id parameters. Re-states `KdfParams::default()`'s
/// values rather than referencing them, because a recovery QR printed under
/// one parameter set cannot be decoded under another. **Once shipped, these
/// values MUST NOT change** — doing so silently invalidates every previously
/// printed QR. See the module header for full rationale.
const RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams {
argon2_m: 65536,
argon2_t: 3,
argon2_p: 4,
};
/// A sealed 109-byte recovery payload. The bytes are an opaque package — they
/// only become useful when fed back through [`unwrap_recovery_qr`] together
/// with the recovery passphrase that was used to produce them.
///
/// [`as_bytes`](Self::as_bytes) is the only accessor. The bytes are designed to
/// travel as a single unit; the supported transport is rendering via
/// [`recovery_qr_to_svg`] and printing the QR on paper, but a hex string
/// (sneakernet-friendly) works equally well as long as the full 109 bytes
/// are preserved.
pub struct RecoveryQrPayload {
bytes: [u8; PAYLOAD_LEN],
}
@@ -24,15 +118,12 @@ fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
let prefix = b"relicario-recovery-v1\0";
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
input.extend_from_slice(prefix);
// length-prefix on nfc_bytes mirrors crypto::derive_master_key (audit H1)
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
input.extend_from_slice(nfc_bytes);
input
}
fn production_params() -> KdfParams {
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
}
fn derive_wrap_key(
passphrase: &str,
kdf_salt: &[u8; 32],
@@ -42,11 +133,38 @@ fn derive_wrap_key(
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}
/// Produce a sealed [`RecoveryQrPayload`] from the recovery passphrase and the
/// 32-byte image_secret.
///
/// # Inputs
///
/// - `passphrase`: the user's recovery passphrase (UTF-8). Independent of the
/// vault passphrase, but the user may reuse them — the
/// `b"relicario-recovery-v1\0"` domain-separation prefix in the KDF input
/// guarantees the wrap key still cannot collide with a vault master key.
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG
/// via [`crate::imgsecret::extract`].
///
/// # Output
///
/// A [`RecoveryQrPayload`] whose 109 bytes encode `MAGIC || VERSION || kdf_salt
/// || wrap_nonce || ciphertext`. Both `kdf_salt` and `wrap_nonce` are freshly
/// drawn from `OsRng` on every call, so two payloads generated from the same
/// `(passphrase, image_secret)` pair are distinct bit-for-bit. The printed QR
/// therefore does not reveal that the user has printed others before.
///
/// To render the payload as a printable SVG, see [`recovery_qr_to_svg`].
///
/// # Errors
///
/// Returns [`RelicarioError::RecoveryQr`] if the AEAD wrap fails (extremely
/// unlikely in practice — this can only happen if the cipher implementation
/// itself errors, not on user input).
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
generate_recovery_qr_with_params(passphrase, image_secret, &RECOVERY_PRODUCTION_PARAMS)
}
#[doc(hidden)]
@@ -78,11 +196,39 @@ pub fn generate_recovery_qr_with_params(
Ok(RecoveryQrPayload { bytes })
}
/// Decode a recovery payload back into the original 32-byte image_secret.
///
/// # Inputs
///
/// - `payload_bytes`: the 109 bytes produced by [`generate_recovery_qr`] (after
/// the QR has been scanned, or the hex transcribed and decoded).
/// - `passphrase`: the recovery passphrase that was used at generate time.
///
/// # Output
///
/// The recovered image_secret as `Zeroizing<[u8; 32]>` — the wrapper ensures
/// the secret is wiped from memory when the binding goes out of scope, so a
/// caller that immediately feeds it into [`crate::crypto::derive_master_key`]
/// and then drops it never leaves a copy in process memory longer than
/// strictly necessary.
///
/// # Errors
///
/// - [`RelicarioError::RecoveryQr`] for **format** problems: wrong length,
/// bad magic, unsupported version byte. These come from inspecting the
/// bytes themselves, before any cryptographic work, so they leak nothing
/// about whether the passphrase is right.
/// - [`RelicarioError::Decrypt`] for **AEAD** failure — wrong passphrase
/// (wrong wrap key) **or** a payload tampered after the fact. The two
/// cases are deliberately not distinguished, mirroring the same
/// non-distinguishing rejection as [`crate::crypto::decrypt`] (audit M4):
/// a Poly1305 tag failure cannot, in principle, leak which bytes were
/// wrong, and the API surface preserves that property.
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &RECOVERY_PRODUCTION_PARAMS)
}
#[doc(hidden)]
@@ -104,9 +250,9 @@ pub fn unwrap_recovery_qr_with_params(
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 wrap_nonce = &payload_bytes[37..61];
let ciphertext = &payload_bytes[61..109];
let kdf_salt: &[u8; 32] = payload_bytes[KDF_SALT_RANGE].try_into().expect("slice length validated above");
let wrap_nonce = &payload_bytes[WRAP_NONCE_RANGE];
let ciphertext = &payload_bytes[CIPHERTEXT_RANGE];
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
@@ -119,6 +265,15 @@ pub fn unwrap_recovery_qr_with_params(
Ok(out)
}
/// Render a [`RecoveryQrPayload`] as a printable QR-code SVG string.
///
/// The QR is encoded at **version 40** (the largest standard symbol, 177×177
/// modules) at **error-correction level M** (~15% recoverable), with a
/// minimum rendered dimension of **140×140** SVG units. The 109-byte payload
/// fits comfortably inside v40 at level M — there is significant
/// error-correction headroom left over, which is the point: the QR is
/// expected to live on paper (where smudges, folds, and fading are normal)
/// and must still scan years later.
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
use qrcode::{QrCode, EcLevel};
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)

View File

@@ -12,7 +12,13 @@ use zeroize::Zeroizing;
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]
pub struct SessionHandle(u32);
@@ -22,6 +28,23 @@ impl SessionHandle {
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]
pub fn unlock(
passphrase: &str,
@@ -533,6 +556,19 @@ mod session_tests {
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]
fn with_yields_key_only_while_session_lives() {
session::clear();

View File

@@ -46,6 +46,9 @@ where
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 {
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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/);
});
});

View File

@@ -7,6 +7,12 @@
/// Future multi-vault (β+) would replace `current` with
/// `Map<vaultId, SessionHandle>` and thread `vaultId` through every
/// 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';
@@ -23,6 +29,6 @@ export function requireCurrent(): SessionHandle {
export function clearCurrent(): void {
if (!current) return;
try { current.free(); } catch { /* already freed */ }
current.free();
current = null;
}

View File

@@ -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)")"
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_C_PROMPT="$(ls -t "$COORD_DIR"/*-dev-c-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
print_manual_instructions() {
echo ""
@@ -38,12 +39,13 @@ print_manual_instructions() {
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
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 ""
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
echo " Terminal 4 (Dev C): cat '$DEV_C_PROMPT'"
echo ""
echo "This terminal becomes the relay log. Keep it open."
echo ""
@@ -57,13 +59,15 @@ launch_tmux() {
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-b" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-c" "cd '$REPO_ROOT' && claude"
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 " Prompts:"
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
echo " Dev C: $DEV_C_PROMPT"
echo ""
tmux attach-session -t "$SESSION"
}
@@ -77,12 +81,15 @@ launch_kitty() {
bash -l -i -c "cd '$REPO_ROOT' && claude"
kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \
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 "[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 " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
echo " Dev C: $DEV_C_PROMPT"
}
case "$MODE" in