Goal: give the CLI the verbs a script or a second program needs to read
sessions safely: machine-readable output, a session list, an undo for naming,
a guard on moving the sessions directory, and a doctor that says what is
missing.
Architecture: every verb is a thin src/main.rs arm over a pure function in
src/session.rs (or src/config.rs) that takes paths, so each has a unit test
against a temporary directory. Output formats are stable: --json prints the
same structs serde_json already serialises.
Tech Stack: Rust 1.85 floor, serde/serde_json, no new crates.
Global Constraints
raw.jsonlis never rewritten; every change is an appended line inedits.jsonl.--jsonoutput is one JSON value on stdout and nothing else on stdout.- Every new verb is added to
USAGEinsrc/main.rsand documented indocs/using/commands.mdin the existing per-verb style (heading, fenced command, a short paragraph). - Functions that take a
rootnever consultsession::home(); the CLI arm passeshome()in. - Tests use a directory unique to the test:
std::env::temp_dir().join(format!("ambient-{}-{}", <test fn name as a string literal>, std::process::id())), created at the start and removed at the end of that test only;cargo testruns tests in parallel, so two tests never share a directory. scripts/checkdoes not look at docs. After any docs edit runnode .github/scripts/check-links.mjsand quote its… 0 broken link(s)line.- No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.
Task 1: show --json
Files:
- Modify:
src/session.rs:1049-1055(Line),src/session.rs:1127-1145(show) - Modify:
src/main.rs:141-148(showarm),USAGE - Modify:
docs/using/commands.md(theshowsection)
Interfaces:
-
Produces:
#[derive(Serialize)]onLine(field ordertrack, start_ms, end_ms, speaker, text;trackserialises as"room"/"call"exactly asRawRecorddoes);pub fn show(dir: &Path, verbatim: bool, json: bool) -> Result<()>;ambient show <dir> [--verbatim] [--json]printing a JSON array of lines. -
Step 1: write the failing test in
src/session.rs’s test module: create a temp session dir withraw.jsonlholding two records (room0–1000 “hello”,call1000–2000 “hi”) andedits.jsonlwith onespeakeredit naming the first as “Ana”; calltranscript(dir, false)and assertserde_json::to_string(&lines)equals[{"track":"room","start_ms":0,"end_ms":1000,"speaker":"Ana","text":"hello"},{"track":"call","start_ms":1000,"end_ms":2000,"speaker":null,"text":"hi"}]. -
Step 2: run
cargo test session::, expect FAIL “the traitSerializeis not implemented forLine”. -
Step 3: derive
SerializeonLine; add thejsonparameter toshow, printingserde_json::to_string_prettyof the lines when set; parse--jsonin theshowarm the way--verbatimis parsed (collect the remaining args into aVecfirst so both flags work in either order). -
Step 4: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/session.rs src/main.rs docs/using/commands.md && git commit -m "show: --json for scripts".
Task 2: ambient sessions
Files:
- Modify:
src/session.rs(newpub struct SessionSummary,pub fn summaries(root: &Path) -> Vec<SessionSummary>next tolistatsrc/session.rs:927-937; newpub fn live_session_in(root: &Path) -> Option<PathBuf>with the existinglive_session()(src/session.rs:1389-1397) becominglive_session_in(&home())) - Modify:
src/main.rs(newsessionsarm beforeshow),USAGE - Modify:
docs/using/commands.md(new## sessionssection)
Interfaces:
-
Consumes:
session::list,SessionMeta(session.json),is_growing,live_transcriber. -
Produces:
#[derive(Serialize)] pub struct SessionSummary { pub id: String, pub dir: PathBuf, pub name: Option<String>, pub started_at: Option<String>, pub duration_s: Option<f64>, pub transcribed: bool, pub live: bool, pub transcribing: bool, pub error: Option<String> };ambient sessions [--json]: one line per session newest first (id started_at duration name state) or the JSON array.stateis one oflive,transcribing,awaiting transcript,done,broken. -
Step 1: write the failing test: temp root with four session dirs:
aholdingsession.json(id “a”, duration 12.5) andtranscript.md;bholding onlysession.json(id “b”);cholding asession.jsonthat is not JSON ({"id":);dholding nosession.jsonbut a freshly writtenaudio/room.native.wav(a capture in progress:session.jsonis written only after the scratch wavs close,src/session.rs:766); plus a directorynoteswith nothing in it and a plain fileREADME.summaries(root)returnsd,c,b,ain that order (newest first, by directory name, which sorts by time);a.transcribed == true;b.transcribed == false;c.errorisSomeand contains “session.json”;c.id == "c"(the directory name, when the file cannot say);d.live == true,d.error == None,d.duration_s == None;notesandREADMEare absent;a,b,chavelive == false. -
Step 2: run
cargo test session::summaries, expect FAIL “cannot find functionsummaries”. -
Step 3: implement: a dir is included when
session.jsonexists oraudio/room.native.wavis growing; a parse or read failure fillserrorand leaves the metadata fieldsNone(statebroken), so a session interrupted mid-write is listed, not hidden; asession.jsonwhosesymlink_metadatais not a regular file is not read and fillserrorwith “session.json is a symlink”; a live capture has nosession.jsonyet, so its metadata fields areNonewith no error (statelive).transcribed=transcript.mdis a file;live=is_growing(dir/audio/room.native.wav);transcribing=live_transcriber(dir).is_some(). Addlive_session_in(root)by moving the body oflive_session()and making the old function call it. -
Step 4: run
scripts/check, expectCHECK OK; runcargo run -- sessionsand quote the first line; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/session.rs src/main.rs docs/using/commands.md && git commit -m "sessions: list what is on disk".
Task 3: ambient undo
Files:
- Modify:
src/session.rs(newpub fn undo_last_naming(dir: &Path) -> Result<usize>andpub fn undo_seq(dir: &Path, seq: usize) -> Result<()>aftername_speaker,src/session.rs:1343-1379) - Modify:
src/main.rs(newundoarm),USAGE - Modify:
docs/using/commands.md(new## undosection);docs/using/getting-started.md:174(replace “there is no undo verb” with one sentence pointing atundo)
Interfaces:
-
Consumes:
Edit::Revert { target_seq, by, at }whichtranscriptalready folds (src/session.rs:1059-1121);USER_BY;markdown. -
Produces:
ambient undo <dir>reverts the newest batch ofspeakeredits byuser(everyspeakerline sharing the newestatamong user speaker edits), appending onerevertper line and rewritingtranscript.md; printsreverted <n> edits.ambient undo <dir> --seq <n>reverts one edit by its zero-based line inedits.jsonl. An already-reverted seq, or a seq that names arevert, is an error. -
Step 1: write the failing tests: (a) temp session with two raw lines both labelled
SPEAKER_00bydiarizeedits, thenname_speaker(dir, "SPEAKER_00", "Ana");undo_last_naming(dir)returns 2 andtranscript(dir,false)showsSPEAKER_00again; a secondundo_last_namingreturns an error “nothing to undo”. (b)undo_seq(dir, 0)on the diarize edit reverts it;undo_seq(dir, 0)again errors “already reverted”;undo_seq(dir, 99)errors “no edit 99”. -
Step 2: run
cargo test session::undo, expect FAIL “cannot find function”. -
Step 3: implement over
edits.jsonlread asVec<Edit>: the set of already-reverted seqs is everyRevert.target_seq; appendEdit::Revert { target_seq, by: USER_BY, at: now }per line with oneatfor the batch; rewritetranscript.mdfrommarkdown(dir)?exactly asname_speakerdoes. -
Step 4: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/session.rs src/main.rs docs/using/commands.md docs/using/getting-started.md && git commit -m "undo: revert the last naming batch".
Task 4: Refuse to move the sessions directory mid-recording
Files:
- Modify:
src/config.rs(newpub fn refuse_while_live(key: &str, live: Option<&Path>) -> Result<()>next toset,src/config.rs:114) - Modify:
src/main.rs:403-412(configarm: call it beforecfg.set) - Modify:
docs/using/settings.md(one sentence under the sessions directory setting)
Interfaces:
-
Consumes:
session::live_session()(src/session.rs:1389-1397). -
Produces:
ambient config sessions_dir <path>fails witha recording is in progress in <dir>; stop it before moving the sessions directorywhile a capture is live. -
Step 1: write the failing test in
src/config.rs’s test module (create one if absent):refuse_while_live("sessions_dir", Some(Path::new("/x")))is an error containing “/x”;refuse_while_live("sessions_dir", None)andrefuse_while_live("diarize", Some(Path::new("/x")))areOk. -
Step 2: run
cargo test config::, expect FAIL “cannot find functionrefuse_while_live”. -
Step 3: implement; wire the arm as
ambient::config::refuse_while_live(&k, ambient::session::live_session().as_deref())?;. -
Step 4: run
scripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/config.rs src/main.rs docs/using/settings.md && git commit -m "config: keep sessions_dir still while recording".
Task 5: ambient doctor
Files:
- Create:
src/doctor.rs; registerpub mod doctor;insrc/lib.rsbefore writing the test - Modify:
src/session.rs(extractpub fn model_files(root: &Path) -> ModelFilesholding the four paths the verbs build atsrc/session.rs:520-535andsrc/session.rs:1188-1197, and make those two sites use it) - Modify:
src/main.rs(newdoctorarm),USAGE - Modify:
docs/using/troubleshooting.md(a## Start with doctorsection at the top of the page)
Interfaces:
-
Consumes:
session::models_root() -> Result<PathBuf>(an error when no candidate directory exists),config::path,session::captured_awaiting_transcript,session::live_session_in,TRANSCRIBING_LOCK. -
Produces:
pub struct ModelFiles { pub asr_dir: PathBuf, pub vad: PathBuf, pub segmentation: PathBuf, pub embedding: PathBuf };pub struct Check { pub name: String, pub ok: bool, pub detail: String };pub fn run(models: Result<PathBuf>, config_file: &Path, sessions: &Path) -> Vec<Check>;ambient doctor [--json]printsok <name> <detail>/FAIL <name> <detail>per check and exits 1 if any failed. Check names, in order:models/root,models/asr,models/vad,models/segmentation,models/embedding,config,sessions/writable,sessions/awaiting-transcript,sessions/live,sessions/stale-lock. -
Step 1: write the failing tests: (a) with a temp models dir holding only an empty
wespeaker_en_voxceleb_resnet34_LM.onnx, a temp config file containing{}, and a temp sessions dir with one session lackingtranscript.md(session.json+audio/room.wav):runreportsmodels/rootok,models/asrFAIL,models/vadFAIL,models/segmentationFAIL,models/embeddingok,configok,sessions/writableok,sessions/awaiting-transcriptok with detail “1 session”,sessions/liveok with detail “none”,sessions/stale-lockok. (b)run(Err(anyhow!("no models")), …)reportsmodels/rootFAIL with detail containing “no models” and the fourmodels/*checks FAIL with detail “no models directory”; the sessions checks still run. (c) a config file containing{"diarize":givesconfigFAIL with detail containing “not valid”; a missing config file givesconfigok with detail “defaults”; a config path that is a directory givesconfigFAIL with detail containing “could not be read”. -
Step 2: run
cargo test doctor::, expect FAIL “unresolved import”. -
Step 3: implement.
configreads and parses the file itself, becauseConfig::load_from(src/config.rs:74-98) deliberately swallows both failures:read_to_stringNotFound→ ok “defaults”; any other read error → FAIL “could not be read:”; serde_json::from_str::<Config>error → FAIL “not valid:”. sessions/liveuseslive_session_in(sessions). Atranscribing.lockwhose pid is not alive (libc::kill(pid, 0)fails) issessions/stale-lockFAIL with the path in the detail. -
Step 4: run
scripts/check, expectCHECK OK; runcargo run -- doctorand quote its output; runnode .github/scripts/check-links.mjs, expect0 broken link(s). -
Step 5: commit:
git add src/doctor.rs src/session.rs src/lib.rs src/main.rs docs/using/troubleshooting.md && git commit -m "doctor: say what is missing".