diff --git a/.abcd/development/brief/04-surfaces/01-demo.md b/.abcd/development/brief/04-surfaces/01-demo.md index e2b7bf2..1957d89 100644 --- a/.abcd/development/brief/04-surfaces/01-demo.md +++ b/.abcd/development/brief/04-surfaces/01-demo.md @@ -11,13 +11,20 @@ app contains at least one intentional usability flaw, found by talking. | Flag | Default | Meaning | |---|---|---| | `-addr` | `:8737` | listen address (a bare `:port` binds loopback `127.0.0.1` only) | -| `-out` | `sessions` | root directory for new session folders | +| `-out` | `~/Testimony/sessions` | root directory for new session folders, created on demand | ## Behaviour - Creates `//` (format `2006-01-02_150405`) and writes a `manifest.json` with `t0_epoch_ms` set to launch time, app `"testimony demo"`, participant `"P1"`, and a one-line task. +- `-out` defaults to the fixed root `~/Testimony/sessions`, resolved against + the home directory at invocation time and shared with + [`record`](05-record.md) through one helper in `internal/cli`, so a session + lands in the same place whatever directory the command was run from. The + root is created on demand. With `-out` omitted and no home directory to + resolve, the command refuses at the usage status naming the flag to pass + rather than fall back to a relative root. - Serves the embedded single-page app at `/`; interactive elements carry `data-testid` attributes throughout. - `POST /api/interactions` appends one normalised interaction (single JSON diff --git a/.abcd/development/brief/04-surfaces/05-record.md b/.abcd/development/brief/04-surfaces/05-record.md index c88e4c2..68e20d7 100644 --- a/.abcd/development/brief/04-surfaces/05-record.md +++ b/.abcd/development/brief/04-surfaces/05-record.md @@ -13,7 +13,7 @@ screen video is opt-in retained evidence, not yet consumed downstream. | Flag | Default | Meaning | |---|---|---| -| `-out` | `sessions` | root directory for new session folders | +| `-out` | `~/Testimony/sessions` | root directory for new session folders, created on demand | | `-app` | *(empty)* | application under test (with `-demo`, defaults to the demo app) | | `-participant` | `P1` | participant pseudonym | | `-commit` | *(empty)* | build/commit hash under test | @@ -30,6 +30,13 @@ screen video is opt-in retained evidence, not yet consumed downstream. `t0_epoch_ms` is set once, from the same instant that names the directory. The manifest carries the app under test, participant pseudonym, task list, and the commit hash when supplied. +- `-out` defaults to the fixed root `~/Testimony/sessions`, resolved against the + home directory at invocation time through the same `internal/cli` helper + [`demo`](01-demo.md) uses, so the two cannot drift and a session lands in the + same place whatever directory the command was run from. The root is created on + demand. With `-out` omitted and no home directory to resolve, the command + refuses at the usage status naming the flag to pass rather than fall back to a + relative root. The printed session path is the real one, as it always was. - On macOS, captures the default microphone to `audio.wav` — canonical 16 kHz mono PCM, the exact input the ASR step expects, so no re-conversion is needed downstream. With `-video`, it also captures the screen to `screen.mp4` (H.264). diff --git a/.abcd/development/brief/06-delivery/02-verification.md b/.abcd/development/brief/06-delivery/02-verification.md index 01a85c7..3f31226 100644 --- a/.abcd/development/brief/06-delivery/02-verification.md +++ b/.abcd/development/brief/06-delivery/02-verification.md @@ -66,14 +66,14 @@ the full loop (ffmpeg + an ASR engine installed): 1. `./testimony demo` — note the printed session directory. 2. Start a QuickTime audio recording, say "session start" aloud, click through the demo app while thinking aloud, stop both. -3. `./testimony transcribe -session sessions/ -audio .m4a` +3. `./testimony transcribe -session ~/Testimony/sessions/ -audio .m4a` — check the printed offset and its provenance; if it looks wrong, locate the spoken marker in the transcript and re-run with `-offset`. -4. `./testimony merge -session sessions/` then - `./testimony report -session sessions/`. +4. `./testimony merge -session ~/Testimony/sessions/` then + `./testimony report -session ~/Testimony/sessions/`. 5. Read `report.md`: utterances must interleave with the right events — in particular, the save-button complaint must sit next to the save-button click. -A real captured session is kept under `sessions/` as evidence of the last +A real captured session is kept under `~/Testimony/sessions/` as evidence of the last live verification. diff --git a/.abcd/development/intents/drafts/itd-10-fixed-session-location.md b/.abcd/development/intents/drafts/itd-10-fixed-session-location.md deleted file mode 100644 index b1e6b1a..0000000 --- a/.abcd/development/intents/drafts/itd-10-fixed-session-location.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -id: itd-10 -slug: fixed-session-location -spec_id: null -kind: null -suggested_kind: null -reclassification_history: [] -builds_on: [] -severity: minor ---- - -# Every Session Lands in the Same Place, However You Ran It - -## Press Release - -> **Testimony now defaults every new session to one fixed, discoverable location** (`~/Testimony/sessions`) instead of a `sessions/` folder relative to whatever directory `record` or `demo` happened to be run from. The existing `-out` flag still overrides it for anyone who wants project-scoped capture; nothing about `merge`, `report`, `analyze`, or `review` changes, since they already take an explicit `-session DIR`. -> -> "I ran `demo`, closed the terminal, and twenty minutes later couldn't remember which folder I'd been in when the session was created," said Bob, capturing their first session. "Once it always landed in the same place, I stopped worrying about it." - -## Why This Matters - -`record`'s and `demo`'s default `-out` is the relative path `sessions`, created under whatever the current working directory happens to be at the moment of capture. That is a reasonable default for project-scoped tooling invoked from inside a consistent repository, but it splinters session history across every directory a first-time or occasional operator happened to be sitting in — discoverable later only if they remember. A fixed default trades a little of that lightweight per-project convenience for predictability: sessions accumulate in one place unless an operator deliberately opts into a project-local `-out`. - -## What's In Scope - -- A fixed default output root for `record` and `demo` (for example `~/Testimony/sessions`), used whenever `-out` is not given. -- The existing `-out DIR` flag continues to override the default per invocation, unchanged. -- Docs (`README.md`, `docs/reference/cli.md`, the tutorials) updated to state the new default and how to override it. - -## What's Out of Scope - -- Any config-file-based persistent override; the existing `-out` flag remains the only override mechanism this intent assumes. -- Migrating or relocating session directories captured under the old relative default before this ships. -- `merge`, `report`, `analyze`, and `review`, which already require an explicit `-session DIR` and are unaffected either way. - -## Acceptance Criteria - -- **Given** no `-out` flag, **when** `record` or `demo` creates a new session, **then** the session directory is created under the fixed default location, not relative to the current working directory. -- **Given** an explicit `-out DIR` flag, **when** `record` or `demo` runs, **then** the session directory is created under `DIR` exactly as today. -- **Given** the fixed default location does not yet exist, **when** a session is created, **then** the directory is created automatically, matching today's root-creation behaviour for `-out`. - -## Open Questions - -- What is the right fixed default path — `~/Testimony/sessions`, an XDG-style `~/.local/share/testimony/sessions`, or something else? Platform conventions differ between macOS and Linux. -- Is the existing `-out` flag override sufficient, or does this need a persistent override too (an environment variable or config file)? -- Changing an existing default is a behaviour change, not a bug fix — does it need its own CHANGELOG entry and a migration note for anyone who scripted against the old relative `sessions/` default? - -## Audit Notes - -_Empty. Populated by intent-fidelity-reviewer when intent moves to shipped/._ diff --git a/.abcd/development/intents/shipped/itd-10-fixed-session-location.md b/.abcd/development/intents/shipped/itd-10-fixed-session-location.md new file mode 100644 index 0000000..50995e3 --- /dev/null +++ b/.abcd/development/intents/shipped/itd-10-fixed-session-location.md @@ -0,0 +1,143 @@ +--- +id: itd-10 +slug: fixed-session-location +spec_id: spc-2609150752365788 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [] +severity: minor +--- + +# Every Session Lands in the Same Place, However You Ran It + +## Press Release + +> **Testimony now defaults every new session to one fixed, discoverable location** (`~/Testimony/sessions`) instead of a `sessions/` folder relative to whatever directory `record` or `demo` happened to be run from. The existing `-out` flag still overrides it for anyone who wants project-scoped capture; nothing about `merge`, `report`, `analyze`, or `review` changes, since they already take an explicit `-session DIR`. +> +> "I ran `demo`, closed the terminal, and twenty minutes later couldn't remember which folder I'd been in when the session was created," said Bob, capturing their first session. "Once it always landed in the same place, I stopped worrying about it." + +## Why This Matters + +`record`'s and `demo`'s default `-out` is the relative path `sessions`, created under whatever the current working directory happens to be at the moment of capture. That is a reasonable default for project-scoped tooling invoked from inside a consistent repository, but it splinters session history across every directory a first-time or occasional operator happened to be sitting in — discoverable later only if they remember. A fixed default trades a little of that lightweight per-project convenience for predictability: sessions accumulate in one place unless an operator deliberately opts into a project-local `-out`. + +## What's In Scope + +- A fixed default output root for `record` and `demo` (for example `~/Testimony/sessions`), used whenever `-out` is not given. +- The existing `-out DIR` flag continues to override the default per invocation, unchanged. +- Docs (`README.md`, `docs/reference/cli.md`, the tutorials) updated to state the new default and how to override it. + +## What's Out of Scope + +- Any config-file-based persistent override; the existing `-out` flag remains the only override mechanism this intent assumes. +- Migrating or relocating session directories captured under the old relative default before this ships. +- `merge`, `report`, `analyze`, and `review`, which already require an explicit `-session DIR` and are unaffected either way. + +## Acceptance Criteria + +- **Given** no `-out` flag, **when** `record` or `demo` creates a new session, **then** the session directory is created under the fixed default location, not relative to the current working directory. +- **Given** an explicit `-out DIR` flag, **when** `record` or `demo` runs, **then** the session directory is created under `DIR` exactly as today. +- **Given** the fixed default location does not yet exist, **when** a session is created, **then** the directory is created automatically, matching today's root-creation behaviour for `-out`. + +## Scope Conditions + +- The home directory resolves for the process running the command; with no home to resolve, the default root does not exist and the command refuses rather than guessing one. +- The default root is created on demand exactly as an explicit `-out` root is today, including any missing parent, and a root that cannot be created is a runtime failure naming the path it tried. +- The operator can write under their own home directory; a read-only or quota-exhausted home makes the default root unusable, and `-out` is the answer. +- `-out DIR` is the only override in scope: the default is not read from an environment variable, a configuration file, or any persisted state. +- Sessions captured under the old relative default stay where they are; nothing moves them, and every command that operates on an existing session still takes the path it is given. +- The path is written `~/Testimony/sessions` wherever it appears in text, and the printed session directory stays the real, resolved path the operator can act on. + +## Open Questions + +- What is the right fixed default path — `~/Testimony/sessions`, an XDG-style `~/.local/share/testimony/sessions`, or something else? Platform conventions differ between macOS and Linux. +- Is the existing `-out` flag override sufficient, or does this need a persistent override too (an environment variable or config file)? +- Changing an existing default is a behaviour change, not a bug fix — does it need its own CHANGELOG entry and a migration note for anyone who scripted against the old relative `sessions/` default? + +## Audit Notes + + +Fidelity review — receipt rcp-5ef93c710261 (verifier abcd:intent-auditor claude-opus-5[1m]). + +Provenance: abcd:intent-auditor@claude-opus-5[1m] · rubric_hash sha256:3540a217931626f34372df0e4e460ac8cb662b950d8f734afdc24f102e7354bc · prompt_hash sha256:aafe064e67fdd5f51b57b7164fe104a47c91afa806b5cb04923995a4adce9871 +Input attestations: diff:HEAD..working tree@sha256:af2e5d4ac801d4002d04fbd12ae4e071c7830eddc8e0653880ff0cc52bfa7102; + +Acceptance rollup: MET 2 · MET_WITH_CONCERNS 1 · NOT_MET 0 · INCONCLUSIVE 0 + +Per-criterion verdicts: +- ac-1 — MET: both capture commands register defaultSessionRoot()'s home-anchored result as the -out default, and a passing test shows bare record tries < home>/Testimony/sessions and leaves the working directory empty + evidence: internal/cli/cli.go:855 + evidence: internal/cli/cli.go:171 + evidence: internal/cli/cli.go:77 + evidence: internal/cli/cli_test.go:1268 + evidence: internal/demo/demo.go:104 +- ac-2 — MET: the flag default is the only thing that changed, so a given -out reaches session.Create verbatim; the passing test shows record -out sessions uses the relative root, never touches the home root, and that an unresolvable home does not interfere with any explicit -out invocation + evidence: internal/cli/cli_test.go:1302 + evidence: internal/cli/cli.go:192 + evidence: internal/cli/cli.go:206 +- ac-3 — MET_WITH_CONCERNS: creation is session.Create's unchanged os.MkdirAll(outRoot, 0o755), the identical call an explicit -out root has always used and one that creates the missing Testimony parent; concern: no delivered test demonstrates the default root actually being created, every new default-root test pins a failure path, and the pre-existing TestCreate is handed an already-existing root + evidence: internal/session/session.go:76 + evidence: internal/record/record.go:138 + evidence: internal/cli/cli_test.go:1268 + evidence: internal/session/session_test.go:16 + +Gap audit: +- honoured: + - a fixed default output root ~/Testimony/sessions for record and demo, used whenever -out is not given, defined once so the two cannot drift + evidence: internal/cli/cli.go:855 + evidence: internal/cli/cli.go:835 + evidence: internal/cli/cli_test.go:1248 + - the existing -out DIR flag continues to override the default per invocation, unchanged + evidence: internal/cli/cli_test.go:1302 + evidence: internal/cli/cli.go:171 + - docs (README, docs/reference/cli.md, session-directory.md, the tutorials and how-to guides) updated to state the new default and how to override it; no page still shows the old relative default + evidence: docs/reference/cli.md:39 + evidence: docs/reference/cli.md:41 + evidence: README.md:96 + evidence: docs/reference/session-directory.md:3 + - merge, report, analyze and review are unaffected, still operating on a session they are given or infer + evidence: internal/cli/cli.go:904 + evidence: CHANGELOG.md:98 + - the behaviour change is recorded as one, naming the old default, the new root, and the one-flag migration + evidence: CHANGELOG.md:87 + evidence: CHANGELOG.md:89 +- diverged: + - the press release describes only a change of default location, but the delivery adds a new refusal: with -out omitted and no resolvable home, record and demo now exit 2 where they previously succeeded against a relative sessions/ root — a signed-off divergence, carried by the intent's own scope condition and by the spec rather than by the press release text + evidence: internal/cli/cli.go:91 + evidence: internal/cli/cli.go:192 + evidence: internal/cli/cli_test.go:1355 + - the ~/Testimony/sessions spelling is not used in every text surface: the -out flag registers the resolved path, so testimony record -h prints (default "/< home>/Testimony/sessions") — a deliberate, spec-recorded choice, but a delta from the condition's 'wherever it appears in text' + evidence: internal/cli/cli.go:171 + evidence: internal/cli/cli.go:77 +- missing: + - no delivered test demonstrates a session directory actually landing under the fixed default root: the new default-root coverage is entirely failure-path (an un-creatable root, an unresolvable home), and demo's creation path is not exercised end to end at all + evidence: internal/cli/cli_test.go:1268 + evidence: internal/cli/cli_test.go:1355 + evidence: internal/session/session_test.go:16 + +Scope-condition dispositions: +- cond-2609150800063443 — survived: an unresolvable home with -out omitted exits at the usage status naming the root, the reason and the flag, and never falls back to a relative root; the check is scoped by fs.Visit to the omitted-flag path + evidence: internal/cli/cli.go:91 + evidence: internal/cli/cli.go:867 + evidence: internal/cli/cli_test.go:1355 +- cond-2609150800069939 — survived: the resolved root reaches session.Create as the same opaque string an explicit -out always did, so creation is the unchanged os.MkdirAll that also makes the missing Testimony parent, and a root that cannot be created stays a runtime failure naming the path it tried + evidence: internal/session/session.go:76 + evidence: internal/cli/cli_test.go:1268 +- cond-2609150800061963 — survived: an unusable default root is reported at exit 1 naming that path and -out remains a fully home-free escape hatch, proven with HOME cleared; the unusability was exercised by a file planted at the root rather than by a read-only home, but the effect path is the same one + evidence: internal/cli/cli_test.go:1268 + evidence: internal/cli/cli_test.go:1302 +- cond-2609150800068368 — survived: the default comes only from os.UserHomeDir joined with Testimony/sessions; no environment variable, configuration file or persisted state is read anywhere on the path, and the docs state -out as the sole override + evidence: internal/cli/cli.go:855 + evidence: docs/reference/cli.md:43 +- cond-2609150800063485 — survived: the change is confined to the two capture commands' -out default and the refusal; no relocation or migration code exists, resolveSession is untouched, and every existing-session command still takes the path it is given or infers + evidence: internal/cli/cli.go:904 + evidence: CHANGELOG.md:98 +- cond-2609150800063390 — narrowed: the ~ spelling holds in the usage block, the reference docs, the README and the refusal message, and the start-up output still prints the real resolved directory; but the -out flag default is registered resolved, so -h prints an absolute home path instead + narrowing: holds for prose surfaces — the usage block, the documentation and the refusal — and for the printed session directory; it does not hold for the -out flag default in `testimony record -h` / `testimony demo -h`, which deliberately shows the resolved absolute path + evidence: internal/cli/cli.go:171 + evidence: internal/cli/cli.go:835 + evidence: internal/record/record.go:150 + evidence: internal/cli/cli_test.go:1382 +## Grounds + +- pursued: one fixed, discoverable default root means an occasional operator always knows where a session went; a wrong guess would show as operators routinely overriding it with -out to keep project-local capture diff --git a/.abcd/development/specs/closed/spc-2609150752365788-fixed-session-location.md b/.abcd/development/specs/closed/spc-2609150752365788-fixed-session-location.md new file mode 100644 index 0000000..3550ab8 --- /dev/null +++ b/.abcd/development/specs/closed/spc-2609150752365788-fixed-session-location.md @@ -0,0 +1,198 @@ +--- +id: spc-2609150752365788 +slug: fixed-session-location +intent: itd-10 +origin: researcher-authored +production_mode: hand-written +--- +# fixed-session-location + +## Summary + +The two capture commands — `record` and `demo` — stop defaulting their `-out` +root to the relative path `sessions`. When `-out` is omitted, both create the +new session under `~/Testimony/sessions`, one fixed root resolved against the +operator's home directory at the moment of the run, so a session lands in the +same place whatever directory the command was invoked from. The root is created +on demand, exactly as an explicit `-out` root already is. `-out DIR` is +unchanged: it wins wherever it is given, is used verbatim whether relative or +absolute, and the home directory is not consulted at all — `-out sessions` +reproduces the old behaviour byte for byte. With `-out` omitted and no home +directory to resolve, the command refuses at the usage status naming the flag +to pass, rather than falling back to a relative root. One helper in +`internal/cli` defines the default and both commands register its result as +their flag default, so `-h` shows the real directory a session will land in and +the two cannot drift. Every other command is untouched: they operate on a +session they are given or infer, and take no `-out` root at all. + +## Design + +### One definition of the default + +`internal/cli` gains `defaultSessionRoot() (string, error)` — +`os.UserHomeDir()` joined with `Testimony/sessions` — and the display constant +`defaultRootDisplay`, the `~/Testimony/sessions` spelling used in the usage +block, the documentation, and the refusal message. Both commands call the +helper before registering their flags and pass its result straight to +`fs.String("out", root, …)`, so the default is stated once and the two commands +cannot diverge in what it means. Nothing downstream changes: the root reaches +`session.Create` as the same opaque string an explicit `-out` always did, and +`session.Create`'s existing `os.MkdirAll(outRoot, 0o755)` creates it and any +missing parent on demand. + +Registering the *resolved* path as the flag default, rather than a `~` literal +expanded later, is what makes `testimony record -h` print +`(default "/…/Testimony/sessions")` — the real directory, which is the answer +to the question an operator opens `-h` to ask. The `~` form survives only where +a person reads prose: the usage block's `[-out ~/Testimony/sessions]`, the +reference tables, and the refusal. No expansion of `~` is performed anywhere on +a value the operator supplies; the shell does that, as it always has. + +### The refusal + +A home directory that cannot be resolved is refused, not papered over. The +check is scoped to the one path that needs a home: `fs.Visit` records whether +`-out` was given, and only when it was *not* given and the helper returned an +error does the command exit at the usage status: + +``` +record: -out is required (the default root ~/Testimony/sessions cannot be resolved: $HOME is not defined); pass -out DIR +``` + +`demo` refuses identically under its own name. The message names the root it +tried, carries the reason verbatim from `os.UserHomeDir`, and ends with the one +thing that gets the operator moving. A silent fall back to the relative +`sessions` would put the session beside whichever directory the operator +happened to be standing in — precisely the outcome this intent exists to end — +and would do it invisibly, on the one run where the operator has least reason +to suspect it. + +The refusal sits after `rejectArgs` and before the existing empty-`-out` guard, +which keeps the two messages from competing: `-out ""` is an explicitly given +flag, so it keeps reporting `-out must not be empty` whether or not a home +exists, and an omitted `-out` with no home reports the root it could not +resolve. Both go through `usageErr` (2), the status every wrong invocation on +these commands already uses; a root that resolves but cannot be *created* stays +a runtime failure (1) from `session.Create`, naming the path it tried. + +### What does not change + +The start-up output still prints the session directory `session.Create` +returned — the real, resolved path, which is what the operator opens, hands on, +and passes to `transcribe`. The `-out` flag's help string, its position in the +usage block, both commands' exit statuses, and every other flag are as they +were. No environment variable and no configuration file is read: `-out` remains +the sole override, which is the intent's own out-of-scope fence and keeps the +answer to "where did my session go?" a function of the command line alone. + +### Documentation + +`docs/reference/cli.md` gains one `Where a new session lands` section holding +the rule for both commands — the fixed default, the on-demand creation, `-out` +as the only override with `-out sessions` named as the project-local form, and +the refusal verbatim — and each capture command's usage line and flag table +points at it. The tutorial, the how-to guides, `README.md`, and +`docs/reference/session-directory.md` carry example session paths that were +written from the old relative default; each moves to the new one, so no page +shows a path the documented default no longer produces. + +## Decisions + +- **The fixed default is `~/Testimony/sessions`, the press release's own path, + chosen over an XDG-style `~/.local/share/testimony/sessions`.** Discoverability + is the whole point of the change. A captured session is evidence — a folder an + operator reopens weeks later, browses in a file manager, and hands to a + colleague — not application state, and a dotted or deeply nested directory + hides exactly the thing the intent set out to make findable. The convention + XDG encodes is a good one for caches and state; this is neither. +- **The root is resolved via `os.UserHomeDir()` at invocation time, never + cached and never expanded from a stored `~` literal.** Resolving per + invocation means the path follows the account actually running the command, + including a run under `sudo` or a different user, and there is no persisted + copy to go stale. +- **An unresolvable home directory refuses; it never silently falls back to a + relative path.** The fallback would be invisible and would recreate the + scattered-session failure this intent removes, on the one run where the + operator has least reason to look for it. The refusal is a usage-style error + (exit 2) naming `-out`, because the invocation is recoverable by adding + exactly one flag. +- **The refusal is scoped to an omitted `-out`.** An operator who names a root + never needed a home directory, so a machine with no resolvable home must + still be able to capture with `-out DIR`. The `fs.Visit` check is what keeps + the new requirement off that path. +- **`-out DIR` stays the only override — no environment variable, no + configuration file.** This is the intent's out-of-scope fence, and it is what + keeps the location of a session a property of the command that created it: + one place to look, and no invisible state that makes the same command put + sessions in different places on two machines. +- **The `-out` flag registers the resolved path, so `-h` shows it, while prose + keeps the `~` form.** The two audiences differ: `-h` is read to learn where + the session will actually go, and documentation is read by someone who is not + the operator whose home directory it would name. Committed text therefore + never carries an absolute local path. +- **This is a behaviour change and is recorded as one.** A single `### Changed` + bullet under `[Unreleased]` names the old relative `sessions/` default, the + new root, and the migration in one line — pass `-out sessions` to keep the old + behaviour — following the changelog's own rule that a change which can break + an existing invocation is called out in the entry that records it. + +## Test plan + +In `internal/cli/cli_test.go`, using the existing `Run`-plus-`captureStderr` +pattern, the `chdir` helper, and `t.Setenv("HOME", …)`: + +- **The default root** — `TestDefaultSessionRootIsUnderHome` calls the helper + with `HOME` pointed at a temp directory and the working directory at a + different one, and requires `/Testimony/sessions`. +- **`record` creates under it** — `TestRecordCreatesUnderTheDefaultRoot` points + `HOME` at a temp directory and plants a *regular file* at the exact default + root, making it un-creatable: bare `record` exits 1 naming that path, and the + working directory is left empty. Without `-demo`, `session.Create` is the + first thing `record` does, so the failure can come from nowhere else and no + recorder is ever spawned — which is also what keeps the test from blocking on + a machine that has ffmpeg. +- **An explicit `-out` is unchanged** — `TestExplicitOutRootIsUnchanged` plants + the same un-creatable marker at a relative `sessions` and requires + `record -out sessions` to name it, leave `/Testimony` uncreated, and + never mention the home directory. It then clears `HOME` and requires + `demo -addr bogus -out DIR`, `record -demo -addr bogus -out DIR`, + `demo -out ""`, and `record -out ""` each to reach their own existing check + rather than the default-root refusal. +- **The unresolvable-home refusal** — `TestUnresolvableHomeRefuses` clears + `HOME` and requires bare `demo` and bare `record` to exit 2 with + `: -out is required (the default root ~/Testimony/sessions cannot be + resolved: …); pass -out DIR`, with nothing written into the working directory. +- **The advertised surface** — `TestUsageShowsTheFixedDefaultRoot` pins + `[-out ~/Testimony/sessions]` on both capture lines and the footer sentence, + and fails if the old `[-out sessions]` is still advertised. + +`demo`'s own creation path is not exercised end to end: it binds its listener +before creating the session, and the suite's established way of reaching +`demo` without binding a real port is a refused `-addr`, which stops earlier +still. Its default is covered by the shared helper's test and by the refusal +that can only fire because `demo` registers that helper's result. + +All tests pass under `-race`; none runs in parallel, so the working-directory +and environment changes are safe. + +## How acceptance criteria are satisfied + +- **No `-out` → the session is created under the fixed default, not relative to + the current directory** — both commands register `defaultSessionRoot()`'s + result as the `-out` default, so the value reaching `session.Create` is + `/Testimony/sessions` and never depends on the working directory. + `TestRecordCreatesUnderTheDefaultRoot` pins the path `record` tries and that + the working directory stays empty; `TestDefaultSessionRootIsUnderHome` pins + the root itself. +- **An explicit `-out DIR` → created under `DIR` exactly as today** — the flag + default is the only thing that changed, so a given `-out` reaches + `session.Create` verbatim and the home directory is never read. + `TestExplicitOutRootIsUnchanged` proves the named root is the one used, that + the default root is left untouched, and that an unresolvable home does not + interfere with any explicit-`-out` invocation. +- **A default location that does not yet exist → created automatically** — + creation is `session.Create`'s existing `os.MkdirAll(outRoot, 0o755)`, which + is the same call an explicit `-out` root has always gone through, and it + creates the missing `Testimony` parent as well. The un-creatable-root tests + reach that call and report from it, which is what pins the default root as + the argument it receives. diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 9648359..5316cb7 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -1677,3 +1677,14 @@ Architecture-shaping decisions graduate to an ADR under The 3.x (v3) recorder is not installed, so v3 stays fixture-verified only. - 2026-09-15 — PR bodies carry the `Assisted-by:` trailer as their last line, matching the global attribution gate that refuses a body without it; the earlier "no footer in PR bodies" rule contradicted that gate and is replaced. Session links stay out of PR bodies and commits. - 2026-09-15 — itd-6 (terminal capture inside `record`) is superseded by itd-11: `import` delivers the same evidence from an operator-recorded cast without touching `record`'s lifecycle. Moved to `intents/superseded/` with a `superseded_by` pointer; abcd has no supersede verb, so the move is by hand (captured as a tooling observation). +- 2026-09-15 — `record` and `demo` default their `-out` root to the fixed + `~/Testimony/sessions` (itd-10), resolved via `os.UserHomeDir()` at + invocation time by one helper both commands register as their flag default, + in place of the relative `sessions/`. The press release's own path beats an + XDG-style `~/.local/share/testimony/sessions`: a session is evidence to open + and hand on, not application state, and a hidden directory hides it. `-out + DIR` stays the only override — no environment variable, no config file — and + an unresolvable home refuses at the usage status naming `-out` rather than + falling back to a relative root, which is the scattered-session outcome the + fixed default exists to end. A behaviour change, called out in the changelog + with `-out sessions` as the one-line migration. diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ec339..857b585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,20 @@ break an existing invocation is called out in the entry that records it. one, are unaffected. With neither an explicit flag nor a session manifest in the current directory, the usage error names both things that were checked. +### Changed + +- `record` and `demo` create a new session under `~/Testimony/sessions` when + `-out` is not given, in place of the relative `sessions/` root they used to + create beside whatever directory the command happened to be run from — so a + session lands in the same, findable place however the command was invoked. + The root is resolved against the home directory at invocation time and + created on demand exactly as an explicit `-out` root is; with no home + directory to resolve it, the command refuses at the usage status naming + `-out` rather than fall back to a relative root. **This changes an existing + invocation:** pass `-out sessions` to keep the old behaviour. `-out DIR` + remains the only override, and every other command is unaffected — they take + an explicit `-session DIR` or infer one from the current directory. + ### Fixed - `demo` and `record` refuse a capture post that misses an endpoint path diff --git a/README.md b/README.md index 2b12644..bb048c8 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ open examples/sample-session/report.md ``` Then capture a real one: `testimony record -demo` starts a capture session — -recording your voice and clicks in one command — and prints every step. Voice +recording your voice and clicks in one command — and prints every step. The +session lands in `~/Testimony/sessions`, the same place whatever directory you +run from; `-out DIR` puts it somewhere else. Voice and screen capture need macOS; elsewhere, `record` skips those streams and says so, and an external recording joins the session via `transcribe -audio`. The [getting-started tutorial](docs/tutorials/getting-started.md) walks the @@ -91,10 +93,10 @@ The demo app contains at least one intentional usability flaw. Find it by talkin ## Session directory -Each session is one folder of small, inspectable files: +Each session is one folder of small, inspectable files, created under `~/Testimony/sessions` unless `-out` names another root: ``` -sessions// +~/Testimony/sessions// manifest.json # app, participant, tasks, t0_epoch_ms (the shared clock anchor) audio.wav # 16 kHz mono ASR input, captured or converted (local only) audio.offset.json # audio→session offset for an external recording (local only) diff --git a/docs/how-to/analyse-a-session.md b/docs/how-to/analyse-a-session.md index 661f094..0876184 100644 --- a/docs/how-to/analyse-a-session.md +++ b/docs/how-to/analyse-a-session.md @@ -7,7 +7,7 @@ by hand. Testimony delegates the model work to an assistant of your choice — emits a request, you run it, and it validates the answer. Prerequisite: a session with a merged timeline. If you have only a transcript and -interactions, run `testimony merge -session sessions/` first. +interactions, run `testimony merge -session ~/Testimony/sessions/` first. The flow is four steps: **emit** the request, **run** it with your assistant, **ingest** the answer, and **review** the findings — then re-render the report. @@ -19,7 +19,7 @@ plus the session's timeline. Send it to stdout to read it, or to a file to hand off: ```sh -testimony analyze -session sessions/ -out request.txt +testimony analyze -session ~/Testimony/sessions/ -out request.txt ``` The request pins a rubric version (`testimony-analysis/v1`), asks for two passes @@ -50,7 +50,7 @@ accepted): Validate the answer against the findings schema and write `findings.jsonl`: ```sh -testimony analyze -session sessions/ -ingest answer.json +testimony analyze -session ~/Testimony/sessions/ -ingest answer.json ``` Ingest is the validation boundary, and it never trusts the model. It rejects, with @@ -64,7 +64,7 @@ whole answer is clean, so you can fix a batch in one pass. Every finding lands You can also pipe the answer straight in with `-ingest -`: ```sh -your-assistant < request.txt | testimony analyze -session sessions/ -ingest - +your-assistant < request.txt | testimony analyze -session ~/Testimony/sessions/ -ingest - ``` ## 4. Review the findings @@ -73,7 +73,7 @@ Each finding is a *candidate* until you judge it. `testimony review` walks the unverified findings and records your verdict: ```sh -testimony review -session sessions/ +testimony review -session ~/Testimony/sessions/ ``` For each finding it shows its id, type, severity, the clock, the participant's @@ -86,8 +86,8 @@ decided both survive. To record a single verdict without the interactive walk (handy in scripts): ```sh -testimony review -session sessions/ -finding F-001 -verdict confirmed -testimony review -session sessions/ -finding F-005 -verdict duplicate-of-F-001 +testimony review -session ~/Testimony/sessions/ -finding F-001 -verdict confirmed +testimony review -session ~/Testimony/sessions/ -finding F-005 -verdict duplicate-of-F-001 ``` Interactive review needs stdin to be a character device (an interactive @@ -99,14 +99,14 @@ prints a notice and exits without blocking. Rebuild `report.md` to see the findings grouped by verdict: ```sh -testimony report -session sessions/ -open sessions//report.md +testimony report -session ~/Testimony/sessions/ +open ~/Testimony/sessions//report.md ``` The Findings section lists findings under **Confirmed**, **Unverified**, **Duplicate**, and **Rejected**, each with its quote, anchor, and — where you recorded one — the verdict and its date. Change a verdict at any time with -`testimony review -session sessions/ -finding F-NNN -verdict ` +`testimony review -session ~/Testimony/sessions/ -finding F-NNN -verdict ` (the interactive walk offers only findings that are still unverified); the latest one wins, and the history is kept. diff --git a/docs/how-to/draft-regression-tests.md b/docs/how-to/draft-regression-tests.md index df9bb37..a6e75c4 100644 --- a/docs/how-to/draft-regression-tests.md +++ b/docs/how-to/draft-regression-tests.md @@ -25,7 +25,7 @@ own record plus its **event window** from the timeline. Send it to stdout to rea it, or to a file to hand off: ```sh -testimony draft-tests -session sessions/ -out request.md +testimony draft-tests -session ~/Testimony/sessions/ -out request.md ``` The event window is the only material the steps may be reconstructed from, so its @@ -39,7 +39,7 @@ utterance falls outside the window, and a draft made from it has nothing to ground "expected" in. ```sh -testimony draft-tests -session sessions/ -window 20 # a slower, more deliberate session +testimony draft-tests -session ~/Testimony/sessions/ -window 20 # a slower, more deliberate session ``` Nothing in the session directory changes. @@ -75,7 +75,7 @@ distinct sequences is two test cases. Validate the answer against the draft schema and write `tests.jsonl`: ```sh -testimony draft-tests -session sessions/ -ingest tests.json +testimony draft-tests -session ~/Testimony/sessions/ -ingest tests.json ``` Ingest is the validation boundary, and it never trusts the model. It rejects, @@ -95,7 +95,7 @@ mismatch is the cheapest signal that the draft was linked to the wrong finding. You can also pipe the answer straight in with `-ingest -`: ```sh -your-assistant < request.md | testimony draft-tests -session sessions/ -ingest - +your-assistant < request.md | testimony draft-tests -session ~/Testimony/sessions/ -ingest - ``` Once a decision exists in `tests.jsonl`, ingest refuses to overwrite the file: @@ -108,7 +108,7 @@ Each draft is a *proposal* until you judge it. `testimony review -kind tests` walks the proposed drafts and records your decision: ```sh -testimony review -session sessions/ -kind tests +testimony review -session ~/Testimony/sessions/ -kind tests ``` For each draft it shows its id, the source finding with its type, severity and @@ -126,9 +126,9 @@ finding or session. To record a single decision without the interactive walk (handy in scripts): ```sh -testimony review -session sessions/ -kind tests -test T-001 -decision accepted -testimony review -session sessions/ -kind tests -test T-003 -decision rejected -testimony review -session sessions/ -kind tests -test T-002 -decision edited -edit edit.json +testimony review -session ~/Testimony/sessions/ -kind tests -test T-001 -decision accepted +testimony review -session ~/Testimony/sessions/ -kind tests -test T-003 -decision rejected +testimony review -session ~/Testimony/sessions/ -kind tests -test T-002 -decision edited -edit edit.json ``` `-edit FILE` (or `-edit -` for stdin) holds the replacement fields as a JSON @@ -149,7 +149,7 @@ both are kept. Render the accepted drafts as Markdown test-case blocks: ```sh -testimony draft-tests -session sessions/ -render -out tests.md +testimony draft-tests -session ~/Testimony/sessions/ -render -out tests.md ``` Each block names its source finding and session, the decision and its date, the diff --git a/docs/how-to/instrument-your-own-app.md b/docs/how-to/instrument-your-own-app.md index 965ddc2..59beb86 100644 --- a/docs/how-to/instrument-your-own-app.md +++ b/docs/how-to/instrument-your-own-app.md @@ -151,9 +151,9 @@ The capture script then posts to relative URLs, exactly as in the snippets above Record your voice, think aloud, then stop both recorders and run: ```sh -testimony transcribe -session sessions/ -audio -testimony merge -session sessions/ -testimony report -session sessions/ +testimony transcribe -session ~/Testimony/sessions/ -audio +testimony merge -session ~/Testimony/sessions/ +testimony report -session ~/Testimony/sessions/ ``` The report anchors each utterance to your app's `data-testid` selectors. See the [session directory reference](../reference/session-directory.md) for the exact file schemas. diff --git a/docs/how-to/record-a-terminal-session.md b/docs/how-to/record-a-terminal-session.md index f12c0e6..bab7fc4 100644 --- a/docs/how-to/record-a-terminal-session.md +++ b/docs/how-to/record-a-terminal-session.md @@ -41,15 +41,15 @@ You need two terminal windows: one for Testimony, one for the work. ## Import the cast ```sh -testimony import -session sessions/ -cast session.cast +testimony import -session ~/Testimony/sessions/ -cast session.cast ``` `import` copies the cast into the session as `terminal.cast`, normalises its output into `interactions.jsonl`, and prints the clock offset it used and its provenance. Then finish the pipeline as usual: ```sh -testimony transcribe -session sessions/ -testimony merge -session sessions/ -testimony report -session sessions/ +testimony transcribe -session ~/Testimony/sessions/ +testimony merge -session ~/Testimony/sessions/ +testimony report -session ~/Testimony/sessions/ ``` Each line the terminal displayed becomes one interaction record, which the report renders beside the utterance it falls next to: @@ -107,7 +107,7 @@ Three provenance forms appear: If the report shows terminal output clearly misaligned with the speech, correct it from the spoken marker exactly as [fix a wrong clock offset](transcribe-a-recording.md#fix-a-wrong-clock-offset) describes for audio, then re-import. `-cast` is not needed the second time — the session already holds `terminal.cast`: ```sh -testimony import -session sessions/ -offset -12.4 +testimony import -session ~/Testimony/sessions/ -offset -12.4 ``` Re-run `testimony merge` and `testimony report` afterwards to rebuild the timeline and the report. diff --git a/docs/how-to/transcribe-a-recording.md b/docs/how-to/transcribe-a-recording.md index 503c2af..16c4d11 100644 --- a/docs/how-to/transcribe-a-recording.md +++ b/docs/how-to/transcribe-a-recording.md @@ -7,7 +7,7 @@ This guide covers the common `testimony transcribe` tasks: transcribing the sess A session captured with `testimony record` already holds its voice recording as `audio.wav`, so name the session and nothing else: ```sh -testimony transcribe -session sessions/ +testimony transcribe -session ~/Testimony/sessions/ ``` The command reads that `audio.wav` in place: no conversion runs, and ffmpeg is not needed. @@ -17,7 +17,7 @@ The command reads that `audio.wav` in place: no conversion runs, and ffmpeg is n When the voice was recorded separately — a `testimony demo` session with QuickTime Player alongside it, say — pass the file with `-audio`: ```sh -testimony transcribe -session sessions/ -audio recording.m4a +testimony transcribe -session ~/Testimony/sessions/ -audio recording.m4a ``` This variant needs ffmpeg on your PATH (`brew install ffmpeg`). It accepts `.m4a`, `.mov`, and `.wav` recordings, converts the file into a 16 kHz mono `audio.wav` in the session directory, and persists the clock offset it works out beside it in `audio.offset.json`, so a later bare re-run keeps the same alignment. @@ -33,7 +33,7 @@ By default (`-engine auto`) Testimony prefers WhisperX and falls back to whisper ```sh uv tool install whisperx # or: pipx install whisperx -testimony transcribe -session sessions/ -engine whisperx +testimony transcribe -session ~/Testimony/sessions/ -engine whisperx ``` WhisperX produces word-level timestamps, which make the utterance-to-event join in reports precise. `-model` names a Whisper model; the default is `large-v3-turbo`. @@ -43,7 +43,7 @@ WhisperX produces word-level timestamps, which make the utterance-to-event join ```sh brew install whisper-cpp -testimony transcribe -session sessions/ -engine whispercpp +testimony transcribe -session ~/Testimony/sessions/ -engine whispercpp ``` whisper.cpp needs a ggml model file. `-model` accepts either: @@ -58,7 +58,7 @@ whisper.cpp needs a ggml model file. `-model` accepts either: - **A file path** to an existing ggml model, used as-is: ```sh - testimony transcribe -session sessions/ \ + testimony transcribe -session ~/Testimony/sessions/ \ -engine whispercpp -model ~/models/ggml-base.en.bin ``` @@ -67,7 +67,7 @@ whisper.cpp needs a ggml model file. `-model` accepts either: Pass the spoken language code: ```sh -testimony transcribe -session sessions/ -language de +testimony transcribe -session ~/Testimony/sessions/ -language de ``` The default is `en`. @@ -83,7 +83,7 @@ These three flags apply to WhisperX only. Example, forcing CPU with int8: ```sh -testimony transcribe -session sessions/ \ +testimony transcribe -session ~/Testimony/sessions/ \ -device cpu -compute_type int8 ``` @@ -102,7 +102,7 @@ If the report shows speech clearly misaligned with events, correct the offset us 3. Re-run with the explicit value (an explicit `-offset` always wins over derivation). The bare form works whichever way the audio arrived, because the session already holds `audio.wav`: ```sh - testimony transcribe -session sessions/ -offset -12.4 + testimony transcribe -session ~/Testimony/sessions/ -offset -12.4 ``` On a session whose audio came from an external recording, this rewrites the offset in `audio.offset.json`, so later bare re-runs keep the correction. On a `record` session there is no sidecar and the value applies to this run. Naming the recording again — `-audio recording.m4a -offset -12.4` — works too, at the cost of converting it a second time. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 61df47e..5ec0608 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,20 +34,34 @@ report: -session is required (no -session flag, and the current directory holds Resolution is the last of a command's invocation checks, so a run refused for any other flag reports only that flag and announces no session. -`record` and `demo` are unaffected: they create sessions rather than operate on an existing one, and their `-out` root is a different flag with a different meaning. +`record` and `demo` are unaffected: they create sessions rather than operate on an existing one, and their `-out` root is a different flag with a different meaning — see [where a new session lands](#where-a-new-session-lands). + +## Where a new session lands + +The two capture commands — `record` and `demo` — create a session under the `-out` root. That root defaults to `~/Testimony/sessions`, a fixed location resolved against the home directory at the moment of the run, so every session lands in the same place whatever directory the command was run from. The root, and any missing parent of it, is created on demand. + +`-out DIR` overrides it per invocation and is used verbatim, relative or absolute: `-out sessions` keeps a project-local `sessions/` beside the working directory. It is the only override — there is no environment variable and no configuration file. + +When `-out` is omitted and the home directory cannot be resolved, the command refuses at status 2 rather than fall back to a relative root, since a session written beside whichever directory the operator was standing in is exactly what the fixed default exists to prevent: + +``` +record: -out is required (the default root ~/Testimony/sessions cannot be resolved: $HOME is not defined); pass -out DIR +``` + +Both commands print the session directory they created, as the real path, in their start-up output. ## `testimony demo` Serves the instrumented demo app and captures a session. ``` -testimony demo [-addr :8737] [-out sessions] +testimony demo [-addr :8737] [-out ~/Testimony/sessions] ``` | Flag | Default | Meaning | |---|---|---| | `-addr` | `:8737` | listen address (a bare `:port` binds loopback `127.0.0.1` only) | -| `-out` | `sessions` | root directory for new session folders | +| `-out` | `~/Testimony/sessions` | root directory for new session folders, created on demand (see [where a new session lands](#where-a-new-session-lands)) | Behaviour: creates a new session directory named after the current time (`YYYY-MM-DD_HHMMSS`) under the `-out` root, writes `manifest.json` (app `testimony demo`, participant `P1`, one seeded default task, `t0_epoch_ms` set to now), serves the demo page at `/`, and appends captured events via two endpoints: @@ -166,13 +180,13 @@ Behaviour: reads `manifest.json` (required) and `timeline.jsonl`, plus `findings Managed capture: creates the session directory and manifest, starts the recorders, and runs until interrupted. ``` -testimony record [-out sessions] [-app NAME] [-participant P1] [-commit HASH] +testimony record [-out ~/Testimony/sessions] [-app NAME] [-participant P1] [-commit HASH] [-task ...] [-video|-no-video] [-demo [-addr :8737]] ``` | Flag | Default | Meaning | |---|---|---| -| `-out` | `sessions` | root directory for new session folders | +| `-out` | `~/Testimony/sessions` | root directory for new session folders, created on demand (see [where a new session lands](#where-a-new-session-lands)) | | `-app` | *(empty)* | application under test; with `-demo`, defaults to the demo app | | `-participant` | `P1` | participant pseudonym | | `-commit` | *(empty)* | build/commit hash under test | @@ -252,8 +266,8 @@ Render behaviour: writes one Markdown test-case block per draft whose effective **Loud staging.** Two states are refused at exit 1 — a well-formed invocation whose work cannot be done — with the counts by status and nothing written: ``` -testimony: no confirmed findings to draft tests from (5 findings: 0 confirmed, 2 unverified, 1 duplicate, 2 rejected); confirm one with `testimony review -session sessions/x` first -testimony: no accepted test drafts to render (3 drafts: 0 accepted, 0 edited, 2 proposed, 1 rejected); accept one with `testimony review -session sessions/x -kind tests` first +testimony: no confirmed findings to draft tests from (5 findings: 0 confirmed, 2 unverified, 1 duplicate, 2 rejected); confirm one with `testimony review -session ~/Testimony/sessions/x` first +testimony: no accepted test drafts to render (3 drafts: 0 accepted, 0 edited, 2 proposed, 1 rejected); accept one with `testimony review -session ~/Testimony/sessions/x -kind tests` first ``` The first applies to emit and to ingest, and on ingest it fires before a byte of the answer is read: with no eligible finding, every draft in the answer would fail the same rule. The second keeps `-out FILE` from truncating an existing test plan into an empty document. diff --git a/docs/reference/session-directory.md b/docs/reference/session-directory.md index 4c5d91d..a6a859a 100644 --- a/docs/reference/session-directory.md +++ b/docs/reference/session-directory.md @@ -1,9 +1,9 @@ # Session directory reference -Every capture session lives in one directory (by default under `sessions/`): +Every capture session lives in one directory. `record` and `demo` create it under `~/Testimony/sessions`, a fixed root whatever directory they are run from, unless `-out` names another one (see [where a new session lands](cli.md#where-a-new-session-lands)): ``` -sessions// +~/Testimony/sessions// manifest.json # session metadata, including t0_epoch_ms (written by demo and record) audio.wav # 16 kHz mono ASR input (captured by record, or converted by transcribe -audio; local only) audio.offset.json # audio→session offset for an external recording (written by transcribe; local only) diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index c5ce23a..584ce13 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -51,7 +51,9 @@ testimony version testimony record -demo ``` -One command creates a fresh session directory (for example `sessions/2026-07-17_174858`), starts recording your microphone into that directory, serves a small instrumented settings app, and prints the URL to open — the exact commands for the next steps print once you stop the session (step 5). The first run asks for **Microphone** permission — grant it in System Settings and run the command again. Keep this terminal open: it records your voice and captures your clicks for the whole session. +One command creates a fresh session directory (for example `~/Testimony/sessions/2026-07-17_174858`), starts recording your microphone into that directory, serves a small instrumented settings app, and prints the URL to open — the exact commands for the next steps print once you stop the session (step 5). The first run asks for **Microphone** permission — grant it in System Settings and run the command again. Keep this terminal open: it records your voice and captures your clicks for the whole session. + +Every session lands under `~/Testimony/sessions`, the same place whatever directory you run the command from, so you can always find one again; `-out DIR` puts it somewhere else. The exact path prints at the top of the output — the steps below use the example one. > If you would rather not capture the microphone, run `testimony demo` instead, record your voice separately in QuickTime Player, save the file, and pass it to `transcribe` in step 6 with `-audio ~/Desktop/session.m4a`. Everything else is the same. @@ -74,7 +76,7 @@ In the terminal, press `Ctrl+C`. The recorder finalises `audio.wav`, the capture Point `transcribe` at the session directory — no audio file to name, because the recording is already in the session as `audio.wav`: ```sh -testimony transcribe -session sessions/2026-07-17_174858 +testimony transcribe -session ~/Testimony/sessions/2026-07-17_174858 ``` This runs speech recognition locally on your machine — using the WhisperX engine you installed in step 1 — and writes `transcript.jsonl` into the session directory. It also prints the clock offset it uses to align the recording with the session — note it, and see [how alignment works](../explanation/how-alignment-works.md) if it ever looks wrong. @@ -82,7 +84,7 @@ This runs speech recognition locally on your machine — using the WhisperX engi ## 7. Merge speech and clicks ```sh -testimony merge -session sessions/2026-07-17_174858 +testimony merge -session ~/Testimony/sessions/2026-07-17_174858 ``` This interleaves the transcript with the captured interactions into a single `timeline.jsonl`. @@ -90,8 +92,8 @@ This interleaves the transcript with the captured interactions into a single `ti ## 8. Generate and read the report ```sh -testimony report -session sessions/2026-07-17_174858 -open sessions/2026-07-17_174858/report.md +testimony report -session ~/Testimony/sessions/2026-07-17_174858 +open ~/Testimony/sessions/2026-07-17_174858/report.md ``` The report pairs each utterance with the interface events around it: diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 48dc716..ada88cf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -29,9 +29,9 @@ var Version = "dev" const usage = `testimony — usability evidence, on the record Usage: - testimony record [-out sessions] [-app NAME] [-participant P1] [-task ...] managed capture: session dir + manifest, start recorders, run until Ctrl+C + testimony record [-out ~/Testimony/sessions] [-app NAME] [-participant P1] [-task ...] managed capture: session dir + manifest, start recorders, run until Ctrl+C [-commit HASH] [-video|-no-video] [-demo [-addr :8737]] - testimony demo [-addr :8737] [-out sessions] serve the instrumented demo app, capture a session + testimony demo [-addr :8737] [-out ~/Testimony/sessions] serve the instrumented demo app, capture a session testimony transcribe [-session DIR] [-audio FILE] transcribe a voice recording into transcript.jsonl (reuses the session's audio.wav when -audio is omitted) [-engine auto|whisperx|whispercpp] [-model large-v3-turbo] [-language en] [-offset SECONDS] [-device auto|cpu|cuda] [-compute_type auto|int8|float16|…] [-vad auto|silero|pyannote] (whisperx only) @@ -56,6 +56,9 @@ Omitting -session on transcribe, import, merge, report, analyze, draft-tests, or review uses the current directory when it holds a Testimony session manifest.json (one with a session field), and names the inferred session on stderr. +record and demo create a new session under ~/Testimony/sessions unless -out +names another root, so a session lands in the same place whatever directory the +command was run from. ` // Run executes the CLI and returns a process exit code. @@ -70,11 +73,24 @@ func Run(args []string) int { case "demo": fs := flag.NewFlagSet("demo", flag.ExitOnError) addr := fs.String("addr", ":8737", "listen address") - out := fs.String("out", "sessions", "root directory for new session folders") + root, rootErr := defaultSessionRoot() + out := fs.String("out", root, "root directory for new session folders") fs.Parse(rest) if err := rejectArgs(fs); err != nil { return usageErr(err) } + outSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "out" { + outSet = true + } + }) + // The default root is the only thing a home directory is needed for, so + // an unresolvable one is refused here and nowhere else: an explicit -out + // runs exactly as it always has. + if !outSet && rootErr != nil { + return usageErr(fmt.Errorf("demo: %w", unresolvedRootErr(rootErr))) + } // An empty -out is a wrong invocation (an unset shell variable spliced // into the flag, say), not a valid root: every other validated flag on // this path exits 2 naming itself, but an empty -out previously reached @@ -151,7 +167,8 @@ func Run(args []string) int { case "record": fs := flag.NewFlagSet("record", flag.ExitOnError) - out := fs.String("out", "sessions", "root directory for new session folders") + root, rootErr := defaultSessionRoot() + out := fs.String("out", root, "root directory for new session folders") app := fs.String("app", "", "application under test") participant := fs.String("participant", "P1", "participant pseudonym") commit := fs.String("commit", "", "build/commit hash under test") @@ -165,6 +182,16 @@ func Run(args []string) int { if err := rejectArgs(fs); err != nil { return usageErr(err) } + outSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "out" { + outSet = true + } + }) + // See demo's identical check above. + if !outSet && rootErr != nil { + return usageErr(fmt.Errorf("record: %w", unresolvedRootErr(rootErr))) + } // See demo's identical check above: an empty -out is a wrong invocation, // not a valid root, and must exit 2 naming the flag rather than surface // os.MkdirAll's bare "mkdir : no such file or directory" at exit 1. @@ -802,6 +829,46 @@ func rejectArgs(fs *flag.FlagSet) error { return nil } +// defaultRootDisplay is the default session root as an operator writes it — +// the form the usage block, the documentation, and every message about it use, +// rather than the expanded path of whoever happens to be running the command. +const defaultRootDisplay = "~/Testimony/sessions" + +// defaultSessionRoot returns the root under which record and demo create a new +// session when -out is not given. It is the single definition of that default: +// both commands register the value it returns as their -out default, so `-h` +// shows the real directory a session will land in and the two cannot drift. +// +// The root is fixed rather than relative to the working directory because a +// session is evidence an operator comes back to, and a relative "sessions" +// scattered one session per directory the command happened to be run from — +// findable later only by remembering where you stood. ~/Testimony/sessions is +// preferred to an XDG-style ~/.local/share/testimony/sessions for the same +// reason: captured sessions are documents to open, browse, and hand on, not +// application state, and a hidden directory hides them. +// +// The home directory is resolved at invocation time, never cached, and a home +// that cannot be resolved is reported rather than papered over: silently +// falling back to a relative root would put the session in whatever directory +// the operator was standing in, which is the outcome the fixed default exists +// to end. +func defaultSessionRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Testimony", "sessions"), nil +} + +// unresolvedRootErr phrases the refusal both capture commands give when -out is +// omitted and the default root cannot be resolved. It names the root, carries +// the reason, and tells the operator the one thing that gets them moving — +// exactly one flag — rather than leaving them to discover it in the usage block. +func unresolvedRootErr(err error) error { + return fmt.Errorf("-out is required (the default root %s cannot be resolved: %w); pass -out DIR", + defaultRootDisplay, err) +} + // resolveSession returns the session directory a pipeline command operates on: // the explicit -session flag when it is given, otherwise the current directory // when that directory itself holds a manifest.json. It is the single resolution diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4c50d7d..7fe8c25 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1240,3 +1240,156 @@ func TestUsageListsImport(t *testing.T) { t.Error("usage text offers a -terminal flag; terminal capture is a hand-off, not a record mode") } } + +// TestDefaultSessionRootIsUnderHome pins the fixed default root itself: the one +// helper both capture commands register as their -out default resolves +// ~/Testimony/sessions against the home directory in force at invocation time, +// never against the working directory. +func TestDefaultSessionRootIsUnderHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chdir(t, t.TempDir()) + + got, err := defaultSessionRoot() + if err != nil { + t.Fatalf("defaultSessionRoot: %v", err) + } + if want := filepath.Join(home, "Testimony", "sessions"); got != want { + t.Errorf("default session root = %q, want %q", got, want) + } +} + +// TestRecordCreatesUnderTheDefaultRoot is the intent's first criterion for +// `record`: with no -out, the session is created under the fixed default root, +// not under the directory the command was run from. The root is made +// un-creatable (a regular file sits at the exact path) so the attempt names the +// path it tried and nothing is spawned; session.Create is the first thing +// record does without -demo, so the failure cannot come from anywhere else. +func TestRecordCreatesUnderTheDefaultRoot(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cwd := t.TempDir() + chdir(t, cwd) + + root := filepath.Join(home, "Testimony", "sessions") + if err := os.MkdirAll(filepath.Dir(root), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(root, []byte("not a directory"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + var code int + stderr := captureStderr(t, func() { code = Run([]string{"record"}) }) + if code != 1 { + t.Errorf("record with an un-creatable default root: exit %d, want 1 (runtime error)", code) + } + if !strings.Contains(stderr, root) { + t.Errorf("record did not try the default root: want %q on stderr, got %q", root, stderr) + } + // The old relative default must be gone: nothing is created beside the + // operator, wherever they were standing. + if entries, err := os.ReadDir(cwd); err != nil || len(entries) != 0 { + t.Errorf("record wrote into the working directory (entries=%d, err=%v)", len(entries), err) + } +} + +// TestExplicitOutRootIsUnchanged is the intent's second criterion: an explicit +// -out is used exactly as it always was, including a relative one, and the home +// directory is not consulted at all — so an operator who wants project-local +// capture keeps it, and an unresolvable home cannot refuse a run that never +// needed the default. +func TestExplicitOutRootIsUnchanged(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cwd := t.TempDir() + chdir(t, cwd) + + // The named root is made un-creatable so the run stops at session.Create, + // before a recorder is spawned or the command blocks on one; what it names + // is the root it used. + if err := os.WriteFile(filepath.Join(cwd, "sessions"), []byte("not a directory"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + var code int + stderr := captureStderr(t, func() { code = Run([]string{"record", "-out", "sessions"}) }) + if code != 1 { + t.Errorf("record -out sessions (un-creatable): exit %d, want 1 (runtime error)", code) + } + if !strings.Contains(stderr, "sessions") || strings.Contains(stderr, home) { + t.Errorf("record -out sessions did not use the named root verbatim, got %q", stderr) + } + if _, err := os.Stat(filepath.Join(home, "Testimony")); !os.IsNotExist(err) { + t.Errorf("record -out sessions touched the default root (err=%v)", err) + } + + // With no home to resolve, an explicit -out still reaches the command's own + // checks rather than the default-root refusal. + t.Setenv("HOME", "") + for _, c := range []struct { + args []string + want string + }{ + {[]string{"demo", "-addr", "bogus", "-out", t.TempDir()}, `demo: invalid capture address "bogus"`}, + {[]string{"record", "-demo", "-addr", "bogus", "-out", t.TempDir()}, `record: invalid capture address "bogus"`}, + {[]string{"demo", "-out", ""}, `demo: -out must not be empty`}, + {[]string{"record", "-out", ""}, `record: -out must not be empty`}, + } { + var code int + got := captureStderr(t, func() { code = Run(c.args) }) + if code != 2 { + t.Errorf("%v: exit %d, want 2 (usage error)", c.args, code) + } + if !strings.Contains(got, c.want) { + t.Errorf("%v: want %q on stderr, got %q", c.args, c.want, got) + } + } +} + +// TestUnresolvableHomeRefuses pins the refusal: with no -out and no home +// directory to resolve the default root against, both capture commands exit at +// the usage status naming the root, the reason, and the flag that gets the +// operator moving. They must never fall back to a relative root — that is the +// scattered-session outcome the fixed default exists to end — and must refuse +// before creating or binding anything. +func TestUnresolvableHomeRefuses(t *testing.T) { + t.Setenv("HOME", "") + cwd := t.TempDir() + chdir(t, cwd) + + for _, cmd := range []string{"demo", "record"} { + var code int + stderr := captureStderr(t, func() { code = Run([]string{cmd}) }) + if code != 2 { + t.Errorf("%s with no home: exit %d, want 2 (usage error)", cmd, code) + } + want := cmd + ": -out is required (the default root ~/Testimony/sessions cannot be resolved: " + if !strings.Contains(stderr, want) { + t.Errorf("%s with no home: want %q on stderr, got %q", cmd, want, stderr) + } + if !strings.Contains(stderr, "); pass -out DIR") { + t.Errorf("%s with no home: the refusal does not name the flag to pass, got %q", cmd, stderr) + } + } + if entries, err := os.ReadDir(cwd); err != nil || len(entries) != 0 { + t.Errorf("a refused capture wrote into the working directory (entries=%d, err=%v)", len(entries), err) + } +} + +// TestUsageShowsTheFixedDefaultRoot pins the surface: both capture commands +// advertise the fixed root in the usage block, the footer states the rule, and +// the old relative default is gone from it. +func TestUsageShowsTheFixedDefaultRoot(t *testing.T) { + for _, want := range []string{ + "testimony record [-out ~/Testimony/sessions]", + "testimony demo [-addr :8737] [-out ~/Testimony/sessions]", + "record and demo create a new session under ~/Testimony/sessions unless -out", + } { + if !strings.Contains(usage, want) { + t.Errorf("usage text does not mention %q", want) + } + } + if strings.Contains(usage, "[-out sessions]") { + t.Error("usage text still advertises the old relative sessions/ default") + } +}