Running an operation
Driving an operation interactively: sidecar mode, the action bar, verification, sessions and resume.
Sidecar mode (default)
Sidecar is the default run mode (-m sidecar). Samaritan acts as your copilot: it displays each step's resolved command, you run it yourself in your own terminal, and samaritan validates step.expect only when you press [v]. No commands are ever sent automatically.
# Default — sidecar mode
samaritan run deployment.yaml --env staging
# Attach to an existing tmux pane so [v] can read its output
samaritan run deployment.yaml --env staging --attach mysession:0.0
# Mid-flight: press [t] at any step to attach a panePressing [t] fires immediately (no Enter needed) and shows a numbered picker of all existing tmux panes — pick one by number, or type a raw target (mysession:0.0, %3). Samaritan's own pane is marked (this pane — samaritan):
Available tmux panes:
1) work:0.0 zsh
2) work:0.1 node (this pane — samaritan)
Select pane [number or target, Enter to cancel]:How sidecar attach works:
| Scenario | What happens |
|---|---|
sessions: defined in YAML | Samaritan bootstraps its own tmux session; prints Attach with: tmux attach -t samaritan-<id> |
--attach <target> flag | Samaritan attaches a pipe-pane capture to your existing pane without touching your session |
No sessions, no --attach | Prompt-only mode; [v] will indicate that you need [t] to attach a pane first |
Caveats:
--attachmode uses a single capture target for all steps. It takes precedence oversessions:: the named sessions are not spawned, per-stepsession:routing is ignored, and every step's[v]/[p]uses the attached pane. Samaritan prints a one-line notice when the operation declaressessions:. Press[t]to point a step at a different pane (the new pane is used from then on).resumekeeps the same precedence: it re-attaches the saved pane and does not spawnsessions:. To get one pane per named session, run without--attachand samaritan spawns them.- tmux allows one
pipe-paneper pane, so attaching replaces any existing pipe on it. Run one samaritan per pane. If a second run attaches to the same pane anyway:- it warns at attach (
This pane was captured by samaritan session <id> (pid N)) - the first run notices at its next step or
[v]/[e], printsCapture pipe on … was taken over by …, and re-attaches - a verify whose pipe missed part of the step reads the pane scrollback instead of reporting
missing:for output that is on screen: from the line[p]pasted the command on, or, when the command was typed rather than pasted, from its last echo (which may include an earlier attempt) - a run only closes the pipe on exit while it still owns it, so one run exiting never cuts the other's capture
- it warns at attach (
[v]with nothing captured since the step began prints a hint to run the command in the pane first.- After
[p],[v]judges only output from that paste on, so output left by an earlier attempt of the same step (a retry, or a resumed run from another day) cannot trip a check such asnot_contains. A new step or a[t]pane switch starts without a paste. samaritannever kills your session on exit — only the temporary capture pipe and temp file are cleaned up.
Step display in sidecar:
type: automaticsteps: Command is displayed prominently; thecommand_displayedevent is written to the audit log (notcommand_sent). The report renders it as**Command (run by operator)**.type: manualsteps: Same prompt loop as always.- Each step is resolved with its
variants.<env>overrides before it is shown, so the command, instruction,[p]paste and[v]verify matchgenerate manual --env <env>exactly. - Script-only steps (
script:with no inlinecommand): the run loop showsScript: <path>, embeds the script file's content, and displays thebash <path>invocation to run.[c] copyand[p] send to paneoperate on thatbash <path>invocation. - Both types offer
[v] verify(whenexpectis defined),[t] attach pane, and[p] send to pane(when the step has a command or ascriptand a pane is attached).
Execution flow (spawn-own sessions)
samaritan run deployment-with-sessions.yaml --env production
1. Creates a tmux session (samaritan-<id>)
2. Bootstraps one window per named session (local or SSH)
3. Starts pipe-pane background capture on every pane
4. Launches the interactive TUI in the current terminal
5. Writes all events to /tmp/samaritan-<id>.jsonlIn iTerm2, panes open as native vertical splits automatically. In any other terminal with tmux, panes are split inside the current window.
Interactive prompts
At each step, SAMARITAN pauses and shows the step details, then prompts based on step type.
Each step heading uses the same number as the single-environment manual (generate manual --env <name>), followed by the step's position in the run. Below the heading you get the parent section (for a sub-step) and what comes next:
[Step 10.2 · 34/46] MANUAL: review-upload-and-record-backup
Section : Step 10: upload-and-backup
Next : Step 10.3: verify-backup (12 remaining)Step 10.2is the number ingenerate manual --env <name>: sub-steps are dotted (10.1,10.2,10.2.1). It is not the letter scheme (10a,10b1) that the multi-environment manual uses.34/46is the position in the run's flat step list (parents and sub-steps all count). Use this number with[b]back /[j]jump and--from-step.remainingcounts the steps still to visit. Steps thatwhen:excludes for this environment are not counted.
| Step type / mode | Prompt | Keys |
|---|---|---|
automatic in sidecar | Command — [p] pastes it into <pane>: (pane attached) or Run this command in your terminal ([t] attaches a pane for [p]/[v]): | Bar: y=run it (after p), Enter=done, p=paste, v=verify, t=attach (no pane yet), s=skip, r=rollback, ?=more. Under ?: c=copy, n=note, e=evidence, x=remove evidence, t=attach another pane, b=back, j=jump, g=global rollback, q/abort=quit |
automatic (tmux-backed, non-sidecar) | ▶ Send to tmux? | Enter=send, s=skip, r=rollback, g=global rollback, q=quit |
automatic (prompt-only, non-sidecar) | ▶ Execute? | Enter=confirm, s=skip, r=rollback, g=global rollback, q=quit |
manual | ✋ Mark done | Same compact bar: Enter/notes=confirm, v=verify, p/y/t (sidecar), s=skip, r=rollback, ?=more (n=note, e=evidence, b=back, j=jump, g=global rollback, q/abort=quit) |
approval | ⚡ approve/reject | approve, reject, r=rollback, g=global rollback, skip |
The step bar is compact. It shows the keys of the usual flow ([p] → [y] → ↵, [v]), plus [s], [r] and [?] more. [t] is shown while no pane is attached. ? prints the rest once (copy, note, evidence, back, jump, global rollback, abort…). Every key works whether it is shown or not, so n, e, b and j need no ? first.
g=global rollback is only offered when the operation declares a top-level rollback: block. It runs the consolidated recovery (see Group per-step rollbacks into the global rollback) and then aborts.
After the last step, when anything can be rolled back, the run shows All steps done with ↵ finish · r roll back a step · g global rollback, so finishing the final step never takes rollback away.
The global-rollback confirmation asks [y/N]. On a TTY it is answered with a single key, without Enter.
Two guards keep a stray Enter from completing a step on a TTY:
- Keys typed after an action key in one burst are discarded. Typing
v⏎quickly runs[v]only; the Enter does not mark the step done. - A second
↵within 400 ms of one that was acted on, with no other key in between, is ignored, with↩ Enter ignored: pressed twice within 400 ms. A double press on the step you just finished never completes the step that opened next. Press↵again to confirm.
Before each step, samaritan prints Expected: <criteria> up front (e.g. Expected: contains: Running, does not contain: CrashLoopBackOff) so you know what [v] will check before you run anything.
Verify output: checklist, highlighting, and the line-number gutter
Pressing [v] runs every check in step.expect (no short-circuit on the first failure) and renders a PASS/FAIL header, a per-check checklist, and the captured output — on both pass and fail:
✅ PASS
✅ contains: Running
✅ does not contain: CrashLoopBackOff
✅ does not contain: Error
✅ at least 1 line(s) (2 lines, expected ≥ 1)
╭─ output (tail) ────────────────────────────────────────╮
│ 1 │ NAME READY STATUS RESTARTS AGE │
│ 2 →│ web-server-0 1/1 Running 0 45s │
╰───────────────────────────────────────────────────────╯- Checklist — one line per check (
✅/❌), in the same order asExpected:. Numeric/count checks show their computed value inline, e.g.found "2", need ≥ 3or2 lines, expected ≥ 1. - Highlighted output — the captured output (tail of the last 12 lines by default, or full output after
[m]) is shown with:- green + inverse around the text that satisfied a passing
contains/any_line_contains/matches/any_line_matchescheck - red around the offending text that violated a failing
not_contains/no_line_contains/no_line_matchescheck missing: <expected>for failing checks whose expected text isn't present in the output at all
- green + inverse around the text that satisfied a passing
- Line-number gutter — each line of the output block is prefixed with its absolute line number (accounting for tail truncation) and a
→arrow on any line containing a highlight, e.g.2 →│ web-server-0 1/1 Running 0 45s. This makes it easy to see exactly which line satisfied (or violated) a check.
On FAIL, a single-key menu follows:
❌ FAIL
❌ contains: Running
❌ does not contain: CrashLoopBackOff
╭─ output (tail) ──────────────────────────────────────────────╮
│ 1 │ NAME READY STATUS RESTARTS AGE │
│ 2 →│ web-server-0 0/1 CrashLoopBackOff 3 2m │
│ 3 │ missing: Running │
╰─────────────────────────────────────────────────────────────╯
⚠️ Assertion failed. [y=re-run / o=override with reason / r=rollback / c=copy command / m=more / v=re-verify / Enter=stop]:Each choice fires on a single key press (no Enter needed).
y— re-run (sidecar, with a pane attached): paste the step's command into the pane again, press the same guarded Enter as[y]run it, wait for the command to finish, and verify again. Use it when the check waits on a state change — for example a rollout that still reports fewer ready replicas than desired. It is one key instead of Enter=stop →[p]→[y]. Each run is its ownsend_to_pane+enter_sentaudit event. If the paste can't be armed (the command isn't at the cursor) or Enter is refused, nothing is run and you're back on the step bar. For a resource that takes minutes, prefer a command that waits by itself (for examplekubectl rollout status deployment/web-server --timeout=10m) so one run is enough.o— record an override reason in the audit log and continuer— trigger rollbackc— copy command: copy the step's${VAR}-resolved command to the clipboard so you can paste and re-run it, then the menu re-renders (copy is not a terminal choice). Only shown when the step has a command.m— more: re-render the same captured output in full (output (full), starting the gutter at line 1) instead of just the tailv— re-verify: re-capture the pane output and re-run all checks (useful when the command is still finishing)- Enter — stop
On PASS, samaritan prints ✅ Verify passed. The step is not done yet: press ↵ to mark it done, or [v] to re-check. A pass never completes the step by itself: ↵ does, and you can re-run [v] as often as you like.
Verification runs against cleaned pane output: ANSI color codes and escape sequences are stripped and
\r-overwrites (progress bars,\r\nline endings) are resolved beforeexpectassertions run — socontains/equalsmatch what you actually see on screen, even when tools colorize their output.
Responsive output: command boxes and the verify output are sized to your terminal. On a narrow terminal, over-long lines are truncated with
…so box borders stay aligned, and the captured-output tail shrinks to fit a short window. When output is piped or redirected (no TTY), rendering is unbounded and full content is preserved.
Manual-step actions: note, evidence, verify
manual steps offer extra operator actions while you're working the step — they don't complete the step, so you can use any of them as many times as you like before pressing Enter to mark the step done. The bar shows the common keys; ? lists the rest:
[y] run it (after [p]) · [↵] done · [p] paste · [v] verify · [s] skip · [r] rollback · [?] more
> ?
[c] copy · [n] note · [e] evidence · [x] remove evidence · [t] attach another pane · [b] back · [j] jump · [g] global rollback · [abort] abort[n]note — record a free-text annotation (e.g. "restarted pod manually, confirmed with on-call"). Stored in the JSONL audit log as auser_input/noteevent and rendered as a bullet list under the step in the--reportMarkdown.[e]evidence — capture and persist evidence with the session, the same way you'd attach a file in Claude Code. You're offered up to three sources:- capture terminal output (default, only offered when a tmux session is attached to the step) — grabs everything written to the pane since the step started and stores it as
command_outputevidence [f]file or image — drag a file into the terminal (most terminals insert its path) or type/paste the path; SAMARITAN reads it from disk, copies it into~/.samaritan/sessions/<session-id>/evidence/, and stores it as a typedEvidenceItem(screenshotforpng/jpg/jpeg/webp/gif,videoformp4/webm/mov,filefor everything else)[t]type/paste text — type or paste evidence content directly
Every captured item can include an optional description, and is rendered in the
--reportMarkdown — screenshots as embedded images (), files/videos as download-style links ([View file](path)), and text/terminal captures as fenced code blocks.All evidence bytes — including dragged-in files, screenshots, and videos — are stored exclusively under
~/.samaritan/sessions/<session-id>/evidence/, alongside the session's own JSON record. SAMARITAN never leaves a second copy elsewhere: the persisted session references the file by path rather than embedding its raw bytes.- capture terminal output (default, only offered when a tmux session is attached to the step) — grabs everything written to the pane since the step started and stores it as
[x]remove evidence — only offered once at least one item has been captured for the current step. Lists the step's captured evidence (type, description, and stored path), lets you pick one by number to delete, removes it from the session record, and — for file/screenshot/video evidence copied into the session's evidence directory — deletes the copy from disk too (your original source file is never touched). Recorded in the JSONL audit log as anevidence_removedevent, and the--reportMarkdown omits removed items entirely.A key that does not apply never marks the step done.
[v]on a step withoutexpect,[p]on a step without a command,[x]with no evidence,[b]on the first step and so on print why ([v] — this step has no expect, so there is nothing to verify ([e] captures evidence). The step is not marked done (↵ does that).) and keep you on the bar. Only↵(or typed notes) completes a step.↵with nothing run asks first (sidecar, pane attached). When the step has a command, nothing was pasted with[p], and the pane has printed nothing since the step started,↵asksNothing was pasted or run in <pane> since this step started. Mark done anyway? [y / n=go back]. This catches a stray Enter that lands on the next step after a verify pass. A command you type into the pane yourself produces output, so it never asks.yis logged (user_input/done_without_run) and shows as a ⚠️ note on the step inreport.md.[v]verify — only offered when the step definesexpect. Reads the pane output captured since the step started and asserts it againstexpect(the sameassertOutputDetailed/interpolateExpectmachineryautomaticsteps use), evaluating every check (not just the first failure) and rendering the PASS/FAIL checklist + highlighted, line-numbered output described in Verify output: checklist, highlighting, and the line-number gutter — and, on failure, the override/rollback/[m]more/[v]re-verify/stop prompt. This is what actually checksexpectonmanualsteps; without pressing[v], a manual step'sexpectis documentation only.- Auto-capture on pass (closes the
expect↔evidenceloop): the first time a step's[v]verify passes, the verified pane output is automatically saved as acommand_outputevidence item (markedautomatic/validated,source: verify) — so the output you checked also becomes the output recorded in the session and--report. The evidence is captured from the pane's rendered screen (tmux capture-pane -p -J), so it reads as clean text in the report instead of the raw terminal byte stream (no ANSI/cursor-move/redraw noise). Re-pressing[v]won't record duplicates.evidence(the record) andexpect(the check) stay separate concepts; this just records what you verified.
- Auto-capture on pass (closes the
[r]rollback — runs this step'srollbackand stays on the step. In sidecar/manual runs it is operator-driven: each${VAR}-resolved command is shown,[p]pastes it into the attached pane,[v]verifies itsexpect,↵confirms you ran it (see Running a rollback); a non-sidecar run with spawn-own sessions sends it via tmux. If this step has no rollback, it offers the nearest upstream step's rollback instead — following theneedschain if present, otherwise scanning earlier steps in document order (only completed steps are offered).[g]global rollback — only offered when the operation declares a top-levelrollback:block. Previews the consolidated recovery — the explicitrollback.stepsplus, whenaggregate_step_rollbacksis on, every completed step's own rollback in reverse order (ordered by reverse-topological order whenneedsare present) — asks for a single-keyyconfirmation, runs it the same way as[r], then aborts the operation (the session stays resumable). Use it when a failure means abandoning forward progress and unwinding what's been done so far.Forward gating (
needs) — before starting a step whoseneedsaren't all completed (e.g. a dependency was skipped by[j]jump or--from-step), the run loop warns (⚠️ This step needs: …) and promptsStart anyway? [y=proceed / Enter=go back].yproceeds and records aneeds_overrideaudit event; Enter rewinds to the first unmet dependency. A skipped dependency counts as unmet.[p]send to pane (sidecar only) — pastes the step's${VAR}-resolved command into the attached tmux pane without pressing Enter, so you review it at your own prompt and run it yourself. The command is delivered via a tmux paste buffer with bracketed paste (paste-buffer -p), so a multi-line command (e.g. a heredoc script) lands as one atomic block at your prompt and does not execute line-by-line — you press Enter once yourself to run it. Only offered when the step has a command and a pane is attached (a spawn-ownsessions:pane, or one attached via[t]/--attach); with nothing attached it tells you to[t]attach first. Before pasting, line endings are normalized: CRLF or a lone CR becomes LF, and trailing newlines are stripped. tmux rewrites every LF to CR when it pastes. A stray\r, for example from a CRLF variable value, would therefore reach the shell as a blank line after each\continuation and split the block into separate commands. A trailing newline would act as Enter in a shell without bracketed paste. Multi-line commands paste straight away, with no confirmation. They land as one un-run block because the pane's shell supports bracketed paste (on by default in bash ≥ 4.4 and zsh ≥ 5.1). Don't use[p]for a multi-line command in ash/dashpane, or with bracketed paste turned off: there each line runs as it arrives. In bash, an empty row after a line that exactly fills the pane width is a readline redraw artifact. The pasted bytes are unchanged. To re-run a command during verify, just press[p]again, then[v]. Logged as auser_input/send_to_panebreadcrumb in the JSONL audit log.[p]itself never executes anything;[y](below) is the one explicit way to have samaritan press Enter.- An un-run paste is tracked. Samaritan reads the pane's cursor line. While it still ends with the pasted command, the command has not run:
[p]is refused (Nothing pasted.) until you run or clear it (Ctrl+U), so a new paste never lands on the end of an old one (echo aecho b).↵on that step asksMark done anyway? [y / n=go back].yis logged (user_input/paste_not_run) and shows as a ⚠️ note on the step inreport.md.- Entering a later step warns when an earlier step's paste is still on the pane's input line.
- Rollback
pand↵follow the same rules.
- An un-run paste is tracked. Samaritan reads the pane's cursor line. While it still ends with the pasted command, the command has not run:
[y]run it (Enter in pane) (sidecar only, shown right after a[p]paste) — samaritan presses Enter in the attached pane for you, so you don't have to switch panes. It sends Enter only while the pane is exactly as the paste left it. Right after[p], samaritan waits for the paste to finish echoing, then checks the command is sitting at the cursor (otherwise[y]isn't offered). When you press[y]it re-reads the pane and refuses with🛑 Enter NOT sent: <reason>if any of these changed:Check Why Same pane ( #{pane_id}), still aliveEnter must go to the pane that holds the paste Not in copy/view mode Enter would copy a selection instead of running the command synchronize-panesoffEnter would reach every pane in the window Input not disabled ( select-pane -d)The key would be dropped Same foreground program ( #{pane_current_command})Something else ( vim,less,ssh…) took over the paneNo pane output since the paste, cursor and prompt row unchanged Nothing was typed or edited, and the command hasn't already run, so it can't run twice After Enter, samaritan waits for the command and verifies it for you. It shows
⏳ Running… verify follows when it finishes. Once the pane is back at the same bare prompt it showed before the paste (digits such as clocks or exit codes may differ) and output has been quiet for 0.3s, it prints✔ Finished.and runs[v]against the step'sexpect. A step withoutexpectjust reports it finished. A slow, silent program in the foreground (e.g.kubectl rollout status) keeps it waiting. If the prompt can't be recognised (acdchanged it), 3s of quiet with the shell back in front counts as finished. Any key stops waiting and hands the action bar back; press[v]yourself when the command is done. The usual step flow is[p]→ glance at the pane →[y]→↵.A refusal sends nothing and keeps
[y]available (leave copy-mode, press[y]again). If the pane changed, press Enter there yourself, or[p]to paste again.[y]only ever applies to the paste made from the same step (or rollback entry). It isn't carried over to another step, or to the same step revisited with[b]. Logged asuser_input/enter_sent, and the report marks the command⏎ Enter sent from samaritan [y]. There is no flag or setting for it.[b]back — go back to an earlier, already-processed step to re-run it (e.g. an external dependency was fixed and you want to retry from there). Shows a numbered picker of the prior steps (type the listed number, the manual's step number such as2.7, or a unique part of the step name); the chosen step and every step after it are reset to pending and execution resumes from there. The append-only JSONL audit log keeps the full history of the earlier attempt, and the rewound step index is persisted soresumestays consistent. Not offered on the first step.- Re-run only one step. When there are steps between the one you pick and the one you are on,
[b]asksRe-run only "<picked>", then come back to "<current>"? [y / n=rewind].yresets only the picked step. Its header showsNext : back to step N. Once it is done (or skipped), the run returns straight to where you were, and the steps in between keep their status: they are neither re-run nor marked skipped.nrewinds as described above.- Logged as
user_inputrerun/rerun_return. If you abort during the re-run,resumecontinues after the re-run step, not at the step you came from. - Prefer this over
[b]followed by[j]: a[j]back over the rewound steps records them as skipped even though they ran.
- Re-run only one step. When there are steps between the one you pick and the one you are on,
[j]jump — jump forward to a later step (e.g. steps 3–5 are irrelevant this run and you want to go straight to step 6). Shows a numbered picker of the steps after the current one (type the listed number, the manual's step number such as2.7, or a unique part of the step name); the current step and every step up to (but not including) the target are recorded as skipped (⏭) in the run report, and execution resumes at the target. The append-only JSONL audit log keeps the full history. Not offered on the last step. To start a fresh run partway through instead, userun --from-step <N>(see below).
Evidence-required gate
When a step declares evidence: { required: true }, completing it (pressing Enter/notes on a manual/sidecar step, or approve on an approval step) is blocked until at least one item has been captured via [e]. There is no separate gate menu — trying to complete with nothing captured just prints a one-line warning on the same action bar, so you never leave it:
⚠️ This step requires evidence — press [e] to capture (typed text is fine) or [s] to skip.[e]— runs the same[e]capture flow described above; a captured item (including a quick typed-text note) satisfies the requirement, and the step completes on your nextEnter. If you can't attach real evidence right now, type a note explaining why via[e]— it's recorded in the run report as the audit trail.[s]— skip the step. It's recorded as skipped (not completed) in the run record, distinct from a completed step.
approval steps get an [e] evidence key on their own bar too, so they're satisfied the same way (capture, then approve).
This is on by default — declaring evidence.required: true is enough, no flag needed. To disable enforcement (e.g. a CI rehearsal or dry-run walk-through), pass --no-require-evidence to run or resume. The gate applies to manual/sidecar step completion and to approval steps' approve path; it does not gate plain (non-sidecar) type: automatic steps. See examples/evidence-required-step.yaml.
Mock run (--mock): replay expect against captured evidence
samaritan run <op> --env <env> --mock validates your verification rules without a terminal, tmux, or executing anything. For each step that defines expect, it pulls the command_output/log entries from evidence.results[<env>] (inline content or a referenced file), runs them through the same assertOutputDetailed engine the interactive [v] verify uses, and prints a per-step PASS/FAIL/SKIP report with the highlighted output:
samaritan run examples/mock-run-expect.yaml --env staging --mock- Steps with
expectbut no replayable evidence for the environment are skipped (reported, not failed). ${VAR}references inexpectare resolved against the environment's variables, exactly as a real run would.- Exits non-zero if any assertion fails — so CI can catch the day your
expectrules drift from the output you actually capture.
This reuses evidence.results read-only; it doesn't change what evidence is for. See examples/mock-run-expect.yaml.
The run record (durable, beside the operation)
Every samaritan run/resume writes a run record next to the operation file, so the audit trail travels with the operation you ran (operations-as-code) and can be committed or attached to a change ticket:
<operation-dir>/.samaritan-runs/<session-id>/
├── events.jsonl # append-only "black box": every step start, command, capture,
│ # verification, evidence, approval, and rollback (paths printed as 📝 Audit log)
└── report.md # human-readable Markdown report, written automatically every run (📄 Report)- Always-on:
report.mdis generated on every run —--report <dir>now writes an extra copy to a directory of your choice (e.g. an evidence bundle). - Local paths redacted: operator-local path prefixes in commands, output, and evidence are stripped so a shared report doesn't leak machine-specific locations — your home directory shows as
~, and the run directory / operation directory are relativized to their tail (e.g./tmp/operation/runs/JOB-1/email.html→email.html). - Append-only across resume:
resumecontinues the sameevents.jsonl, so the full history survives across processes. - Verification & approvals: the report surfaces each
expectcheck (pass/fail with expected-vs-actual) and an Approval Trail (approver, decision, rationale, timestamp). - Read-only fallback: if the operation's directory isn't writable, the run record falls back to
~/.samaritan/sessions/<session-id>/. - Gitignored by default:
.samaritan-runs/is in.gitignoreto keep working trees clean —git add -fa specific run folder when you want to commit it.
Sessions & resume
Every samaritan run also creates a resumable session state file at ~/.samaritan/sessions/<session-id>.json, persisted after each completed step (the session ID is printed at the start of the run: 📋 Session: <id>). This file now carries a structured step_log — per step it records the input command(s), captured output, verification result, approval (with rationale), notes, evidence references, status, and timing — derived by folding events.jsonl, so the saved session is meaningful on its own, not just metadata.
# List resumable sessions (running / paused / failed)
samaritan sessions
# Include completed and cancelled sessions
samaritan sessions --all
# Continue a run where you left off
samaritan resume <session-id>
# Or jump to a specific step
samaritan resume <session-id> --from-step 4
# Sidecar: capture from a different pane than the one the run used
samaritan resume <session-id> --attach work:0.1Sidecar resume re-attaches your pane. The pane a sidecar run captures from (--attach or [t]) is saved on the session as tmux's stable pane id (%3), so resume re-attaches it automatically (🔗 Re-attached capture to tmux pane: %3) and [v] keeps working. The id is used, not a positional target like work:0.0: when a pane closes, tmux renumbers the window, and work:0.0 could then point at a different pane, even samaritan's own. If the saved pane no longer exists, resume warns (Saved tmux pane "%3" no longer exists — press [t] …). --attach overrides the saved pane. Samaritan also refuses to attach to the pane it is running in.
Quitting a run with q, abort, or Ctrl+C stops execution but saves the session as paused, prints the resume command, and keeps it listed in samaritan sessions — so you can stop mid-operation and pick it back up later. (Ctrl+C no longer hard-quits without saving — it now unwinds through the same save path as abort.) Resume restores variables, execution mode, and the current step index, then re-enters the interactive loop.
Named sessions
Define where each step runs — local or over SSH:
sessions:
execution:
host: prod-bastion.example.com
user: deploy
env:
KUBECONFIG: /home/deploy/.kube/prod-config
monitoring:
host: monitoring.example.com
user: sre-readonly # read-only verification hostVariable names. ${VAR} in a session name is resolved against common variables, so sessions can be derived from, say, a ticket id instead of hardcoding. Quote keys that start with ${:
common_variables:
ticket: JIRA-1234
sessions:
"${ticket}": # → session "JIRA-1234"
host: prod-bastion.example.com
user: deploy
"${ticket}-local": {} # → session "JIRA-1234-local" (local pane)${VAR} in a step's session: reference resolves the same way, so session: ${ticket} lines up with the resolved session key. Remote-vs-local stays config-driven — a config with host: makes Samaritan auto-run ssh; an empty {} is a local pane. See examples/sessions-with-vars.yaml.
Assign a step to a session with session: <name>. Verification can run in a different session than execution:
steps:
- name: Deploy
session: execution
command: kubectl apply -f deployment.yaml -n ${NAMESPACE}
verify:
session: monitoring # check via the read-only pane
command: kubectl rollout status deployment/web -n ${NAMESPACE}
expect:
contains: "successfully rolled out"Run modes (auto_send / auto_exec)
Control how hands-off the operator experience is:
run:
auto_send: false # true → command auto-loaded into terminal on step start
auto_exec: false # true → Enter sent automatically after sendauto_send | auto_exec | Behaviour |
|---|---|---|
false | false | [s] loads command, operator presses Enter (most cautious) |
true | false | command auto-loads, operator reviews then presses Enter |
false | true | [s] loads and immediately executes |
true | true | fully automatic — no operator intervention |
Rule-based assertions
Verify command output automatically. Shorthand string = contains:
verify:
command: kubectl get pods -n prod | grep web | grep -c Running
expect: "3" # shorthand: output must contain "3"Or structured:
verify:
command: kubectl get pod web-0 -o jsonpath='{.status.phase}'
expect:
equals: "Running"
retry:
interval: 5s
max: 12 # retry up to 12 times (60s total)All supported assertion types:
| YAML key | Passes when |
|---|---|
contains: "text" | output includes substring |
not_contains: "Error" | output does not include substring |
equals: "value" | trimmed output exactly equals value |
matches: "regex" | output matches regular expression |
not_empty: true | output is non-empty |
any_line_contains: "text" | at least one line includes substring |
no_line_contains: "Error" | no line includes substring |
all_lines_match: "regex" | every non-empty line matches pattern |
any_line_matches: "regex" | at least one line matches pattern (regex sibling of any_line_contains) |
no_line_matches: "Error|FATAL" | no line matches pattern (regex sibling of no_line_contains) |
line_count: 3 | exactly N non-empty lines |
line_count_gte: 1 | at least N non-empty lines |
numeric_gte: 80 | first number in output ≥ value |
jsonpath: "$.status" equals: "ok" | JSONPath expression equals value |
equals_captured: VAR | output equals a previously captured variable |
Variables in
expect: only${VAR}operation variables (variables:,common_variables:,--var) resolve inexpect. A bare shell variable such ascontains: $USERis compared literally — the shell that expanded$USERin your command never seesexpect, and samaritan does not read its own environment (an SSH pane's$USERwould differ).samaritan validatewarns (expect-lint:) about every such$NAME, and a failing[v]prints a hint. Use${USER}with a variable you define, or a literal value.
Checks that pass for the wrong reason:
samaritan validatealso warns (expect-lint:) when acontains/any_line_containsvalue also appears in the step's own command (ls -la dist/main.jswithcontains: dist/main.js) — it can match the echoed command line instead of the output — and when the value is a bare short number (contains: '2'also matches12and20; usematches: '\b2\b'orequals: '2').
Shell prompt: in sidecar capture, the prompt the shell prints after the output (
user@host:~$) is stripped before checks run, soline_count,all_lines_match,equalsand friends see only the command's output. With an attached pane the pane's current prompt line is removed exactly; otherwise an unterminated last line ending in$ # % > ❯ ➜ » λis dropped.
Regex semantics:
matches,all_lines_match,any_line_matches, andno_line_matchescompile with Node'snew RegExp(pattern)using default flags — case-sensitive, no multiline, and an unanchored partial match (Runningmatches anywhere; anchor with^...$for a full-string/full-line match).samaritan validateruns a built-in regex-lint pass over these fields (andexpect.retry.while): an uncompilable pattern is a hard error, and an obviously catastrophic (ReDoS-prone) pattern such as(a+)+is a warning (promoted to an error under--strict).
Retryable verification (expect.retry)
Some checks need a moment to settle (rolling deploys, health endpoints, async jobs). Add a retry block so an automatic step's verify re-captures the pane and re-asserts up to max times, interval apart:
- name: Wait for rollout
type: automatic
command: kubectl rollout status deployment/web -n staging
expect:
contains: successfully rolled out
retry:
interval: 5s # '5s', '500ms', '2m', or a bare number (ms)
max: 10 # stop after 10 retries
while: timeout|503 # OPTIONAL: only retry while output looks transient- Without
while, any failure is retried until it passes ormaxis hit. - With
while(substring or regex), only failures whose captured output matches the pattern are retried — a non-transient failure (e.g. a permission error) fails fast instead of burning the remaining attempts. This is the "retryable code / retryable message" guard. - Polling applies to the automatic-step verify path; manual
[v]verify stays operator-driven (press[v]again to re-check). - The retry policy is also rendered in generated manuals (Markdown, Confluence, and ADF) as an extra Expected criterion, e.g.
retry up to 10× every 5s(or… while "<guard>"when awhileguard is set), so the runbook documents the polling behaviour alongside the assertion.
See examples/expect-retry.yaml.
Capture — carry values forward
Extract values from command output and use them in later steps:
steps:
- name: Build image
command: docker build -t myapp .
capture:
IMAGE_ID:
pattern: "Successfully built ([a-f0-9]+)"
group: 1 # capture group 1 from the regex
LAST_LINE:
line: last # or: line: first
- name: Deploy
command: kubectl set image deployment/web web=myapp:${IMAGE_ID}
verify:
command: kubectl get pod -l app=web -o jsonpath='{.items[0].spec.containers[0].image}'
expect:
contains: "${IMAGE_ID}" # ${VAR} interpolated at runtime