Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Specifications

git-paw uses OpenSpec for formal, testable specifications: each capability has a dedicated spec.md under openspec/specs/ using RFC 2119 keywords (SHALL, MUST, SHOULD) and GIVEN/WHEN/THEN scenarios, and every scenario maps to at least one test.

The eight foundational capabilities (the original v0.1–v0.2 surface) are reproduced in full below. The project has since grown to many more capabilities (broker, supervisor, dashboard, MCP, learnings, conflict detection, unattended operation, agent-friendly docs, …); the complete index at the end of this page links to every capability spec in the repository.

Foundational capabilities

CapabilityDescription
CLI ParsingCommand-line argument parsing and subcommands
CLI DetectionAuto-detect AI CLIs on PATH, load custom CLIs
Git OperationsValidate repos, list branches, manage worktrees
Tmux OrchestrationCreate sessions, manage panes, apply layout
Session StatePersist and recover session state
ConfigurationParse and merge TOML config files
Interactive SelectionUser prompts for mode, branch, and CLI selection
Error HandlingUnified error types with exit codes

CLI Parsing

Purpose

Define the command-line interface for git-paw using clap v4. Declares all subcommands (start, stop, purge, status, list-clis, add-cli, remove-cli), their flags, and argument validation. When no subcommand is given, defaults to start. This includes the git paw start --force flag that suppresses the uncommitted-spec validation warning (logging force usage to stderr for audit and propagating git status failures as PawError::GitError), and the guarantee that git paw start --supervisor --specs <names> passes the resolved --specs value (not just its presence) to the supervisor launch path so it launches worktrees only for the named subset — pinned by a regression matrix across every selection-flag/supervisor-flag combination and required to behave identically across all spec backends (OpenSpec, Markdown, Spec Kit).

Requirements

Requirement: Default to start when no subcommand is given

The system SHALL treat no arguments as equivalent to start with no flags.

The system SHALL also accept the hidden internal subcommands __broker, which hosts the session’s broker in its detached service surface; __dashboard, which renders the dashboard as a thin client of that broker in pane 0; and __prompt-profile, which prints one resolved CLI prompt-shape profile field for the bundled helper. None of them SHALL appear in --help output.

Scenario: No arguments yields None command

  • GIVEN no arguments are passed
  • WHEN the CLI is parsed
  • THEN command SHALL be None (handled as Start in main)

Scenario: __dashboard subcommand parses

  • GIVEN __dashboard is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Dashboard

Scenario: __broker subcommand parses

  • GIVEN __broker is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Broker

Scenario: Hidden subcommands do not appear in help

  • GIVEN --help is passed
  • WHEN the help text is rendered
  • THEN the output SHALL NOT contain __dashboard, __broker or __prompt-profile

Requirement: Start subcommand with optional flags

The start subcommand SHALL be extended to accept a --supervisor flag (boolean, defaults to false). The flag MAY be combined with any other start flags.

When --supervisor is passed, the parsed StartArgs struct SHALL have supervisor: bool set to true.

The start subcommand SHALL also accept a --no-rebase flag (boolean, defaults to false). When --no-rebase is passed, the parsed StartArgs struct SHALL have no_rebase: bool set to true. The dispatch SHALL invoke create_worktree with rebase_onto_main = !args.no_rebase for every worktree creation in the launch. When --no-rebase is omitted (i.e. no_rebase == false), agent branches SHALL be rebased onto the repository’s default branch before their worktrees are opened. When --no-rebase is present, agent branches SHALL NOT be rebased, matching the post-worktree-resume-fix v0.5.0 behaviour.

The --no-rebase flag MAY be combined with any other start flags including --supervisor, --from-specs, --cli, and --branches.

Scenario: Start with –supervisor flag

  • GIVEN start --supervisor
  • WHEN the CLI is parsed
  • THEN supervisor SHALL be true

Scenario: Start with –supervisor combined with other flags

  • GIVEN start --supervisor --cli claude --branches feat/a,feat/b
  • WHEN the CLI is parsed
  • THEN supervisor SHALL be true
  • AND cli SHALL be Some("claude")
  • AND branches SHALL be ["feat/a", "feat/b"]

Scenario: Start without –supervisor defaults to false

  • GIVEN start --cli claude
  • WHEN the CLI is parsed
  • THEN supervisor SHALL be false

Scenario: Start with –no-rebase flag

  • GIVEN start --no-rebase
  • WHEN the CLI is parsed
  • THEN no_rebase SHALL be true

Scenario: Start without –no-rebase defaults to false

  • GIVEN start --cli claude
  • WHEN the CLI is parsed
  • THEN no_rebase SHALL be false

Scenario: Start with –no-rebase combined with other flags

  • GIVEN start --no-rebase --supervisor --from-specs
  • WHEN the CLI is parsed
  • THEN no_rebase SHALL be true
  • AND supervisor SHALL be true
  • AND from_specs SHALL be true

Scenario: –no-rebase propagates to create_worktree as rebase_onto_main = false

  • GIVEN start --branches feat/a --no-rebase is invoked
  • WHEN the dispatch reaches the worktree-creation loop
  • THEN create_worktree(repo_root, "feat/a", rebase_onto_main) SHALL be called with rebase_onto_main = false

Scenario: Default start propagates rebase_onto_main = true

  • GIVEN start --branches feat/a is invoked without --no-rebase
  • WHEN the dispatch reaches the worktree-creation loop
  • THEN create_worktree(repo_root, "feat/a", rebase_onto_main) SHALL be called with rebase_onto_main = true

Requirement: Stop subcommand

The stop subcommand SHALL take no confirmation flag and SHALL NOT prompt for confirmation. Stopping is recoverable — the worktrees, branches and session state survive, and git paw start recovers the session — so stop SHALL proceed immediately whether or not stdin is a TTY. The stop subcommand SHALL NOT accept a --force flag; passing one SHALL be rejected by argument parsing as an unexpected argument.

The long_about help text for stop SHALL name all three teardown verbs (pause, stop, purge) with a one-line summary of each, so users can choose the right verb at --help time, and SHALL NOT describe a confirmation prompt or a --force flag.

Scenario: Stop parses without flags

  • GIVEN stop is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Stop

Scenario: Stop rejects –force

  • GIVEN stop --force is passed
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail with an unexpected-argument error naming --force

Scenario: Stop help names all three teardown verbs

  • WHEN git paw stop --help is run
  • THEN the output SHALL mention pause as the soft alternative
  • AND the output SHALL mention purge as the full reset
  • AND the output SHALL describe what stop itself does (kills CLI processes, preserves worktrees)
  • AND the output SHALL NOT mention a confirmation prompt or a --force flag

Scenario: Stop from a TTY does not prompt

  • GIVEN an active session and stdin attached to a TTY
  • WHEN git paw stop is run
  • THEN no interactive prompt SHALL be rendered
  • AND the session SHALL be stopped immediately

Requirement: Purge subcommand with optional –force flag

The purge subcommand SHALL accept an optional --force flag (defaults to false).

Scenario: Purge without –force

  • GIVEN purge is passed without flags
  • WHEN the CLI is parsed
  • THEN force SHALL be false

Test: cli::tests::purge_without_force

Scenario: Purge with –force

  • GIVEN purge --force is passed
  • WHEN the CLI is parsed
  • THEN force SHALL be true

Test: cli::tests::purge_with_force

Requirement: Status subcommand

The status subcommand SHALL parse with no additional arguments.

Scenario: Status parses

  • GIVEN status is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Status

Test: cli::tests::status_parses

Requirement: List-CLIs subcommand

The list-clis subcommand SHALL parse with no additional arguments.

Scenario: List-CLIs parses

  • GIVEN list-clis is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::ListClis

Test: cli::tests::list_clis_parses

Requirement: Add-CLI subcommand with required and optional arguments

The add-cli subcommand SHALL require name and command positional arguments and accept an optional --display-name flag.

Scenario: Add-CLI with required arguments only

  • GIVEN add-cli my-agent /usr/local/bin/my-agent
  • WHEN the CLI is parsed
  • THEN name SHALL be "my-agent", command SHALL be the path, and display_name SHALL be None

Test: cli::tests::add_cli_with_required_args

Scenario: Add-CLI with –display-name

  • GIVEN add-cli my-agent my-agent --display-name "My Agent"
  • WHEN the CLI is parsed
  • THEN display_name SHALL be Some("My Agent")

Test: cli::tests::add_cli_with_display_name

Scenario: Add-CLI missing required arguments is rejected

  • GIVEN add-cli with no positional arguments
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail

Test: cli::tests::add_cli_missing_required_args_is_rejected

Requirement: Remove-CLI subcommand with required argument

The remove-cli subcommand SHALL require a name positional argument.

Scenario: Remove-CLI parses

  • GIVEN remove-cli my-agent
  • WHEN the CLI is parsed
  • THEN name SHALL be "my-agent"

Test: cli::tests::remove_cli_parses

Requirement: Standard flags –version and –help

The CLI SHALL accept --version and --help flags.

Scenario: –version flag is accepted

  • GIVEN --version is passed
  • WHEN the CLI is parsed
  • THEN clap SHALL emit a DisplayVersion response

Test: cli::tests::version_flag_is_accepted

Scenario: –help flag is accepted

  • GIVEN --help is passed
  • WHEN the CLI is parsed
  • THEN clap SHALL emit a DisplayHelp response

Test: cli::tests::help_flag_is_accepted

Requirement: Unknown subcommands are rejected

The CLI SHALL reject unrecognized subcommands with a parse error.

Scenario: Unknown subcommand fails

  • GIVEN an unrecognized subcommand is passed
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail

Test: cli::tests::unknown_subcommand_is_rejected

Requirement: Help output contains all subcommands and quick start

The start --help output SHALL list the --supervisor flag with a description.

Scenario: Start help shows –supervisor flag

  • GIVEN start --help is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain --supervisor

Requirement: Version output includes binary name

The --version output SHALL include the binary name.

Scenario: Version output

  • GIVEN --version is passed
  • WHEN the binary runs
  • THEN stdout SHALL contain “git-paw”

Test: cli_tests::version_output

Requirement: No arguments behaves like start

When no subcommand is provided, the binary SHALL behave identically to start.

Scenario: No args produces same error as start

  • GIVEN the binary is run with no arguments outside a git repo
  • WHEN both git-paw and git-paw start are run
  • THEN they SHALL produce identical stderr output

Test: cli_tests::no_args_behaves_like_start

Requirement: Subcommands run without error when applicable

Subcommands that don’t require a session SHALL succeed in a valid git repo.

Scenario: Stop runs without error

  • GIVEN the binary is run in a git repo
  • WHEN stop is passed
  • THEN it SHALL succeed

Test: cli_tests::stop_runs_without_error

Scenario: Status runs without error

  • GIVEN the binary is run in a git repo
  • WHEN status is passed
  • THEN it SHALL succeed

Test: cli_tests::status_runs_without_error

Scenario: List-CLIs runs without error

  • GIVEN the binary is run in a git repo
  • WHEN list-clis is passed
  • THEN it SHALL succeed

Test: cli_tests::list_clis_runs_without_error

Requirement: Binary rejects missing required arguments

Subcommands with required arguments SHALL fail when they are missing.

Scenario: Add-CLI requires arguments

  • GIVEN add-cli is passed with no arguments
  • WHEN the binary runs
  • THEN it SHALL fail with stderr mentioning “required”

Test: cli_tests::add_cli_requires_arguments

Scenario: Remove-CLI requires argument

  • GIVEN remove-cli is passed with no arguments
  • WHEN the binary runs
  • THEN it SHALL fail with stderr mentioning “required”

Test: cli_tests::remove_cli_requires_argument

Requirement: Not-a-repo error from binary

Commands requiring a git repo SHALL fail with an actionable error when run outside one.

Scenario: Start from non-git directory

  • GIVEN the binary is run outside a git repository
  • WHEN start is passed
  • THEN it SHALL fail with stderr containing “Not a git repository”

Test: cli_tests::start_from_non_git_dir

Scenario: Unknown subcommand from binary

  • GIVEN the binary is passed an unrecognized subcommand
  • WHEN it runs
  • THEN it SHALL fail with stderr containing “error”

Test: cli_tests::unknown_subcommand_fails

Requirement: Replay subcommand

The replay subcommand SHALL accept an optional <branch> positional argument, a --list flag, a --color flag, and an optional --session flag.

Scenario: Replay with branch

  • WHEN replay feat/add-auth is passed
  • THEN branch SHALL be Some("feat/add-auth"), list SHALL be false, color SHALL be false

Scenario: Replay with –list

  • WHEN replay --list is passed
  • THEN list SHALL be true and branch SHALL be None

Scenario: Replay with –color

  • WHEN replay feat/add-auth --color is passed
  • THEN color SHALL be true

Scenario: Replay with –session

  • WHEN replay feat/add-auth --session paw-myproject is passed
  • THEN session SHALL be Some("paw-myproject")

Scenario: Replay with no arguments and no –list

  • WHEN replay is passed with no arguments and no --list
  • THEN parsing SHALL fail with an error indicating either a branch or --list is required

Scenario: Replay help text

  • WHEN replay --help is passed
  • THEN stdout SHALL contain descriptions of --list, --color, and --session flags with examples

Requirement: Init subcommand

The init subcommand SHALL parse with no required arguments.

Scenario: Init parses

  • WHEN init is passed
  • THEN the command SHALL be Command::Init

Scenario: Init help text

  • WHEN init --help is passed
  • THEN stdout SHALL contain a description of project initialization and examples

Requirement: Spec mode + supervisor mode dispatch

The start subcommand dispatch SHALL evaluate the supervisor-mode resolution chain (per supervisor-cli) BEFORE branching on --from-specs. Specifically:

  1. Resolve supervisor-mode-enabled state from the --supervisor flag, --no-supervisor flag, [supervisor] config, and the prompt fallback.
  2. If supervisor mode is enabled, route to cmd_supervisor. When --from-specs is also set, pass branches_flag = None so cmd_supervisor’s existing scan_specs(...) fallback runs to determine branches from configured specs.
  3. Otherwise, if --from-specs is set, route to cmd_start_from_specs.
  4. Otherwise, route to bare cmd_start.

This ordering ensures --from-specs --supervisor (or --from-specs with [supervisor] enabled = true in config) actually engages supervisor mode end-to-end, rather than silently degrading to spec-mode-without-supervisor.

--from-specs combined with --branches continues to follow v0.4’s existing behaviour (the spec-mode flow ignores explicit branches when from-specs is set); this change does not introduce a new mutual-exclusion error for that combination.

Scenario: –from-specs –supervisor engages supervisor mode

  • GIVEN git paw start --from-specs --supervisor is invoked
  • WHEN the dispatch resolves
  • THEN the supervisor-mode resolution chain SHALL evaluate supervisor = true
  • AND the dispatch SHALL route to cmd_supervisor
  • AND cmd_supervisor SHALL receive branches_flag = None, triggering its scan_specs(...) fallback

Scenario: –from-specs without supervisor uses spec mode

  • GIVEN git paw start --from-specs is invoked, no --supervisor flag, and [supervisor] config indicates supervisor mode is not enabled (either explicitly false or absent + non-interactive)
  • WHEN the dispatch resolves
  • THEN the dispatch SHALL route to cmd_start_from_specs

Scenario: –from-specs with [supervisor] enabled = true config engages supervisor mode

  • GIVEN git paw start --from-specs is invoked with no --supervisor flag
  • AND .git-paw/config.toml contains [supervisor] enabled = true
  • WHEN the dispatch resolves
  • THEN supervisor mode SHALL be active per the resolution chain
  • AND the dispatch SHALL route to cmd_supervisor (not cmd_start_from_specs)

Scenario: –no-supervisor –from-specs uses spec mode

  • GIVEN git paw start --from-specs --no-supervisor is invoked
  • AND [supervisor] enabled = true is set in config
  • WHEN the dispatch resolves
  • THEN supervisor mode SHALL be disabled per the resolution chain
  • AND the dispatch SHALL route to cmd_start_from_specs

Scenario: Bare start (no –from-specs, no supervisor) uses cmd_start

  • GIVEN git paw start is invoked with no --from-specs, no --supervisor, and supervisor mode is not enabled in config
  • WHEN the dispatch resolves
  • THEN the dispatch SHALL route to cmd_start

Requirement: Non-TTY launch handling

When a git paw start invocation reaches its session-launch step (after worktrees are created, panes added, and tmux_session.execute() succeeds), the system SHALL detect whether stdin is connected to a terminal via std::io::IsTerminal::is_terminal(&std::io::stdin()).

When stdin is not a terminal:

  • The system SHALL skip the tmux::attach(...) call.
  • The system SHALL print an informational message to stdout naming the launched session and the manual-attach command (tmux attach -t <session>).
  • The system SHALL exit with status 0.
  • For supervisor mode specifically, the system SHALL also skip the foreground supervisor-CLI launch (Command::new(supervisor_cli).status()) with an additional hint that supervisor mode requires an interactive terminal.

When stdin is a terminal, the launch flow proceeds as before (call tmux::attach, run the supervisor CLI in foreground for supervisor mode).

This SHALL apply to all three start paths: cmd_start, cmd_start_from_specs, and cmd_supervisor.

Scenario: Non-TTY bare start exits cleanly with attach hint

  • GIVEN git paw start --branches feat/x,feat/y is invoked with stdin redirected from /dev/null (or otherwise non-TTY)
  • WHEN the launch flow completes its session-build steps
  • THEN the command SHALL exit with status 0
  • AND stdout SHALL contain “Session ‘’ started in detached mode.”
  • AND stdout SHALL contain “Attach with: tmux attach -t ”
  • AND the tmux session SHALL exist and be alive after exit

Scenario: Non-TTY –from-specs exits cleanly

  • GIVEN git paw start --from-specs is invoked from a non-TTY context
  • WHEN the launch flow completes
  • THEN the command SHALL exit with status 0
  • AND the attach-hint message SHALL be printed
  • AND the tmux session SHALL exist and be alive

Scenario: Non-TTY –supervisor skips supervisor CLI launch

  • GIVEN git paw start --supervisor --from-specs is invoked from a non-TTY context
  • WHEN the launch flow completes
  • THEN the command SHALL exit with status 0
  • AND the foreground supervisor-CLI launch SHALL be skipped
  • AND stdout SHALL contain a hint indicating supervisor mode requires an interactive terminal
  • AND stdout SHALL contain the manual-attach command for the launched session

Scenario: TTY launch attaches as before

  • GIVEN git paw start --branches feat/x,feat/y is invoked from a real TTY
  • WHEN the launch flow completes its session-build steps
  • THEN the system SHALL call tmux::attach(...) for the launched session
  • AND SHALL NOT print the “started in detached mode” hint

Requirement: –from-all-specs flag

The start subcommand SHALL accept a --from-all-specs flag (boolean, default false). When passed, the resulting StartArgs SHALL indicate the “launch every discovered spec” mode — the v0.4 behaviour previously gated by --from-specs.

The flag SHALL appear in git paw start --help output with a description naming it as the canonical name for this behaviour.

Scenario: –from-all-specs sets the launch-all mode

  • GIVEN the user invokes git paw start --from-all-specs
  • WHEN the CLI is parsed
  • THEN the parsed StartArgs SHALL indicate the launch-all-discovered-specs mode

Scenario: –from-all-specs combined with –supervisor

  • GIVEN start --from-all-specs --supervisor
  • WHEN the CLI is parsed
  • THEN both from_all_specs and supervisor SHALL be true

Scenario: –from-all-specs appears in help output

  • WHEN git paw start --help is run
  • THEN the output contains --from-all-specs
  • AND the output describes the flag as launching every discovered spec

Requirement: –from-specs is a hidden alias of –from-all-specs

The start subcommand SHALL accept --from-specs as a hidden alias of --from-all-specs. When the user passes --from-specs, the parsed StartArgs SHALL be byte-for-byte identical to the parse result for --from-all-specs. No stderr warning SHALL be emitted at runtime; the alias is silent.

The alias SHALL NOT appear in git paw start --help output. The alias SHALL be removed in v1.0.0; v0.5.0 keeps it for backward compatibility with v0.4 scripts.

Scenario: –from-specs parses identically to –from-all-specs

  • GIVEN two CLI invocations: start --from-specs and start --from-all-specs
  • WHEN both are parsed
  • THEN the resulting StartArgs values SHALL be equal

Scenario: –from-specs does not appear in help

  • WHEN git paw start --help is run
  • THEN the output SHALL NOT contain the substring --from-specs

Scenario: –from-specs emits no stderr warning

  • GIVEN the user runs a command containing --from-specs
  • WHEN the CLI parses
  • THEN no stderr warning SHALL be emitted regarding the flag’s deprecation
  • AND the command proceeds exactly as if --from-all-specs had been passed

Requirement: –specs flag with comma-separated values

The start subcommand SHALL accept a --specs flag whose value is a comma-separated list of spec names (mirroring the existing --branches feat/a,feat/b syntax). The flag SHALL accept zero or more values:

  • --specs (no values) — indicates the picker mode.
  • --specs NAME — narrows to a single named spec.
  • --specs NAME1,NAME2,NAME3 — narrows to the listed specs.
  • --specs NAME1,NAME2 --specs NAME3 — equivalent to --specs NAME1,NAME2,NAME3 if clap’s value-accumulation across repetitions is enabled (implementation choice; tests assert behaviour for the comma-separated form).

The parsed value distinguishes three states:

  • Flag absent → no spec mode requested.
  • Flag present with zero values → picker mode.
  • Flag present with one or more values → narrow mode with the listed names.

The flag SHALL appear in git paw start --help output.

Scenario: –specs with single value parses as narrow

  • GIVEN start --specs add-auth
  • WHEN the CLI is parsed
  • THEN StartArgs SHALL indicate narrow mode with ["add-auth"]

Scenario: –specs with comma-separated values parses as narrow with multiple names

  • GIVEN start --specs add-auth,fix-session,add-logging
  • WHEN the CLI is parsed
  • THEN StartArgs SHALL indicate narrow mode with ["add-auth", "fix-session", "add-logging"]

Scenario: –specs with no values parses as picker

  • GIVEN start --specs
  • WHEN the CLI is parsed
  • THEN StartArgs SHALL indicate picker mode

Scenario: –specs absent leaves spec mode unset

  • GIVEN start --supervisor (no --specs, no --from-all-specs)
  • WHEN the CLI is parsed
  • THEN StartArgs SHALL indicate no spec mode (falls through to standard branch selection)

Requirement: –from-all-specs and –specs are mutually exclusive

The system SHALL reject any invocation that combines --from-all-specs (or its alias --from-specs) with --specs. clap’s parse step SHALL produce an error before the command runs. The error message SHALL clearly state that the two flags express opposing intents and SHALL list both flags.

Scenario: –from-all-specs and –specs together are rejected

  • GIVEN start --from-all-specs --specs add-auth
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail with an error mentioning both --from-all-specs and --specs

Scenario: –from-specs alias and –specs together are also rejected

  • GIVEN start --from-specs --specs add-auth
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail with an error mentioning both flags
  • AND the alias SHALL enforce the same mutual-exclusion rule as the canonical name

Requirement: –no-supervisor flag

The start subcommand SHALL accept a --no-supervisor flag (boolean, default false). When passed, the parsed StartArgs SHALL have no_supervisor: bool set to true. The flag SHALL appear in git paw start --help output with a description that names the use case (overriding [supervisor] enabled = true for a single session).

Scenario: –no-supervisor sets the flag

  • GIVEN the user invokes git paw start --no-supervisor
  • WHEN the CLI is parsed
  • THEN the parsed StartArgs.no_supervisor SHALL be true
  • AND StartArgs.supervisor SHALL be false

Scenario: –no-supervisor absent leaves flag false

  • GIVEN the user invokes git paw start with neither --supervisor nor --no-supervisor
  • WHEN the CLI is parsed
  • THEN StartArgs.no_supervisor SHALL be false
  • AND StartArgs.supervisor SHALL be false

Scenario: –no-supervisor appears in help output

  • WHEN git paw start --help is run
  • THEN the output contains --no-supervisor
  • AND the output describes the flag as disabling supervisor for the session and overriding any [supervisor] enabled = true config setting

Requirement: –supervisor and –no-supervisor are mutually exclusive

The system SHALL reject any invocation that combines --supervisor and --no-supervisor on the same start command. clap’s parse step SHALL produce an error before the command runs. The error message SHALL clearly state that the two flags express opposing intents and SHALL list both.

Scenario: Both flags together are rejected

  • GIVEN start --supervisor --no-supervisor
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail with an error mentioning both --supervisor and --no-supervisor

Scenario: –no-supervisor combines with other flags

  • GIVEN start --no-supervisor --cli claude --branches feat/a,feat/b
  • WHEN the CLI is parsed
  • THEN no_supervisor SHALL be true
  • AND cli SHALL be Some("claude")
  • AND branches SHALL contain feat/a and feat/b
  • AND parsing SHALL succeed

Requirement: Pause subcommand

The pause subcommand SHALL parse with no additional arguments and SHALL be visible in git paw --help output. The subcommand SHALL include an about string (“Pause the session (detaches client, stops broker, leaves CLIs running)”) and a long_about string that names the RAM trade-off and points the reader at stop and (forthcoming v1.0.0) hibernate for the destructive and RAM-free alternatives respectively.

The pause subcommand SHALL appear in the root after_help quick-start guide alongside start, stop, and purge.

Scenario: Pause parses

  • GIVEN pause is passed
  • WHEN the CLI is parsed
  • THEN the command SHALL be Command::Pause

Scenario: Pause accepts no flags

  • GIVEN pause --anything is passed (any flag)
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail with an unknown-argument error

Scenario: Pause appears in help

  • WHEN git paw --help is run
  • THEN the output SHALL list a pause subcommand
  • AND the output SHALL include the pause line in the quick-start after_help block

Scenario: Pause help text names the RAM trade-off

  • WHEN git paw pause --help is run
  • THEN the output SHALL mention that CLI processes remain running
  • AND the output SHALL mention the RAM-allocation trade-off (or words conveying “RAM stays held”)
  • AND the output SHALL suggest git paw stop for the RAM-releasing alternative

Requirement: git paw purge interactive confirmation SHALL honour y+Enter under all conditions

The cmd_purge interactive confirmation prompt SHALL be reliably readable by the dialoguer Confirm widget regardless of preceding stderr output. When the unmerged-commits warning has been written to stderr immediately before the prompt, the warning writer SHALL flush stderr before the prompt’s interact() call begins, so the user’s y+Enter input is not racing the warning’s buffered bytes.

Scenario: Purge with unmerged commits and y+Enter proceeds

  • GIVEN a session with at least one branch carrying commits not in main
  • AND git paw purge is invoked from a TTY
  • WHEN the prompt “Purge is irreversible. Continue?” appears and the user types y followed by Enter
  • THEN the purge SHALL proceed (kill tmux session + remove worktrees + delete session JSON)
  • AND the exit code SHALL be 0
  • AND stdout SHALL contain Purged session 'paw-...'

Scenario: Purge with unmerged commits and n+Enter cancels

  • GIVEN same setup as above
  • WHEN the user types n followed by Enter
  • THEN the purge SHALL NOT proceed
  • AND the exit code SHALL be 0
  • AND stdout SHALL contain Purge cancelled.
  • AND the session worktrees SHALL still be on disk

Scenario: Purge with bare Enter (no y/n) defaults to no

  • GIVEN same setup
  • WHEN the user types Enter without first typing y or n
  • THEN the prompt SHALL default to false (No)
  • AND the purge SHALL NOT proceed
  • AND stdout SHALL contain Purge cancelled.

Requirement: git paw purge --force SHALL propagate --force to git worktree remove and emit per-worktree progress

When git paw purge is invoked with --force, the underlying git worktree remove invocations SHALL pass --force so the removal succeeds on worktrees with uncommitted changes. The command SHALL also emit per-worktree progress messages to stderr (e.g. Removing worktree <path>... before each removal and done (<elapsed>s) after) so the user can distinguish a slow-but-progressing removal from an actual hang.

Scenario: purge --force removes dirty worktrees

  • GIVEN a session with one worktree containing uncommitted edits
  • WHEN git paw purge --force is invoked
  • THEN the dirty worktree SHALL be removed successfully
  • AND the exit code SHALL be 0
  • AND the underlying git worktree remove invocation SHALL include the --force flag

Scenario: purge --force emits per-worktree progress to stderr

  • GIVEN a session with two or more worktrees
  • WHEN git paw purge --force is invoked
  • THEN stderr SHALL contain a Removing worktree <path>... line for each worktree being removed
  • AND stderr SHALL contain a done or completion marker after each removal
  • AND the order SHALL match the worktree iteration order

Scenario: purge without --force does NOT pass --force to git worktree remove

  • GIVEN a session with one worktree containing uncommitted edits
  • WHEN git paw purge (no --force) is invoked and the user confirms with y
  • THEN the underlying git worktree remove SHALL NOT include the --force flag
  • AND if git worktree remove fails because of the dirty state, the failure SHALL be reported to stderr as warning: failed to remove worktree '<path>': <git error> per the existing error-handling path
  • AND purge SHALL continue with the remaining worktrees

Requirement: git paw stop and git paw purge SHALL strip the supervisor boot-block injection from AGENTS.md

cmd_stop and cmd_purge (src/main.rs) SHALL invoke a helper that removes the supervisor-pane boot-block injection block from <repo>/AGENTS.md. The block is bounded by HTML comment markers <!-- git-paw:start --> … <!-- git-paw:end --> (or similar — the actual marker strings are owned by the injection code path and SHALL match exactly). The helper SHALL be idempotent and SHALL preserve all surrounding content byte-for-byte.

Scenario: Stop strips the boot-block injection

  • GIVEN a session in which cmd_supervisor or cmd_start injected a <!-- git-paw:start -->…<!-- git-paw:end --> block into AGENTS.md
  • WHEN git paw stop (with or without --force) is invoked
  • AND the teardown completes successfully
  • THEN the resulting AGENTS.md SHALL contain no <!-- git-paw:start --> marker
  • AND no <!-- git-paw:end --> marker

Scenario: Purge strips the boot-block injection

  • GIVEN the same setup
  • WHEN git paw purge (with or without --force) is invoked
  • THEN AGENTS.md SHALL contain neither marker after the purge completes

Scenario: Stop/purge on AGENTS.md without markers is a no-op

  • GIVEN an AGENTS.md with no <!-- git-paw:start --> marker
  • WHEN git paw stop or git paw purge runs the cleanup helper
  • THEN AGENTS.md SHALL be byte-identical to its pre-cleanup state
  • AND the helper SHALL return success

Requirement: git paw init SHALL be idempotent and additive on existing config files

src/init.rs::run_init SHALL parse the existing .git-paw/config.toml (if any) and compare its top-level keys/tables against the bundled-default schema. The init flow SHALL append commented stanzas ONLY for keys/tables missing from the user’s config. It SHALL NEVER:

  1. Modify the value of an existing key.
  2. Add a second occurrence of any top-level table (e.g. a second [supervisor]) when the user already has that section commented OR uncommented.
  3. Re-order or reformat existing keys/sections.
  4. Strip existing user comments or blank lines.

When every bundled-default key is already present in the user’s config, init SHALL print config.toml already has all default keys; no changes and return Ok without writing.

Init invocations SHALL be idempotent: running git paw init a second time on a config that the first run produced SHALL leave the file byte-identical.

Scenario: First init writes a complete commented default config

  • GIVEN a fresh repo with no .git-paw/config.toml
  • WHEN git paw init is invoked
  • THEN the file SHALL be created
  • AND SHALL parse as valid TOML
  • AND SHALL contain commented stanzas for every bundled-default top-level key/section

Scenario: Second init on the just-written file is a no-op

  • GIVEN the same repo after the first init
  • WHEN git paw init is invoked again
  • THEN the file SHALL be byte-identical to the first-run output
  • AND the exit SHALL be 0

Scenario: Init preserves a user-authored [supervisor] block

  • GIVEN a .git-paw/config.toml containing only:
    [supervisor]
    enabled = true
    cli = "claude-oss"
    
  • WHEN git paw init is invoked
  • THEN the resulting file SHALL contain enabled = true and cli = "claude-oss" byte-identical to the input
  • AND SHALL NOT contain a second [supervisor] section header (commented or uncommented)
  • AND SHALL parse as valid TOML (no duplicate key errors)

Scenario: Init appends missing top-level sections

  • GIVEN a .git-paw/config.toml containing only branch_prefix = "feat/"
  • WHEN git paw init is invoked
  • THEN the resulting file SHALL preserve branch_prefix = "feat/" byte-identical
  • AND SHALL gain commented stanzas for every bundled-default section the user is missing ([broker], [dashboard], [supervisor], etc.)
  • AND SHALL parse as valid TOML

Scenario: Init never modifies existing user values

  • GIVEN a .git-paw/config.toml with [broker] port = 9200 (non-default port)
  • WHEN git paw init is invoked
  • THEN the resulting file SHALL still have port = 9200
  • AND SHALL NOT introduce a second port key or a commented # port = 9119 stanza inside [broker]

Requirement: –unattended flag

The start subcommand SHALL accept an --unattended flag (boolean, default false). When passed, the parsed StartArgs SHALL have unattended: bool set to true. When omitted, unattended SHALL be false.

The flag SHALL appear in git paw start --help output with a description naming the use case: drive a supervisor wave to completion in-process (escalate every permission prompt for a human decision without blocking the rest of the wave, detect completion, exit with a summary). The help text SHALL NOT claim that the flag approves prompts, and its long help SHALL state that prompt-free unattended runs need the agent CLI’s own allow rules or the full-auto approval level inside a sandbox.

--unattended engages supervisor mode: when --unattended is passed, the supervisor-mode resolution chain SHALL resolve supervisor mode active (equivalent to --supervisor) so the dispatch routes to the supervisor launch path. --unattended MAY be combined with --supervisor, --from-all-specs (and its alias --from-specs), --specs, --cli, and --branches.

--unattended SHALL NOT be combined with --no-supervisor; the system SHALL reject any invocation that combines --unattended with --no-supervisor with a parse error naming both flags, because they express opposing intents.

Scenario: –unattended sets the flag

  • GIVEN the user invokes git paw start --unattended
  • WHEN the CLI is parsed
  • THEN the parsed StartArgs.unattended SHALL be true

Scenario: –unattended absent leaves flag false

  • GIVEN the user invokes git paw start without --unattended
  • WHEN the CLI is parsed
  • THEN StartArgs.unattended SHALL be false

Scenario: –unattended combines with other flags

  • GIVEN start --unattended --from-specs --cli claude
  • WHEN the CLI is parsed
  • THEN unattended SHALL be true
  • AND the spec-mode launch-all state SHALL be set
  • AND cli SHALL be Some("claude")
  • AND parsing SHALL succeed

Scenario: –unattended appears in help output

  • WHEN git paw start --help is run
  • THEN the output SHALL contain --unattended
  • AND the output SHALL describe the flag as driving the wave to completion and escalating permission prompts
  • AND the output SHALL NOT describe the flag as auto-approving prompts

Scenario: –unattended engages supervisor mode

  • GIVEN git paw start --unattended --branches feat/a,feat/b is invoked
  • WHEN the dispatch resolves the supervisor-mode resolution chain
  • THEN supervisor mode SHALL resolve active
  • AND the dispatch SHALL route to the supervisor launch path (cmd_supervisor)

Scenario: –unattended with –no-supervisor is rejected

  • GIVEN start --unattended --no-supervisor
  • WHEN the CLI is parsed
  • THEN parsing SHALL fail with an error mentioning both --unattended and --no-supervisor

Requirement: Force flag definition

The git paw start command SHALL accept an optional --force boolean flag that defaults to false. The flag SHALL appear in git paw start --help output with descriptive text explaining that it bypasses the uncommitted-spec validation warning. The flag SHALL parse according to Clap’s boolean flag rules.

Scenario: Force flag is parsed by clap

  • GIVEN the git paw start command
  • WHEN the user invokes git paw start --from-specs --force
  • THEN the parsed Start arguments expose force = true
  • AND when invoked without --force, force = false

Scenario: Force flag appears in help text

  • WHEN the user runs git paw start --help
  • THEN the output contains a description for --force mentioning uncommitted-spec validation

Requirement: Validation bypass with force flag

When --force is provided, the command SHALL proceed with execution even if uncommitted spec changes are detected. When --force is NOT provided and uncommitted specs exist, the command SHALL display a warning that lists the IDs of specs with uncommitted changes and SHALL continue execution. Uncommitted-spec detection SHALL iterate through pending specs from scan_specs(), check each spec’s directory using git status --porcelain, and collect IDs of specs with non-empty git status output.

Scenario: No uncommitted specs

  • GIVEN all pending specs are committed in git
  • WHEN the user runs git paw start --from-specs
  • THEN the command proceeds normally without any uncommitted-spec warning

Scenario: Uncommitted specs without force

  • GIVEN at least one pending spec has uncommitted changes per git status --porcelain
  • WHEN the user runs git paw start --from-specs
  • THEN stderr contains a warning listing the IDs of specs with uncommitted changes
  • AND the command continues execution

Scenario: Uncommitted specs with force

  • GIVEN at least one pending spec has uncommitted changes
  • WHEN the user runs git paw start --from-specs --force
  • THEN the uncommitted-spec warning is suppressed
  • AND the command logs force usage to stderr for audit purposes
  • AND the command continues execution

Scenario: Force combined with dry run

  • GIVEN at least one pending spec has uncommitted changes
  • WHEN the user runs git paw start --from-specs --force --dry-run
  • THEN dry-run output is shown without the uncommitted-spec warning

Requirement: Force flag error handling

When checking spec git status, the command SHALL propagate git status failures as PawError::GitError. Invalid spec paths SHALL be skipped without aborting the overall validation.

Scenario: Git status failure is reported as PawError

  • GIVEN a spec directory where git status --porcelain exits non-zero
  • WHEN uncommitted-spec validation runs
  • THEN the command returns a PawError::GitError

Requirement: –specs list honoured under –supervisor

The dispatcher SHALL pass the --specs flag’s resolved value (not just its presence) to the supervisor launch path. When the user invokes git paw start --supervisor --specs <names>, the system SHALL launch worktrees ONLY for the named subset of discovered specs, matching the non-supervisor --specs behaviour exactly.

Scenario: –supervisor –specs subset filters correctly

  • GIVEN an openspec/changes/ directory containing N discovered specs (N > 2)
  • WHEN the user runs git paw start --supervisor --specs a,b where a and b are exactly two of the N specs
  • THEN the launch SHALL create exactly two worktrees (for a and b) and SHALL NOT create worktrees for any other spec in the directory

Scenario: –supervisor –from-all-specs still launches all

  • GIVEN the same N discovered specs
  • WHEN the user runs git paw start --supervisor --from-all-specs
  • THEN the launch SHALL create worktrees for all N discovered specs (current behaviour preserved)

Scenario: –supervisor with no spec flag uses branch picker

  • WHEN the user runs git paw start --supervisor with neither --specs nor --from-all-specs
  • THEN the launch SHALL behave identically to git paw start (branch picker) but with supervisor mode enabled — no automatic discovery of every spec

Scenario: –supervisor –specs (picker, no values) opens interactive picker

  • GIVEN an interactive TTY
  • WHEN the user runs git paw start --supervisor --specs with no values
  • THEN the system SHALL open the multi-select picker; selected specs alone SHALL determine the launched worktrees

Scenario: –supervisor + –specs without –from-all-specs

  • GIVEN N discovered specs in openspec/changes/
  • WHEN the user runs git paw start --supervisor --specs cold-start-ci-parity (a single named spec)
  • THEN exactly one worktree SHALL be created (for cold-start-ci-parity); no worktree SHALL exist for any other spec

Requirement: Regression matrix for selection-flag combinations

The test suite SHALL include a matrix of integration tests covering every combination of selection flag and supervisor flag. The matrix SHALL include at minimum the 15 combinations of {none, --from-all-specs, --specs <names>, --specs (picker), --branches} × {no supervisor, --supervisor, --no-supervisor}.

Scenario: Matrix passes for every combination

  • WHEN the integration matrix runs
  • THEN every cell SHALL pass — each combination produces the documented worktree set, and no combination produces worktrees for specs outside the filter

Scenario: Matrix catches a regression in any cell

  • GIVEN a hypothetical regression in any cell (e.g. someone re-introduces the v0.6.0 bug where --supervisor --specs <list> launches all discovered specs)
  • WHEN the matrix runs
  • THEN the failing cell’s test SHALL fail with a message identifying the offending flag combination

Requirement: Stack-agnostic and backend-agnostic

The filter behaviour SHALL be identical across all configured spec backends (OpenSpec, Markdown, Spec Kit). The bug fix SHALL NOT introduce new backend-specific code paths.

Scenario: Same filter behaviour on Markdown backend

  • GIVEN a Markdown-backend repo with N specs
  • WHEN the user runs git paw start --supervisor --specs a
  • THEN the launch SHALL create exactly one worktree for spec a, matching the OpenSpec-backend behaviour

Scenario: Same filter behaviour on Spec Kit backend

  • GIVEN a Spec Kit .specify/specs/ repo with N features
  • WHEN the user runs git paw start --supervisor --specs 003-feature-name
  • THEN the launch SHALL create exactly one worktree for that feature

Requirement: Spec-driven launch chapter uses the canonical flag name

docs/src/user-guide/spec-driven-launch.md SHALL use --from-all-specs as the canonical flag name in every example. The hidden v0.4 alias --from-specs SHALL NOT appear in example command lines in the chapter (per the cross-format-spec-selection archive’s documentation policy, the alias is intentionally undocumented to nudge migration).

Scenario: Spec-driven launch chapter uses –from-all-specs

  • WHEN docs/src/user-guide/spec-driven-launch.md is inspected
  • THEN every example command launching all discovered specs uses --from-all-specs
  • AND no example command line uses --from-specs

Requirement: CLI reference lists every top-level subcommand

docs/src/cli-reference.md’s top-level commands list SHALL include every public subcommand. Currently the list SHALL include (at minimum) start, stop, purge, status, list-clis, add-cli, remove-cli, init, and replay. The v0.4 omission of init and replay SHALL be repaired.

Scenario: CLI reference lists init

  • WHEN the top-level commands section of docs/src/cli-reference.md is inspected
  • THEN it contains the substring init

Scenario: CLI reference lists replay

  • WHEN the top-level commands section of docs/src/cli-reference.md is inspected
  • THEN it contains the substring replay

Requirement: CLI reference start-flag table lists every flag

docs/src/cli-reference.md’s start-flag table SHALL include every flag defined in src/cli.rs::StartArgs. Currently the table SHALL include (at minimum) --cli, --branches, --dry-run, --preset, --from-all-specs, --specs, --specs-format, --supervisor, --no-supervisor, and --force.

Scenario: CLI reference includes –specs-format

  • WHEN the start-flag table in docs/src/cli-reference.md is inspected
  • THEN it contains the substring --specs-format

Scenario: CLI reference includes –no-supervisor

  • WHEN the start-flag table in docs/src/cli-reference.md is inspected
  • THEN it contains the substring --no-supervisor

Scenario: CLI reference includes –from-all-specs and –specs

  • WHEN the start-flag table in docs/src/cli-reference.md is inspected
  • THEN it contains the substring --from-all-specs
  • AND it contains the substring --specs (as a distinct flag, not as a prefix of --specs-format)

Scenario: CLI reference includes –force

  • WHEN the start-flag table in docs/src/cli-reference.md is inspected
  • THEN it contains the substring --force

Requirement: Attach subcommand

The attach subcommand SHALL reattach the current terminal to the running tmux session for the current repository, resolving the session by the repository path (as status does) and invoking the tmux attach path. When no session exists for the repository, or the session is not alive, it SHALL exit with an actionable error naming that no session is running and how to start one. This provides a first-class git paw attach in place of the raw tmux attach -t paw-<project>.

Scenario: Attach reattaches to the repository’s running session

  • GIVEN a running session for the current repository
  • WHEN the user runs git paw attach
  • THEN the system SHALL attach the terminal to that session

Scenario: Attach with no running session errors actionably

  • GIVEN no running session exists for the current repository
  • WHEN the user runs git paw attach
  • THEN the system SHALL exit with an error stating no session is running and how to start one

Requirement: Completions subcommand

The completions subcommand SHALL print a shell-completion script for a requested shell (bash, zsh, fish) to stdout, generated from the clap command definition, so a user can install completions through their shell’s standard mechanism. A missing or unsupported shell argument SHALL produce an actionable error.

Scenario: Completions prints a script for a supported shell

  • WHEN the user runs git paw completions bash
  • THEN the system SHALL print a bash completion script to stdout and exit successfully

Scenario: Completions rejects an unsupported shell

  • WHEN the user runs git paw completions <unsupported>
  • THEN the system SHALL exit with an actionable error naming the supported shells

Requirement: No resume subcommand; reattach is attach, revival is start

The CLI SHALL NOT provide a resume subcommand. Reattaching the current terminal to a running session SHALL be git paw attach; reviving a paused or stopped session SHALL remain git paw start. The documentation (CLI reference, README, user guide) SHALL NOT reference a git paw resume command.

Scenario: git paw resume is not a valid subcommand

  • WHEN the user runs git paw resume
  • THEN the system SHALL reject it as an unknown subcommand

Scenario: Docs do not reference a resume command

  • WHEN the CLI reference and user guide are inspected
  • THEN they SHALL NOT reference a git paw resume command; reattach is documented as git paw attach and session revival as git paw start

Requirement: Purge –all-repos flag for a machine-wide stale sweep

git paw purge SHALL accept an --all-repos flag that broadens the --stale sweep from the current repository (the default) to every repository with a receipt on the machine. --all-repos SHALL be meaningful only in combination with --stale; used without --stale it SHALL be rejected with an actionable error (or treated as inert) per the CLI’s flag rules. The machine-wide sweep SHALL still only ever purge stale receipts and SHALL never purge a live session.

Scenario: –all-repos requires –stale

  • WHEN the user runs git paw purge --all-repos without --stale
  • THEN the CLI SHALL reject the combination with an actionable error (or treat --all-repos as inert), per the flag rules

Scenario: –stale –all-repos broadens the sweep

  • GIVEN stale receipts in multiple repositories
  • WHEN the user runs git paw purge --stale --all-repos
  • THEN the sweep SHALL cover every repository’s stale receipts
  • AND live sessions SHALL remain intact

CLI Detection

cli-resolution Specification

Purpose

Detect available AI coding CLI binaries by scanning PATH for known names and merging with user-defined custom CLIs from configuration, providing a unified, deduplicated, sorted list, and resolve which CLI to use for each spec-driven branch using a multi-level priority chain that considers command-line flags, per-spec overrides, config defaults, and interactive selection, prompting the user at most once. It also covers reliable boot-block submission and CLI-pane launch hardening: injecting the prompt text literally and then sending Enter as a separate keystroke after a settle delay resolved per CLI from [clis.<name>].submit_delay_ms (with a single CLI-agnostic default and no hardcoded CLI names), clearing the shell input line before the launch command, suppressing known auto-update/confirmation prompts in the launched pane’s environment, and verifying the CLI started within a bounded window and retrying the launch once on failure — so a fresh supervisor session boots and all agents self-register unattended.

Requirements

Requirement: Auto-detect known AI CLIs on PATH

The system SHALL scan PATH for the known CLI binaries: claude, codex, agy, aider, vibe, qwen, amp, opencode, cline, droid, pi, junie, cursor-agent, copilot, cn, kilo, and kimi.

agy is the Antigravity CLI (display name “Antigravity”), which replaces the retired Gemini CLI (gemini) — Gemini CLI stops serving free / AI Pro / AI Ultra users on 2026-06-18. Removing gemini affects auto-detection only; an explicit default_cli = "gemini", a [clis.gemini] custom entry, or a saved-session cli string still resolves as a pass-through.

cursor-agent is Cursor’s agent CLI (display name “Cursor Agent”). The bare cursor binary is the Cursor editor launcher on most installations and SHALL NOT be auto-detected as an AI CLI. Removing cursor affects auto-detection only: an explicit git paw start --cli cursor, a [clis.cursor] custom entry, or a saved-session cli = "cursor" still resolves as a pass-through. The paths that validate a CLI against the detected roster — spec-driven selection, git paw add, and the interactive picker (which default_cli only pre-selects within) — require a [clis.cursor] entry.

Scenario: All known CLIs are present on PATH

  • GIVEN all 17 known CLI binaries exist on PATH
  • WHEN detect_known_clis() is called
  • THEN it SHALL return a CliInfo for each binary with source = Detected, a non-empty display_name, and a valid path

Test: detect::tests::all_known_clis_detected_when_present

Scenario: No known CLIs are present on PATH

  • GIVEN PATH contains no known CLI binaries
  • WHEN detect_known_clis() is called
  • THEN it SHALL return an empty list

Test: detect::tests::returns_empty_when_no_known_clis_on_path

Scenario: Partial set of CLIs on PATH

  • GIVEN only a subset of known CLIs exist on PATH
  • WHEN detect_known_clis() is called
  • THEN it SHALL return only the CLIs that are found

Test: detect::tests::detects_subset_of_known_clis

Scenario: Antigravity CLI is detected under its agy binary name

  • GIVEN the agy binary exists on PATH
  • WHEN detect_known_clis() is called
  • THEN the result SHALL include a CliInfo with binary_name = "agy" and source = Detected
  • AND the result SHALL NOT include a CLI with binary_name = "gemini" unless gemini is supplied as a custom [clis.*] entry

Test: detect::tests::agy_detected_and_gemini_not_known

Scenario: Cursor’s agent CLI is detected under cursor-agent, not the editor launcher

  • GIVEN both a cursor and a cursor-agent binary exist on PATH
  • WHEN detect_known_clis() is called
  • THEN the result SHALL include a CliInfo with binary_name = "cursor-agent", display_name = "Cursor Agent", and source = Detected
  • AND the result SHALL NOT include a CLI with binary_name = "cursor"

Test: detect::tests::cursor_agent_detected_and_editor_launcher_not_known

Scenario: An explicit cursor CLI still passes through

  • GIVEN no cursor entry is auto-detected and no [clis.cursor] entry is configured
  • WHEN a bare session is started with --cli cursor
  • THEN the branch-to-CLI mapping SHALL carry cursor unchanged
  • AND the pane SHALL be launched with cursor

Test: interactive::resolver::tests::explicit_cursor_cli_passes_through_without_detection, config::tests::launch_command_falls_back_to_name_without_entry

Requirement: Resolve and merge custom CLIs from configuration

The system SHALL resolve custom CLI definitions by looking up commands as absolute paths or via PATH, and merge them with auto-detected CLIs.

Scenario: Custom CLIs merged with detected CLIs

  • GIVEN auto-detected CLIs exist and custom CLI definitions are provided
  • WHEN detect_clis() is called
  • THEN the result SHALL contain both detected and custom CLIs

Test: detect::tests::custom_clis_merged_with_detected

Scenario: Custom CLI binary not found

  • GIVEN a custom CLI definition references a non-existent binary
  • WHEN detect_clis() is called
  • THEN the missing CLI SHALL be excluded and a warning printed to stderr

Test: detect::tests::custom_cli_excluded_when_binary_missing

Scenario: Custom CLI resolved by absolute path

  • GIVEN a custom CLI definition uses an absolute path to an existing binary
  • WHEN resolve_custom_clis() is called
  • THEN the resolved path SHALL match the absolute path provided

Test: detect::tests::custom_cli_resolved_by_absolute_path

Requirement: Custom CLIs override detected CLIs with the same name

When a custom CLI has the same binary_name as a detected CLI, the custom definition SHALL take precedence.

Scenario: Custom CLI overrides auto-detected CLI

  • GIVEN a custom CLI shares a binary_name with an auto-detected CLI
  • WHEN detect_clis() is called
  • THEN the result SHALL contain only the custom version with source = Custom

Test: detect::tests::custom_cli_overrides_detected_with_same_binary_name

Requirement: Each CLI result includes all required fields

Every CliInfo SHALL have a non-empty display_name, binary_name, a valid path, and a source indicator.

Scenario: Detected CLI has all fields populated

  • GIVEN a known CLI binary exists on PATH
  • WHEN it is detected
  • THEN all fields (display_name, binary_name, path, source) SHALL be populated

Test: detect::tests::detected_cli_has_all_fields

Scenario: Custom CLI has all fields populated

  • GIVEN a custom CLI definition is resolved
  • WHEN it is included in results
  • THEN all fields SHALL be populated

Test: detect::tests::custom_cli_has_all_fields

Requirement: Display name derivation

When no explicit display name is provided, the system SHALL derive one by capitalizing the first letter of the binary name.

Scenario: Custom CLI defaults to capitalized name

  • GIVEN a custom CLI definition has no display_name
  • WHEN it is resolved
  • THEN the display_name SHALL be the binary name with the first letter capitalized

Test: detect::tests::custom_cli_display_name_defaults_to_capitalised_name

Requirement: Results sorted by display name

The combined CLI list SHALL be sorted alphabetically by display_name (case-insensitive).

Scenario: Results are sorted

  • GIVEN multiple CLIs are detected and/or custom
  • WHEN detect_clis() is called
  • THEN the results SHALL be sorted by display name

Test: detect::tests::results_sorted_by_display_name

Requirement: CliSource display format

The CliSource enum SHALL display as "detected" or "custom".

Scenario: CliSource display strings

  • GIVEN CliSource::Detected and CliSource::Custom
  • WHEN formatted with Display
  • THEN they SHALL render as "detected" and "custom" respectively

Test: detect::tests::cli_source_display_format

Requirement: CLI resolution chain for spec-driven launches

The system SHALL resolve which CLI to use for each spec-driven branch using a 5-level priority chain, from highest to lowest priority. This chain SHALL apply identically to non-supervisor and supervisor-mode spec-driven launches — the --supervisor flag SHALL NOT bypass or alter it. In particular, default_spec_cli SHALL be honoured for spec branches lacking a paw_cli override even when --supervisor is combined with --specs, so supervisor-mode workers are never silently launched with a different (e.g. unsandboxed) CLI than configured.

Scenario: –cli flag overrides everything

  • WHEN --cli claude is passed and specs have various paw_cli values
  • THEN all branches SHALL use "claude" regardless of spec or config values

Scenario: paw_cli in spec overrides config

  • WHEN no --cli flag is passed and a spec has paw_cli: gemini
  • THEN that branch SHALL use "gemini" regardless of default_spec_cli or default_cli

Scenario: default_spec_cli fills remaining without prompt

  • WHEN no --cli flag, some specs have no paw_cli, and default_spec_cli = "claude" in config
  • THEN specs without paw_cli SHALL use "claude" with no interactive prompt

Scenario: default_cli pre-selects in picker

  • WHEN no --cli flag, no paw_cli, no default_spec_cli, and default_cli = "claude" in config
  • THEN the CLI picker SHALL be shown with "claude" pre-selected

Scenario: No defaults — full picker

  • WHEN no --cli flag, no paw_cli, no default_spec_cli, and no default_cli
  • THEN the CLI picker SHALL be shown with no pre-selection

Scenario: default_spec_cli is honoured under –supervisor –specs

  • WHEN git paw start --supervisor --specs a,b runs with no --cli, specs a and b having no paw_cli, and default_spec_cli = "claude-oss" in config
  • THEN the a and b worktree panes SHALL launch the "claude-oss" CLI (not a fallback or default CLI), identical to the non-supervisor --specs resolution

Requirement: Mixed resolution across specs

The system SHALL handle specs where some have paw_cli and others don’t in the same launch.

Scenario: Mix of paw_cli and default_spec_cli

  • WHEN 3 specs are launched, 1 has paw_cli: gemini, and default_spec_cli = "claude"
  • THEN the gemini spec SHALL use "gemini" and the other 2 SHALL use "claude"

Scenario: Mix of paw_cli and interactive

  • WHEN 3 specs are launched, 1 has paw_cli: gemini, no default_spec_cli, and user picks "claude" in the prompt
  • THEN the gemini spec SHALL use "gemini" and the other 2 SHALL use "claude"

Requirement: Prompt at most once

The system SHALL prompt the user for CLI selection at most once during a --from-specs launch, applying the choice to all branches without a paw_cli or default_spec_cli.

Scenario: Single prompt for remaining branches

  • WHEN 5 specs are launched, 2 have paw_cli, and the picker fires for the remaining 3
  • THEN the picker SHALL fire once and the chosen CLI SHALL be applied to all 3

Requirement: Validate resolved CLI names

The system SHALL validate that each resolved CLI name matches an available CLI.

Scenario: paw_cli references unknown CLI

  • WHEN a spec has paw_cli: nonexistent and no CLI named "nonexistent" is available
  • THEN the system SHALL return PawError::CliNotFound("nonexistent")

Scenario: default_spec_cli references unknown CLI

  • WHEN default_spec_cli = "nonexistent" and no such CLI is available
  • THEN the system SHALL return PawError::CliNotFound("nonexistent")

Scenario: –cli flag references unknown CLI

  • GIVEN specs exist
  • WHEN --cli nonexistent is passed and “nonexistent” is not in available CLIs
  • THEN the system SHALL return PawError::CliNotFound("nonexistent")

Requirement: No prompt when fully resolved

The system SHALL not show any interactive prompt when all branches are resolved via --cli, paw_cli, or default_spec_cli.

Scenario: All resolved without prompt

  • WHEN --cli claude is passed
  • THEN no interactive prompt SHALL be shown

Scenario: All resolved via paw_cli and default_spec_cli

  • WHEN every spec has paw_cli or default_spec_cli covers the rest
  • THEN no interactive prompt SHALL be shown

Requirement: Boot prompt submitted via split-send + settle delay

The boot-injection path SHALL inject the boot block into a pane literally and then submit it with a SEPARATE Enter sent after a settle delay, rather than a same-call trailing Enter. This split is what reliably submits a large paste across CLIs (W15-1: a same-call trailing Enter left the boot block unsubmitted on a custom CLI). The mechanism SHALL contain no hardcoded CLI names.

Scenario: Boot block is injected then submitted separately

  • WHEN a boot block is injected into a pane
  • THEN the system SHALL first send the prompt text (literally, no Enter), then after the settle delay send Enter as a separate keystroke

Scenario: Mechanism is CLI-name-free

  • WHEN the submit path is inspected
  • THEN it SHALL NOT branch on any specific CLI name — the same split-send applies to every CLI

Requirement: Settle delay is config-driven with an agnostic default

The settle delay SHALL be resolved per CLI from [clis.<name>].submit_delay_ms, falling back to a single CLI-agnostic default (DEFAULT_SUBMIT_DELAY_MS) for any CLI without an override. The resolver SHALL key on the leading binary token of the CLI command (so a CLI string carrying flags still matches its config entry).

Scenario: Unconfigured CLI uses the agnostic default

  • GIVEN a CLI with no [clis.<name>].submit_delay_ms configured (or no [clis.<name>] entry at all)
  • WHEN the settle delay is resolved
  • THEN it SHALL equal DEFAULT_SUBMIT_DELAY_MS

Scenario: Per-CLI override is honoured

  • GIVEN [clis.mycli].submit_delay_ms = 2500
  • WHEN the settle delay for mycli is resolved
  • THEN it SHALL be 2500

Scenario: Resolver keys on the binary, not the flags

  • GIVEN [clis.mycli].submit_delay_ms = 2500
  • WHEN the delay is resolved for the CLI command "mycli --some-flag"
  • THEN it SHALL be 2500 (the leading token mycli matched the config entry)

Scenario: No CLI name resolves to a hardcoded value

  • GIVEN an empty [clis] config
  • WHEN the delay is resolved for any CLI id (including names that might otherwise be special-cased)
  • THEN every CLI SHALL resolve to the same DEFAULT_SUBMIT_DELAY_MS — there is no built-in per-name table

Requirement: Profile applies to supervisor and agent panes

The split-send + resolved delay SHALL apply to every launched coding-agent pane, each resolved from that agent’s own CLI. No orchestrator pane exists in the session, so no boot block is submitted for an orchestrator CLI; the orchestrator’s CLI is resolved only for git-paw-initiated headless invocations, never for a pane.

Scenario: Coding-agent boot blocks are submitted with their own CLI’s delay

  • GIVEN a supervised session with two coding agents running different CLIs
  • WHEN each agent’s boot block is injected
  • THEN each SHALL be submitted via the split-send using that agent’s CLI’s resolved delay, so the agent begins work without a manual Enter

Scenario: No pane receives an orchestrator boot block

  • GIVEN a supervised session with [supervisor] cli set
  • WHEN the launch injects boot blocks
  • THEN no split-send SHALL target a pane with the orchestrator CLI’s resolved delay

Requirement: End-to-end boot registration

The system SHALL boot a fresh supervisor session such that all coding agents register with the broker without a human Enter, for any CLI given an adequate settle delay (default or configured). The agents’ boot publish goes through the bundled broker.sh helper at its stable path. Whether that call runs without a permission prompt is decided by the agent CLI’s own permission system: its resolved permission mode, or an allow rule for the helper’s exact path in that CLI’s settings. Registration SHALL NOT depend on a git-paw command classifier. When the CLI prompts for the helper, git-paw SHALL NOT answer the prompt; under --unattended the drive loop escalates it (supervisor-unattended-operation).

Scenario: All agents register unattended

  • GIVEN a fresh supervisor session with N agents and broker enabled, using a CLI whose settle delay is adequate and whose permission system lets the broker.sh helper run
  • WHEN the session launches
  • THEN within a bounded window the broker /status SHALL list all N coding agents (plus the supervisor) with no human Enter or permission approval required

Scenario: A prompted boot publish is escalated, not answered

  • GIVEN an --unattended session whose agent CLI raises a permission prompt for its broker.sh boot publish
  • WHEN the drive loop sweeps that pane
  • THEN the prompt SHALL be escalated
  • AND no keystroke SHALL be sent to that pane

Requirement: Clean the shell input line before the CLI-launch command

The system SHALL ensure a pane’s shell input line is clean before sending the CLI-launch command — by sending a clearing keystroke (e.g. C-u/C-c) and/or a leading newline — so a pending shell startup prompt (auto-update confirmation, MOTD, etc.) cannot swallow the leading character of the launch command and strand the pane at a bare shell.

Scenario: Launch keystroke is not corrupted by a startup prompt

  • GIVEN a pane whose interactive shell shows a startup prompt (e.g. [oh-my-zsh] Would you like to update? [Y/n]) at launch time
  • WHEN git-paw sends the CLI-launch command
  • THEN the pane SHALL clear the pending prompt first so the full launch command (not a keystroke-truncated variant like laude-oss) reaches the shell and the CLI starts

Requirement: Suppress shell startup prompts in the launched pane

The system SHALL suppress known shell auto-update / confirmation prompts in the pane it launches where it controls the pane environment (e.g. exporting DISABLE_AUTO_UPDATE=true or the equivalent), so such a prompt cannot fire mid-launch. The system SHALL NOT modify the user’s global shell configuration.

Scenario: Auto-update prompt suppressed for the launched pane

  • WHEN git-paw launches a CLI pane
  • THEN it SHALL set the pane environment so the shell’s auto-update prompt does not fire during launch, without editing the user’s ~/.zshrc or global oh-my-zsh settings

Requirement: Verify the CLI started and retry once

The system SHALL verify, within a bounded window after the launch keystroke, that the pane’s CLI actually started (the shell prompt was replaced by the CLI), and SHALL retry the launch once if the first attempt did not take.

Scenario: Failed launch is retried

  • GIVEN a pane where the first CLI-launch attempt did not start the CLI (the shell prompt is still present after the bounded window)
  • THEN git-paw SHALL send the launch command once more before giving up, so a single swallowed attempt does not permanently strand the pane

Requirement: README Supported AI CLIs table matches src/detect.rs

The README’s Supported AI CLIs table SHALL list every CLI binary in src/detect.rs’s known-CLI roster, and SHALL NOT list a binary that the roster does not auto-detect. The mdBook docs/src/supported-clis.md table SHALL carry the same binary set.

Scenario: README CLI table mentions opencode, cline, and droid

  • WHEN the README’s Supported AI CLIs table is inspected
  • THEN it contains the substring opencode
  • AND it contains the substring cline
  • AND it contains the substring droid

Test: tests/supported_clis_docs_sync.rs::readme_and_book_tables_match_known_cli_roster (opencode, cline, and droid are roster members, so the per-binary missing-cell assertion covers them)

Scenario: README and mdBook CLI tables list every roster binary and only those

  • WHEN the README’s Supported AI CLIs table and docs/src/supported-clis.md’s table are inspected
  • THEN each SHALL contain a `<binary>` cell for every binary in the known-CLI roster, including `cursor-agent`
  • AND neither SHALL contain a `cursor` binary cell

Test: tests/supported_clis_docs_sync.rs::readme_and_book_tables_match_known_cli_roster

Requirement: Per-CLI session-id and resume argument templates

The [clis.<name>] table SHALL accept two optional string templates, session_id_args (arguments that assign a session identifier at launch) and resume_args (arguments that resume an existing session), each containing the placeholder {{SESSION_ID}}. Resolution for a CLI SHALL consult, in order: (1) the [clis.<name>] override when present; (2) the built-in table; (3) no template. The built-in table SHALL contain, at minimum, claude → session_id_args = "--session-id {{SESSION_ID}}" and resume_args = "--resume {{SESSION_ID}}"; every CLI without a built-in row and without an override SHALL resolve to no template. A template that omits {{SESSION_ID}} SHALL be rejected at config load with an error naming the key. The built-in rows SHALL be verified against the CLI’s upstream documentation at implementation time.

Scenario: Claude resolves the built-in templates

  • WHEN templates are resolved for claude with no config override
  • THEN session_id_args SHALL render to --session-id <id> and resume_args to --resume <id> for the given id

Scenario: Override wins over the built-in row

  • GIVEN [clis.claude] defining resume_args = "--continue-session {{SESSION_ID}}"
  • WHEN the resume template is resolved for claude
  • THEN it SHALL render to --continue-session <id>

Scenario: Unknown CLI resolves no template

  • WHEN templates are resolved for my-agent with no [clis.my-agent] templates
  • THEN both session_id_args and resume_args SHALL resolve to none

Scenario: Template without the placeholder is rejected

  • GIVEN [clis.my-agent] defining session_id_args = "--session" (no {{SESSION_ID}})
  • WHEN the config is loaded
  • THEN loading SHALL fail with an error naming session_id_args

Requirement: Worker session id is assigned at launch

The system SHALL assign each worker a session identifier at launch: when the launch flow attaches a worker (initial start, spec-driven start, git paw add) and the worker’s CLI resolves a session_id_args template, it SHALL generate a fresh version-4-formatted UUID from OS-provided randomness, append the rendered session_id_args to the CLI launch command after any approval flags, and record the id in the worktree’s cli_session_id. When the CLI resolves no session_id_args template, the launch command SHALL be unchanged and cli_session_id SHALL remain None. The identifier SHALL be generated without adding a dependency.

Scenario: Claude worker is launched with an assigned id

  • GIVEN a worker on feat/x with cli = "claude" and no override
  • WHEN the launch command is built
  • THEN it SHALL contain --session-id <uuid> where <uuid> matches the v4 UUID shape
  • AND the saved WorktreeEntry for feat/x SHALL have cli_session_id = Some(<uuid>)

Scenario: Each worker gets a distinct id

  • GIVEN three Claude workers launched in one session
  • WHEN their cli_session_id values are compared
  • THEN all three SHALL differ

Scenario: CLI without an assignment template launches unchanged

  • GIVEN a worker with cli = "codex" and no [clis.codex].session_id_args
  • WHEN the launch command is built
  • THEN it SHALL be identical to the pre-change command
  • AND the saved entry’s cli_session_id SHALL be None

Scenario: Assignment follows approval flags

  • GIVEN a Claude worker at agent_approval = "full-auto"
  • WHEN the launch command is built
  • THEN --dangerously-skip-permissions SHALL precede --session-id <uuid> in the command

Requirement: Panes launch the configured command of a CLI

Every launch path that spawns an AI CLI into a tmux pane — the bare start, the spec-driven start, the supervisor pane, git paw add, and session recovery (worker and supervisor panes) — SHALL build the pane’s launch command from the CLI’s resolved [clis.<name>].command, not from the [clis.<name>] key. The leading whitespace-delimited token of the CLI identity is looked up in the merged [clis] table; when it names an entry, that token SHALL be replaced by the entry’s command and any trailing text SHALL be preserved verbatim. When it names no entry (a built-in detected CLI, or an explicit pass-through name), the identity SHALL be used unchanged.

The substituted command SHALL be emitted as exactly one shell word that the pane’s shell resolves to the configured string: it SHALL be single-quoted (with embedded single quotes escaped) whenever it contains any character outside the portable safe set A-Z a-z 0-9 _ - . / + , : @ %, so a path containing spaces or shell metacharacters is neither word-split nor expanded. Approval flags and session-id / resume arguments SHALL be appended after the substituted command exactly as they are appended after the bare CLI today.

Scenario: A custom CLI whose key differs from its command launches the command

  • GIVEN [clis.claude-variant] command = "claude"
  • WHEN a session is started with a worker on feat/x using CLI claude-variant
  • THEN the pane launch command SHALL begin with claude
  • AND it SHALL NOT contain claude-variant

Test: tests/cli_launch_identity_e2e.rs::worker_pane_launches_configured_command_not_key

Scenario: A CLI without a [clis] entry launches by name

  • GIVEN no [clis.codex] entry
  • WHEN a worker is attached with CLI codex
  • THEN the pane launch command SHALL be codex exactly as before this change

Test: config::tests::launch_command_falls_back_to_name_without_entry

Scenario: A command path containing a space is one shell word

  • GIVEN [clis.my-agent] command = "/opt/My Tools/my-agent" and an executable stub at that path
  • WHEN the launch command is built and run through a real POSIX shell
  • THEN the launch command SHALL begin with '/opt/My Tools/my-agent'
  • AND the shell SHALL execute the stub at that exact path (not /opt/My with an argument)

Test: config::tests::launch_command_quotes_path_with_space_as_one_shell_word

Scenario: Trailing text after the identity is preserved

  • GIVEN [clis.claude-oss] command = "/usr/local/bin/claude"
  • WHEN the launch command is built for the identity claude-oss --model opus
  • THEN it SHALL be /usr/local/bin/claude --model opus

Test: config::tests::launch_command_preserves_trailing_text

Scenario: Supervisor pane launches the configured command

  • GIVEN [supervisor] cli = "claude-oss" and [clis.claude-oss] command = "claude" with approval_args resolving a non-empty flag for the supervisor’s approval level
  • WHEN the supervisor session launches
  • THEN pane 0’s launch command SHALL be claude followed by that flag

Test: tests/cli_launch_identity_e2e.rs::supervisor_pane_launches_configured_command

Scenario: Recovery relaunches the configured command

  • GIVEN a saved session whose worktree entry has cli = "claude-oss" and cli_session_id = Some(<id>), and [clis.claude-oss] command = "claude" with a resolvable resume_args
  • WHEN the session is recovered
  • THEN the worker pane’s launch command SHALL begin with claude followed by the rendered resume arguments

Test: tests/cli_launch_identity_e2e.rs::recovery_launches_configured_command_with_resume_args

Requirement: The CLI identity, not the launch command, keys per-CLI resolution

The [clis.<name>] key (or the built-in / pass-through CLI name) SHALL remain the CLI’s identity. Approval-flag resolution, session-id and resume template resolution, prompt-profile resolution (including the readiness gate’s choice of readiness markers), settle-delay resolution, MCP-capability resolution, and the cli recorded in each saved WorktreeEntry SHALL all be keyed by that identity, never by the substituted launch command. The readiness gate SHALL relaunch the substituted launch command when a pane is still a bare shell.

Scenario: Saved worktree entry records the key

  • GIVEN [clis.claude-oss] command = "claude"
  • WHEN a session is started with a worker using CLI claude-oss
  • THEN the saved session’s WorktreeEntry.cli for that worker SHALL be claude-oss

Test: tests/cli_launch_identity_e2e.rs::saved_session_records_cli_key

Scenario: Per-CLI overrides resolve against the key

  • GIVEN [clis.claude-oss] command = "claude" with approval_args = { auto = "--paw-oss-sentinel" }
  • WHEN a session is started with a worker using CLI claude-oss at agent_approval = "auto"
  • THEN the pane launch command SHALL be claude --paw-oss-sentinel

Test: tests/cli_launch_identity_e2e.rs::worker_overrides_resolve_by_key_and_launch_command

Scenario: Readiness gate uses the identity’s prompt profile

  • GIVEN [clis.claude-oss] command = "claude" with a prompt_profile.readiness_markers override
  • WHEN the readiness gate polls the pane for CLI identity claude-oss
  • THEN it SHALL classify readiness using the override’s markers
  • AND a bare-shell relaunch SHALL send the launch command claude

Test: tmux::tests::gate_pane_resolves_profile_by_identity_and_relaunches_command

Requirement: Per-CLI headless invocation template

The [clis.<name>] table SHALL accept an optional string headless_args giving the arguments that make the CLI run one non-interactive turn reading its prompt from stdin. Resolution for a CLI SHALL consult, in order: (1) the [clis.<name>] override; (2) the built-in table, which SHALL contain at minimum claude → -p and codex → exec; (3) none. The built-in rows SHALL be verified against each CLI’s upstream documentation at implementation time. An empty override string SHALL be rejected at config load with an error naming the key.

Scenario: Claude and Codex resolve through the built-in table

  • WHEN headless_args is resolved for claude and for codex with no overrides
  • THEN the results SHALL be -p and exec respectively

Scenario: Override wins over the built-in row

  • GIVEN [clis.claude] defining headless_args = "-p --output-format text"
  • WHEN headless_args is resolved for claude
  • THEN the result SHALL be -p --output-format text

Scenario: Unknown CLI resolves none

  • WHEN headless_args is resolved for my-agent with no [clis.my-agent].headless_args
  • THEN the result SHALL be none

Scenario: Empty override is rejected

  • GIVEN [clis.my-agent] headless_args = ""
  • WHEN the config is loaded
  • THEN loading SHALL fail with an error naming headless_args

Git Operations

Purpose

Validate git repositories, list branches, create and remove worktrees, and derive worktree directory names. Provides the git plumbing that underpins parallel branch sessions.

Requirements

Requirement: Validate that a path is inside a git repository

The system SHALL confirm a path is inside a git repository and return the repository root.

Scenario: Path is inside a git repository

  • GIVEN a path inside an initialized git repository
  • WHEN validate_repo() is called
  • THEN it SHALL return Ok with the absolute path to the repository root

Test: git::tests::validate_repo_returns_root_inside_repo

Scenario: Path is not inside a git repository

  • GIVEN a path that is not inside any git repository
  • WHEN validate_repo() is called
  • THEN it SHALL return Err(PawError::NotAGitRepo)

Test: git::tests::validate_repo_returns_not_a_git_repo_outside

Requirement: List branches sorted and deduplicated

The system SHALL list all local and remote branches, deduplicated, sorted, with remote prefixes stripped and HEAD pointers excluded.

Scenario: Branches are returned sorted

  • GIVEN a repository with multiple branches
  • WHEN list_branches() is called
  • THEN it SHALL return branches sorted alphabetically

Test: git::tests::list_branches_returns_sorted_branches

Scenario: Local and remote branches are deduplicated with prefix stripping

  • GIVEN a repository cloned from a remote, with branches existing both locally and as remote-tracking refs
  • WHEN list_branches() is called
  • THEN each branch SHALL appear exactly once, with origin/ prefixes stripped

Test: git_integration::list_branches_strips_remote_prefix_and_deduplicates

Requirement: Derive project name from repository path

The system SHALL extract the project name from the final component of the repository root path, falling back to "project" for root paths.

Scenario: Normal repository path

  • GIVEN a repository at /Users/jie/code/git-paw
  • WHEN project_name() is called
  • THEN it SHALL return "git-paw"

Test: git::tests::project_name_from_path

Scenario: Root path fallback

  • GIVEN a repository at /
  • WHEN project_name() is called
  • THEN it SHALL return "project"

Test: git::tests::project_name_fallback_for_root

Requirement: Build worktree directory names

The system SHALL generate worktree directory names as <project>-<sanitized-branch>, replacing / with - and stripping unsafe characters.

Scenario: Branch with single slash

  • GIVEN project "git-paw" and branch "feature/auth-flow"
  • WHEN worktree_dir_name() is called
  • THEN it SHALL return "git-paw-feature-auth-flow"

Test: git::tests::worktree_dir_name_replaces_slash_with_dash

Scenario: Branch with multiple slashes

  • GIVEN project "git-paw" and branch "feat/auth/v2"
  • WHEN worktree_dir_name() is called
  • THEN it SHALL return "git-paw-feat-auth-v2"

Test: git::tests::worktree_dir_name_handles_multiple_slashes

Scenario: Branch with special characters

  • GIVEN project "my-proj" and branch "fix/issue#42"
  • WHEN worktree_dir_name() is called
  • THEN unsafe characters SHALL be stripped, returning "my-proj-fix-issue42"

Test: git::tests::worktree_dir_name_strips_special_chars

Scenario: Simple branch name

  • GIVEN project "git-paw" and branch "main"
  • WHEN worktree_dir_name() is called
  • THEN it SHALL return "git-paw-main"

Test: git::tests::worktree_dir_name_simple_branch

Requirement: Create worktrees as siblings of the repository

The system SHALL create git worktrees at a path resolved from the configured worktree_placement setting. When worktree_placement is "child", the target path SHALL be <repo_root>/.git-paw/worktrees/<branch-slug> and the .git-paw/worktrees/ directory SHALL be created if absent. When worktree_placement is "sibling" OR the setting is absent, the target path SHALL be <repo_parent>/<project>-<branch-slug> using the derived directory name convention, matching the v0.7.0 sibling layout. Only the resolved target path varies with placement; all other behaviour described below is unchanged.

The create_worktree function SHALL accept a rebase_onto_main: bool parameter. When rebase_onto_main is true AND the target branch already exists in the local repository, the function SHALL rebase the target branch onto the repository’s default branch (as returned by default_branch()) BEFORE performing the existence check for the worktree directory. The rebase SHALL be performed by invoking git rebase <default-branch> from the repository root. When the branch is already at or ahead of the default branch, git rebase exits zero with no rewrite; the function SHALL treat that as success.

If git rebase exits non-zero (rebase conflict or any other failure), the function SHALL invoke git rebase --abort in the repository root and return Err(PawError::WorktreeError("rebase onto main failed: <stderr>")). The branch SHALL be left at its pre-rebase HEAD after the abort; the function SHALL NOT proceed to the existence check or git worktree add when the rebase failed.

If rebase_onto_main is false, the function SHALL skip the rebase block entirely and behave identically to the post-worktree-resume-fix v0.5.0 contract (idempotent existence check followed by git worktree add).

If the target branch does NOT exist in the local repository at the time create_worktree is invoked, the function SHALL skip the rebase block regardless of the rebase_onto_main value and proceed to the existing git worktree add -b <branch> fallback, which creates the branch from current HEAD (already at the default branch tip by construction).

The system SHALL be idempotent in the resume case: when create_worktree() is invoked for a branch whose worktree already exists at the expected path AND is registered with git for that branch, the function SHALL return Ok(WorktreeCreation { path, branch_created: false }) without re-running git worktree add. Idempotency is verified by parsing git worktree list --porcelain output and matching both the worktree path and the refs/heads/<branch> line. When rebase_onto_main is true, the rebase block runs BEFORE this idempotency check, so a surviving worktree’s branch ref SHALL be updated to the rebased SHA before the function returns.

If the expected path exists on disk but is NOT a git worktree registered for the specified branch (e.g. an unrelated directory, or a worktree for a different branch), the function SHALL fall through to the existing git worktree add call so the user sees the actionable fatal: '<path>' already exists error from git directly.

Scenario: Worktree created at correct path

  • GIVEN a repository with a branch feature/test
  • WHEN create_worktree() is called with rebase_onto_main = false
  • THEN a worktree SHALL be created at ../<project>-feature-test containing the repository files

Scenario: Worktree created under child placement

  • GIVEN a repository whose effective config has worktree_placement = "child" and a branch feature/test
  • WHEN create_worktree() is called with rebase_onto_main = false
  • THEN a worktree SHALL be created at <repo_root>/.git-paw/worktrees/feature-test containing the repository files

Scenario: Worktree created under sibling placement

  • GIVEN a repository whose effective config has worktree_placement = "sibling" and a branch feature/test
  • WHEN create_worktree() is called with rebase_onto_main = false
  • THEN a worktree SHALL be created at ../<project>-feature-test

Scenario: Worktree created under absent placement defaults to sibling

  • GIVEN a repository whose effective config has no worktree_placement field and a branch feature/test
  • WHEN create_worktree() is called with rebase_onto_main = false
  • THEN a worktree SHALL be created at ../<project>-feature-test

Scenario: Creating worktree for currently checked-out branch fails

  • GIVEN the current branch is checked out in the main repo
  • WHEN create_worktree() is called for that branch
  • THEN it SHALL return Err(PawError::WorktreeError)

Scenario: Resume of an existing worktree returns success without re-running git worktree add

  • GIVEN a worktree already exists at ../<project>-feature-test for branch feature/test from a prior session
  • AND rebase_onto_main = false is passed
  • WHEN create_worktree() is called for feature/test
  • THEN the function SHALL return Ok(WorktreeCreation { path: <expected>, branch_created: false })
  • AND the existing worktree SHALL remain unchanged (HEAD SHA, working tree files, and uncommitted changes preserved)
  • AND no second git worktree add SHALL be executed

Scenario: Path exists but is not a git worktree

  • GIVEN the expected worktree path ../<project>-feature-test exists as a regular directory (not registered with git)
  • WHEN create_worktree() is called for branch feature/test
  • THEN the function SHALL return Err(PawError::WorktreeError) whose message contains the substring already exists

Scenario: Path exists as a worktree but for a different branch

  • GIVEN a worktree already exists at ../<project>-feature-test but registered for branch feature/other
  • WHEN create_worktree() is called for branch feature/test
  • THEN the function SHALL fall through to git worktree add and return Err(PawError::WorktreeError) (preserving the v0.4 contract for unrelated path collisions)

Scenario: Rebase-on-resume happy path advances branch onto current main

  • GIVEN the default branch main has advanced by N commits since branch feat/example was created
  • AND feat/example exists locally and is behind main by exactly N commits with no diverging commits of its own
  • WHEN create_worktree() is called for feat/example with rebase_onto_main = true
  • THEN the function SHALL invoke git rebase <main> against feat/example in the repository root
  • AND the rebase SHALL succeed
  • AND feat/example’s HEAD SHA after the call SHALL be reachable from main (i.e. include the N new commits)
  • AND the function SHALL return Ok(WorktreeCreation { path: <expected>, branch_created: false })

Scenario: Rebase skipped when branch is already up-to-date

  • GIVEN branch feat/example exists locally and is at the same SHA as main (no divergence)
  • WHEN create_worktree() is called for feat/example with rebase_onto_main = true
  • THEN git rebase <main> SHALL be invoked and SHALL exit zero with no rewrite
  • AND feat/example’s HEAD SHA SHALL be unchanged
  • AND the function SHALL return Ok(WorktreeCreation { path: <expected>, branch_created: false })
  • AND no error SHALL be returned

Scenario: Rebase conflict aborts cleanly and surfaces error

  • GIVEN branch feat/example and main both modify the same line of the same file with different content
  • WHEN create_worktree() is called for feat/example with rebase_onto_main = true
  • THEN git rebase <main> SHALL be invoked and SHALL exit non-zero with conflict markers
  • AND the function SHALL invoke git rebase --abort
  • AND feat/example’s HEAD SHA after the call SHALL equal its pre-call HEAD SHA
  • AND no .git/rebase-merge or .git/rebase-apply directory SHALL remain in the repository
  • AND the function SHALL return Err(PawError::WorktreeError(msg)) where msg contains the substring rebase onto main failed
  • AND the worktree directory at ../<project>-feat-example SHALL NOT have been created (or, if it existed from a prior session, SHALL be unchanged)

Scenario: rebase_onto_main = false preserves v0.5 no-rebase behaviour

  • GIVEN branch feat/example exists locally and is behind main by 3 commits
  • WHEN create_worktree() is called for feat/example with rebase_onto_main = false
  • THEN no git rebase invocation SHALL occur
  • AND feat/example’s HEAD SHA after the call SHALL equal its pre-call HEAD SHA
  • AND the function SHALL proceed to the existence check and (if applicable) git worktree add, matching the post-worktree-resume-fix v0.5.0 contract exactly

Scenario: New branch creation skips rebase regardless of flag

  • GIVEN branch feat/new does NOT exist in the local repository
  • AND rebase_onto_main = true is passed
  • WHEN create_worktree() is called for feat/new
  • THEN no git rebase invocation SHALL occur (there is nothing to rebase)
  • AND the function SHALL invoke git worktree add -b feat/new <path> to create the branch from current HEAD
  • AND the function SHALL return Ok(WorktreeCreation { path, branch_created: true })

Requirement: Remove worktrees and prune stale entries

The system SHALL force-remove a worktree and prune stale git worktree metadata. remove_worktree SHALL pass --force to git worktree remove so a worktree containing uncommitted modifications, untracked files, or both is still deleted; the function is only called from the destructive purge path, where leaving worktree directories on disk after the user already opted into a destructive operation is the wrong behaviour.

Scenario: Worktree fully cleaned up after removal

  • GIVEN an existing worktree
  • WHEN remove_worktree() is called
  • THEN the directory SHALL be deleted and git SHALL no longer track it

Test: git::tests::remove_worktree_cleans_up_fully

Scenario: Dirty worktree is force-removed

  • GIVEN an existing worktree containing both a modified tracked file and an untracked file
  • WHEN remove_worktree() is called
  • THEN the call SHALL succeed
  • AND the worktree directory SHALL be deleted from disk
  • AND git SHALL no longer track the worktree

Test: git_integration::remove_worktree_force_removes_dirty_worktree

Requirement: Repository validation SHALL work against real git repos

Scenario: Succeeds inside a real git repo

  • GIVEN a temporary git repository with an initial commit
  • WHEN validate_repo() is called
  • THEN it SHALL return the canonicalized repo root

Test: git_integration::validate_repo_succeeds_inside_git_repo

Scenario: Fails outside a git repo

  • GIVEN a temporary directory that is not a git repo
  • WHEN validate_repo() is called
  • THEN it SHALL return an error

Test: git_integration::validate_repo_fails_outside_git_repo

Requirement: Branch listing SHALL work against real git repos

Scenario: Lists created branches

  • GIVEN a repo with branches feature/auth and fix/db
  • WHEN list_branches() is called
  • THEN both branches SHALL appear in the result

Test: git_integration::list_branches_includes_created_branches

Scenario: Branches are sorted

  • GIVEN branches created in non-alphabetical order
  • WHEN list_branches() is called
  • THEN results SHALL be alphabetically sorted

Test: git_integration::list_branches_returns_sorted

Scenario: Deduplicates local and remote

  • GIVEN a repository with a default branch
  • WHEN list_branches() is called
  • THEN each branch SHALL appear exactly once

Test: git_integration::list_branches_deduplicates_local_and_remote

Requirement: Worktree lifecycle SHALL work against real git repos

Scenario: Create and remove worktree

  • GIVEN a branch in a temporary repo
  • WHEN create_worktree() then remove_worktree() are called
  • THEN the worktree SHALL exist after creation and be gone after removal

Test: git_integration::create_and_remove_worktree

Scenario: Worktree placed as sibling of repo

  • GIVEN a repo at <sandbox>/test-repo/
  • WHEN create_worktree() is called
  • THEN the worktree SHALL be in the same parent directory

Test: git_integration::worktree_placed_as_sibling_of_repo

Scenario: Fails for checked-out branch

  • GIVEN the currently checked-out branch
  • WHEN create_worktree() is called for it
  • THEN it SHALL fail

Test: git_integration::create_worktree_fails_for_checked_out_branch

Requirement: Directory naming SHALL be correct in integration tests

Scenario: Project name from real repo path

  • GIVEN a repo at .../test-repo/
  • WHEN project_name() is called
  • THEN it SHALL return "test-repo"

Test: git_integration::project_name_from_repo_path

Scenario: Worktree dir name replaces slashes

  • WHEN worktree_dir_name("my-project", "feature/auth-flow") is called
  • THEN it SHALL return "my-project-feature-auth-flow"

Test: git_integration::worktree_dir_name_replaces_slashes

Scenario: Worktree dir name strips unsafe chars

  • WHEN worktree_dir_name("proj", "feat/special@chars!") is called
  • THEN @ and ! SHALL be stripped

Test: git_integration::worktree_dir_name_strips_unsafe_chars

Scenario: Worktree dir name handles nested slashes

  • WHEN worktree_dir_name("proj", "feature/deep/nested/branch") is called
  • THEN it SHALL return "proj-feature-deep-nested-branch"

Test: git_integration::worktree_dir_name_handles_nested_slashes

Requirement: Worktree creation produces a usable worktree path

The create_worktree function SHALL create a git worktree for the given branch and return its path. Callers MAY perform post-creation setup (such as AGENTS.md generation) using the returned path.

Scenario: Worktree created at correct path

  • GIVEN a git repo and a branch name
  • WHEN create_worktree() is called
  • THEN it SHALL return the path to the new worktree as a sibling of the repo directory

Test: git::tests::create_worktree_at_correct_path

Scenario: Worktree creation fails for checked-out branch

  • GIVEN a branch that is currently checked out
  • WHEN create_worktree() is called
  • THEN it SHALL return a PawError::WorktreeError

Test: git::tests::create_worktree_errors_on_checked_out_branch

Requirement: Default-branch resolution is single-source and tolerant of a missing origin/HEAD

The system SHALL resolve the repository’s default branch through a single resolver used by every launch path (initial start, resume, add, and the rebase step), so no two paths can disagree. When refs/remotes/origin/HEAD is not set — e.g. a repository with local commits but no configured remote HEAD — the resolver SHALL fall back deterministically (a local main, else a local master, else the checked-out branch via git symbolic-ref --short HEAD) rather than aborting. A launch that succeeded initially SHALL therefore also succeed on resume in the same repository state.

Scenario: Resolution succeeds without origin/HEAD

  • GIVEN a repository with local commits and no refs/remotes/origin/HEAD set
  • WHEN the default branch is resolved
  • THEN it SHALL return a valid local default branch (via the fallback) rather than aborting with an error

Scenario: First launch and resume resolve identically

  • GIVEN a repository whose first git paw start resolved a default branch
  • WHEN the session is later resumed (a second git paw start) in the same repository state
  • THEN the resume SHALL resolve the same default branch through the same resolver and SHALL NOT fail where the first launch succeeded

Scenario: origin/HEAD is still honoured when present

  • GIVEN a repository with refs/remotes/origin/HEAD pointing at origin/main
  • WHEN the default branch is resolved
  • THEN it SHALL return main (the configured remote HEAD), unchanged from prior behaviour

Requirement: Mid-session rebase of a live worker branch

The system SHALL provide a rebase entry point that rebases a named branch onto the repository’s default branch while that branch is checked out in a live worktree, separate from create_worktree’s creation-time rebase_onto_main path. The entry point SHALL run the rebase inside the worktree where the branch is currently checked out. On a non-zero git rebase exit it SHALL invoke git rebase --abort and return an error, leaving the branch at its pre-rebase HEAD. When the branch is already at or ahead of the default branch, git rebase exits zero with no rewrite and the entry point SHALL treat that as success.

The creation-time contract of create_worktree (including its rebase_onto_main parameter, its ordering relative to the existence check, and its idempotent resume behaviour) SHALL be unchanged by this addition.

Scenario: Rebase runs in the worktree holding the branch

  • GIVEN a branch checked out in a live worktree
  • WHEN the mid-session rebase entry point is invoked for that branch
  • THEN the rebase SHALL be run inside that worktree

Scenario: Failed mid-session rebase aborts and restores

  • GIVEN a mid-session rebase that exits non-zero
  • WHEN the failure is detected
  • THEN git rebase --abort SHALL be invoked
  • AND the branch SHALL be left at its pre-rebase HEAD
  • AND an error SHALL be returned

Scenario: Branch already current is a successful no-op

  • GIVEN a branch already at or ahead of the default branch
  • WHEN the mid-session rebase entry point is invoked for that branch
  • THEN it SHALL return success
  • AND the branch SHALL NOT be rewritten

Scenario: Creation-time rebase contract is unaffected

  • WHEN create_worktree() is called with rebase_onto_main = true for an existing branch
  • THEN its behaviour SHALL be identical to the behaviour before the mid-session entry point was added

Tmux Orchestration

Purpose

Orchestrate tmux sessions with multiple panes, each running an AI CLI in a git worktree. Uses a builder pattern for testability and dry-run support, with configurable mouse mode and automatic tiled layout.

Requirements

Requirement: Check tmux availability with actionable error

The system SHALL verify tmux is installed on PATH and provide install instructions if missing.

Scenario: tmux is present on PATH

  • GIVEN tmux is installed
  • WHEN ensure_tmux_installed() is called
  • THEN it SHALL return Ok(())

Test: tmux::tests::ensure_tmux_installed_succeeds_when_present

Requirement: Create named sessions derived from project name

The system SHALL name tmux sessions as paw-<project_name>.

Scenario: Session named after project

  • GIVEN project name "my-project"
  • WHEN a session is built
  • THEN the session name SHALL be "paw-my-project"

Test: tmux::tests::session_is_named_after_project

Scenario: Session creation command uses correct name

  • GIVEN project name "app"
  • WHEN a session is built
  • THEN the commands SHALL include new-session with paw-app

Test: tmux::tests::session_creation_command_uses_session_name

Requirement: Session creation passes explicit dimensions for headless environments

Both session builders (the basic TmuxSessionBuilder and build_supervisor_session) SHALL emit tmux new-session with -x 200 -y 50 so the session has explicit window dimensions when created without an attached client. The user’s real terminal resizes the session on tmux attach.

Without explicit dimensions, tmux on Linux (apt-shipped tmux 3.4+) errors with size missing on subsequent split-window operations because the layout engine can’t resolve percentages without a known window size.

Additionally, immediately after the new-session command, both builders SHALL emit tmux set-option -g default-size 200x50. This pins the global default-size so subsequent split-window / resize-pane operations have a fallback size context even when no client is attached. macOS tmux honours per-session -x/-y for splits; Linux tmux 3.4+ requires the server-level fallback.

Scenario: Basic session passes -x/-y to new-session

  • GIVEN any TmuxSessionBuilder with one or more panes
  • WHEN command_strings() is invoked
  • THEN the first command in the output SHALL be a new-session containing the substrings -x 200 and -y 50

Scenario: Basic session sets global default-size after new-session

  • GIVEN any TmuxSessionBuilder with one or more panes
  • WHEN command_strings() is invoked
  • THEN the second command in the output SHALL be set-option -g default-size 200x50

Scenario: Supervisor session passes -x/-y to new-session

  • GIVEN a build_supervisor_session invocation with supervisor + dashboard + N agent panes
  • WHEN the command list is built
  • THEN the first emitted command SHALL be a new-session containing -x 200 and -y 50

Scenario: Supervisor session sets global default-size after new-session

  • GIVEN a build_supervisor_session invocation
  • WHEN the command list is built
  • THEN the second emitted command SHALL be set-option -g default-size 200x50

Scenario: Headless supervisor launch succeeds under socket isolation

  • GIVEN a cold tmux server (no pre-existing client, isolated socket via TMUX_TMPDIR)
  • WHEN git paw start --supervisor --branches a,b is invoked
  • THEN the supervisor session SHALL launch successfully
  • AND stderr SHALL NOT contain Tmux error: size missing
  • AND the exit code SHALL be 0

Test: tmux::tests::built_session_can_be_executed_and_killed, tmux::tests::supervisor_top_row_split_50_50, and the cli_supervisor_no_config::supervisor_without_section_uses_default_when_default_cli_present integration test.

Scenario: Supervisor splits use -l <N>% syntax (tmux 3.1+) not deprecated -p <N>

  • GIVEN a build_supervisor_session invocation with any agent count
  • WHEN the command list is built
  • THEN every emitted split-window command SHALL use the -l <N>% length flag, NOT the deprecated -p <N> percentage flag

Rationale: Linux apt-tmux 3.4 (Ubuntu 24.04) emits cmd-split-window.c: "size missing" when -p cannot resolve the percentage against the parent pane’s laid-out size — on a detached server with no attached client the pane geometry is unresolved. -l <N>% resolves against the window’s -y dimension (set by new-session -x 200 -y 50) instead, which is well-defined in headless mode. macOS tmux 3.6a tolerates either form.

Test: tmux::tests::supervisor_splits_use_l_percent_not_p.

Requirement: Session name override via builder

The builder SHALL support overriding the default paw-<project> session name with a custom name.

Scenario: Override replaces default name

  • GIVEN session_name("custom-session-name") is set on the builder
  • WHEN the session is built
  • THEN the session name SHALL be "custom-session-name" and commands SHALL target it

Test: tmux::tests::session_name_override_replaces_default

Requirement: Dynamic pane count matches input

The number of panes in the session’s window 0 SHALL match the number of PaneSpec entries added via the builder. When broker is enabled, the builder SHALL receive an additional PaneSpec for the dashboard in pane 0, increasing the total pane count by one. A detached service window (the broker host’s broker window) is not part of this count.

Scenario: Two agent panes plus dashboard created

  • GIVEN broker is enabled and 2 agent pane specs are added
  • WHEN the session is built
  • THEN exactly 3 panes SHALL exist in window 0: pane 0 (dashboard) + panes 1-2 (agents)

Scenario: Two panes without broker

  • GIVEN broker is disabled and 2 pane specs are added
  • WHEN the session is built
  • THEN exactly 2 panes SHALL exist in window 0 (same as v0.2.0)

Requirement: Correct commands sent to each pane

Each pane SHALL receive a cd <worktree> && <cli_command> command targeting the correct pane index.

Scenario: Each pane receives cd and CLI command

  • GIVEN two panes with different worktrees and CLIs
  • WHEN the session is built
  • THEN each send-keys command SHALL contain cd <worktree> && <cli>

Test: tmux::tests::each_pane_receives_cd_and_cli_command

Scenario: Commands are submitted with Enter

  • GIVEN a pane spec
  • WHEN the session is built
  • THEN the send-keys command SHALL include Enter

Test: tmux::tests::pane_commands_are_submitted_with_enter

Scenario: Each pane targets a distinct index

  • GIVEN 3 panes
  • WHEN the session is built
  • THEN send-keys SHALL target :0.0, :0.1, and :0.2 respectively

Test: tmux::tests::each_pane_targets_a_distinct_pane_index

Requirement: Pane titles show branch and CLI

Each pane SHALL be titled with <branch> → <cli_command> and border status configured.

Scenario: Pane titles contain branch and CLI

  • GIVEN panes with branches and CLIs
  • WHEN the session is built
  • THEN select-pane -T commands SHALL set titles like "feat/auth → claude"

Test: tmux::tests::each_pane_is_titled_with_branch_and_cli

Scenario: Pane border status configured

  • GIVEN any session
  • WHEN the session is built
  • THEN pane-border-status SHALL be set to top and pane-border-format SHALL use #{pane_title}

Test: tmux::tests::pane_border_status_is_configured

Requirement: Configurable mouse mode per session

Mouse mode SHALL be enabled by default and be disableable via the builder.

Scenario: Mouse mode enabled by default

  • GIVEN no explicit mouse mode setting
  • WHEN the session is built
  • THEN a mouse on command SHALL be emitted

Test: tmux::tests::mouse_mode_enabled_by_default

Scenario: Mouse mode can be disabled

  • GIVEN mouse_mode(false) is set on the builder
  • WHEN the session is built
  • THEN no mouse on command SHALL be emitted

Test: tmux::tests::mouse_mode_can_be_disabled

Requirement: Attach to a tmux session

The system SHALL attach the current terminal to a named tmux session, returning an error if the session does not exist.

Scenario: Attaching to a nonexistent session fails

  • GIVEN no tmux session with the given name exists
  • WHEN attach() is called
  • THEN it SHALL return an error

Test: e2e_tests::attach_fails_for_nonexistent_session

Requirement: Session liveness check

The system SHALL check whether a tmux session is alive by name.

Scenario: Nonexistent session reports not alive

  • GIVEN no tmux session with the queried name exists
  • WHEN is_session_alive() is called
  • THEN it SHALL return false

Test: tmux::tests::is_session_alive_returns_false_for_nonexistent

Requirement: Session lifecycle management

The system SHALL support creating, checking, and killing tmux sessions.

Scenario: Full create-check-kill lifecycle

  • GIVEN a tmux session is created
  • WHEN is_session_alive() is called, then kill_session(), then is_session_alive() again
  • THEN it SHALL be alive after creation and not alive after killing

Test: tmux::tests::session_lifecycle_create_check_kill

Scenario: Built session can be executed and killed

  • GIVEN a session built via TmuxSessionBuilder
  • WHEN execute() is called
  • THEN the tmux session SHALL be alive, and after kill_session() it SHALL be gone

Test: tmux::tests::built_session_can_be_executed_and_killed

Requirement: Session name collision resolution

The system SHALL resolve name collisions by appending -2, -3, etc. to the base session name.

Scenario: No collision returns base name

  • GIVEN no existing session with the base name
  • WHEN resolve_session_name() is called
  • THEN it SHALL return paw-<project_name>

Test: tmux::tests::resolve_session_name_returns_base_when_no_collision

Scenario: Collision appends numeric suffix

  • GIVEN a session with the base name already exists
  • WHEN resolve_session_name() is called
  • THEN it SHALL return paw-<project_name>-2

Test: tmux::tests::resolve_session_name_appends_suffix_on_collision

Requirement: Tmux session lifecycle SHALL work against a real tmux server

Scenario: Create and kill session lifecycle

  • GIVEN a tmux session is created via the builder
  • WHEN execute(), is_session_alive(), and kill_session() are called
  • THEN the session SHALL be alive after creation and gone after killing

Test: e2e_tests::tmux_session_create_and_kill_lifecycle

Scenario: Five panes with different CLIs

  • GIVEN 5 pane specs with different branch/CLI pairs
  • WHEN the session is executed
  • THEN tmux SHALL have 5 panes with correct titles

Test: e2e_tests::tmux_session_with_five_panes_and_different_clis

Scenario: Mouse mode enabled by default against live tmux

  • GIVEN a session built with default settings
  • WHEN tmux show-option is queried
  • THEN mouse SHALL be “on”

Test: e2e_tests::tmux_mouse_mode_enabled_by_default

Scenario: is_session_alive returns false for nonexistent (e2e)

  • GIVEN no session with the queried name
  • WHEN is_session_alive() is called
  • THEN it SHALL return false

Test: e2e_tests::tmux_is_session_alive_returns_false_for_nonexistent

Scenario: Attach succeeds for live session

  • GIVEN a live tmux session
  • WHEN attach() is called and the client is detached programmatically
  • THEN the function SHALL execute without panic

Test: e2e_tests::attach_succeeds_for_live_session

Requirement: E2E commands SHALL behave correctly against real repos

Scenario: Dry run shows session plan

  • GIVEN a git repo with branches and --dry-run --cli echo --branches feat/a,feat/b
  • WHEN the binary runs
  • THEN stdout SHALL contain “Dry run”, branch names, and the CLI name

Test: e2e_tests::dry_run_with_flags_shows_plan

Scenario: Preset not found returns error

  • GIVEN a git repo with no presets configured
  • WHEN start --preset nonexistent is run
  • THEN it SHALL fail with stderr mentioning “not found”

Test: e2e_tests::preset_not_found_returns_error

Scenario: Stop with no session

  • GIVEN a git repo with no active session
  • WHEN stop is run
  • THEN it SHALL succeed with stdout mentioning “No active session”

Test: e2e_tests::stop_with_no_session

Scenario: Purge with no session

  • GIVEN a git repo with no active session
  • WHEN purge --force is run
  • THEN it SHALL succeed with stdout mentioning “No session to purge”

Test: e2e_tests::purge_with_no_session

Scenario: Status with no session

  • GIVEN a git repo with no active session
  • WHEN status is run
  • THEN it SHALL succeed with stdout mentioning “No session”

Test: e2e_tests::status_with_no_session

Scenario: Stop from non-git directory fails

  • GIVEN a directory that is not a git repository
  • WHEN stop is run
  • THEN it SHALL fail with “Not a git repository”

Test: e2e_tests::stop_from_non_git_dir_fails

Scenario: Status from non-git directory fails

  • GIVEN a directory that is not a git repository
  • WHEN status is run
  • THEN it SHALL fail with “Not a git repository”

Test: e2e_tests::status_from_non_git_dir_fails

Requirement: TmuxSession supports pipe-pane command

The TmuxSession builder SHALL support queuing a pipe-pane command to attach logging to a specific pane.

Scenario: pipe-pane queued in builder

  • WHEN pipe_pane() is called on a TmuxSession with a pane target and log path
  • THEN the command queue SHALL contain a pipe-pane -o -t <pane> "cat >> <path>" entry

Scenario: pipe-pane in dry-run output

  • WHEN a session with pipe_pane() is rendered as dry-run
  • THEN the output SHALL include the tmux pipe-pane command string

Scenario: pipe-pane executed after pane creation

  • WHEN the session commands are executed
  • THEN the pipe-pane command SHALL execute after the corresponding split-window and send-keys commands for that pane

Requirement: TmuxSession supports session-level environment variables

The TmuxSessionBuilder SHALL support setting session-level environment variables via a set_environment(key, value) method. The resulting set-environment -t <session> <key> <value> command SHALL be emitted before any send-keys commands to ensure all panes inherit the variable.

Scenario: set_environment emits correct tmux command

  • GIVEN set_environment("GIT_PAW_BROKER_URL", "http://127.0.0.1:9119") is called on the builder
  • WHEN the session is built
  • THEN the command queue SHALL contain set-environment -t <session> GIT_PAW_BROKER_URL http://127.0.0.1:9119

Scenario: set_environment appears before send-keys

  • GIVEN a builder with environment variables and pane specs
  • WHEN the session is built
  • THEN all set-environment commands SHALL appear before any send-keys commands in the command queue

Scenario: set_environment in dry-run output

  • GIVEN a builder with set_environment called
  • WHEN the session is rendered as dry-run
  • THEN the output SHALL include the tmux set-environment command string

Scenario: Multiple environment variables

  • GIVEN set_environment("A", "1") and set_environment("B", "2") are both called
  • WHEN the session is built
  • THEN both set-environment commands SHALL appear in the command queue

Requirement: Supervisor-mode pane layout

When the tmux session is built for a supervised session (per the supervisor-launch capability), the system SHALL produce a layout with these structural properties:

  • Top row: pane 0 (dashboard) spanning the full window width.

  • Agent grid below: dynamically sized by agent count, with up to 5 columns per row. The agent grid is a sequence of horizontal rows; each row holds up to 5 agent panes side-by-side.

  • Pane indices: pane 0 = dashboard; panes 1..N = coding agents in row-major order (left-to-right, top-to-bottom). No pane runs an orchestrator CLI.

  • Vertical proportions: the dashboard row SHALL occupy 50% of the session height regardless of agent count, and the agent rows SHALL share the remaining 50% evenly — each agent row occupying 50 / agent_rows percent. Adding agents SHALL shrink the agent rows, never the dashboard row.

    Agent rowsDashboard row heightEach agent row height
    1 (1-5 agents)50%50%
    2 (6-10 agents)50%25% each
    3 (11-15 agents)50%16.7% each
    4 (16-20 agents)50%12.5% each
    5 (21-25 agents)50%10% each
  • Equal-width agent panes within a row: every agent pane in the same agent row SHALL be rendered at equal width (within a one-column rounding tolerance). Because agents are added to a row by successive tmux split-window -h — and each -h split halves the current pane — a row populated by raw splits renders unequal widths, so the system SHALL rebalance each agent row to equal width after its panes are created, by applying tmux select-layout even-horizontal scoped to that row’s agent panes OR by issuing tmux resize-pane to set each agent pane to 100 / agents_in_row percent of the row’s width. The rebalance SHALL NOT alter the dashboard row’s full width nor the vertical height proportions described above.

  • Minimum usable pane width: the layout SHALL keep agent panes wide enough to be usable up to the agent cap; at the maximum 5 columns per row the equal-width target is 20% of the window width per pane. The system SHALL NOT produce agent rows with more than 5 panes (SUPERVISOR_AGENTS_PER_ROW).

  • Hard cap: 25 agents per session. Above 25, the system SHALL reject the launch with a clear “split into multiple sessions” error before any tmux command runs.

The layout SHALL be built using tmux split-window -h and -v with explicit percentages, then enforced via tmux resize-pane -y <pct> for the height proportions and the per-row equal-width rebalance described above. select-layout tiled (or other whole-window auto-layouts) SHALL NOT be used for the supervised layout because they do not preserve the predictable pane-index ordering this layout relies on; select-layout even-horizontal MAY be used only when scoped so it rebalances a single agent row’s widths without reordering pane indices or disturbing the dashboard row. No swap-pane SHALL be emitted.

Scenario: 5-agent supervised layout has 1 agent row

  • GIVEN a supervised session with 5 agent branches
  • WHEN the tmux layout is built
  • THEN pane 0 SHALL be the dashboard spanning the full window width
  • AND panes 1-5 SHALL be agents arranged in a single row below the dashboard
  • AND the dashboard row’s height SHALL be 50% and the agent row’s height SHALL be 50%

Scenario: 10-agent supervised layout has 2 agent rows

  • GIVEN a supervised session with 10 agent branches
  • WHEN the tmux layout is built
  • THEN total row count SHALL be 3 (1 dashboard + 2 agent rows)
  • AND the dashboard row’s height SHALL be 50%
  • AND each agent row’s height SHALL be 25%
  • AND the first agent row SHALL contain panes 1-5, the second agent row SHALL contain panes 6-10

Scenario: 20-agent supervised layout has 4 agent rows

  • GIVEN a supervised session with 20 agent branches
  • WHEN the tmux layout is built
  • THEN total row count SHALL be 5 (1 dashboard + 4 agent rows)
  • AND the dashboard row’s height SHALL be 50%
  • AND each of the 4 agent rows’ height SHALL be 12.5%

Scenario: 26-agent supervised session is rejected

  • GIVEN 26 agent branches resolved
  • WHEN the supervised launch flow runs
  • THEN the launch SHALL be rejected with a PawError stating the requested count (26), the maximum (25), and a hint suggesting --branches <subset>
  • AND no tmux session SHALL be created

Scenario: Pane indices match row-major order

  • GIVEN a supervised session with 7 agents
  • WHEN the tmux layout is built
  • THEN pane 1 SHALL be the first agent (top-left of the agent grid)
  • AND pane 5 SHALL be the fifth agent (top-right of the first agent row)
  • AND pane 6 SHALL be the sixth agent (start of the second agent row)

Scenario: Three agents in a row render at equal width

  • GIVEN a supervised session with 3 agent branches (a single agent row)
  • WHEN the tmux layout is built and applied to a live tmux window
  • THEN the three agent panes SHALL each occupy approximately one third of the window width (within a one-column rounding tolerance)

Scenario: Row rebalance leaves the dashboard row full width

  • GIVEN a supervised session whose agent row is rebalanced to equal width
  • WHEN the layout is applied to a live tmux window
  • THEN pane 0 (dashboard) SHALL remain at the full window width
  • AND the vertical height proportions SHALL be unchanged: the dashboard row at 50% and each agent row at 50 / agent_rows percent

Scenario: Full agent row stays at five equal-width columns

  • GIVEN a supervised session with 5 agents in a single row
  • WHEN the layout is applied to a live tmux window
  • THEN each of the 5 agent panes SHALL occupy approximately 20% of the window width
  • AND no agent row SHALL contain more than 5 panes

Requirement: Launch-readiness gate before boot-block injection

The system SHALL verify that a pane’s CLI process has actually started and reached an interactive ready state before injecting any boot block, framing prompt, or task prompt into that pane, rather than relying solely on a fixed wall-clock sleep. The system SHALL poll the pane (via tmux capture-pane) for a CLI-readiness marker — content that distinguishes the launched CLI’s interactive UI from a bare shell prompt — up to a bounded timeout. The boot block SHALL NOT be injected while the pane is still a bare shell, because the shell would interpret the multi-line boot block line-by-line as failing commands (the v0.8.0 G1 dogfood failure).

If the readiness timeout elapses while the pane is still a bare shell, the system SHALL relaunch the CLI command into that pane (re-clearing the input line first) and poll again, up to a bounded number of relaunch attempts. The readiness gate and its timeout/attempt budget SHALL apply uniformly to EVERY launch path that injects text into an agent pane: bare start (which then pre-fills without submitting), spec-driven start, supervised start, git paw add, and the submission of a held boot prompt when a paused session is restarted. A pane the gate reports as blocked on an unanswered first-run dialog SHALL NOT receive the injected text and SHALL fail that launch path loudly (per “Launch gate handles a first-run permission/trust acceptance dialog”); the one exception is restart from pause, which SHALL keep that pane’s held prompt for the next restart, report the blocked pane on stderr, and continue restarting the session. On restart from pause a held prompt SHALL be submitted only to the pane resolved live from its agent’s worktree: when no live pane resolves, the prompt SHALL stay held and the agent SHALL be reported on stderr, and the prompt SHALL NOT be submitted at a computed pane index.

The readiness gate SHALL be conservative: a CLI that the gate cannot positively classify within the budget (e.g. an unrecognised custom CLI whose UI the marker heuristic does not match) SHALL fall back to injecting the boot block after the budget elapses rather than failing the launch, so launch behaviour is never worse than the previous fixed-sleep behaviour for an unrecognised CLI.

Scenario: Boot block withheld until the pane is CLI-ready

  • GIVEN a freshly split agent pane whose CLI process has not yet reached its interactive prompt
  • WHEN the launch flow reaches the boot-block injection step for that pane
  • THEN the system SHALL poll the pane for a CLI-readiness marker and SHALL NOT inject the boot block while the pane still shows only a bare shell prompt

Scenario: Boot block injected once the CLI is ready

  • GIVEN an agent pane whose CLI has reached its interactive ready state within the readiness timeout
  • WHEN the readiness poll observes the CLI-readiness marker
  • THEN the system SHALL inject the boot block into that pane exactly once

Scenario: CLI relaunched when the pane is still a bare shell

  • GIVEN an agent pane whose CLI never started (the pane is still a bare shell when the readiness timeout elapses)
  • WHEN the readiness gate exhausts its timeout for that pane
  • THEN the system SHALL relaunch the CLI command into that pane and poll for readiness again, up to the relaunch-attempt budget, before falling back to injection

Scenario: Unrecognised CLI falls back to fixed-budget injection

  • GIVEN an agent pane running a custom CLI whose interactive UI does not match any known readiness marker
  • WHEN the readiness budget elapses without a positive readiness classification
  • THEN the system SHALL inject the boot block after the budget rather than rejecting the launch, so behaviour is no worse than the prior fixed-sleep launch for an unrecognised CLI

Scenario: Bare start is gated

  • GIVEN a bare git paw start with the broker enabled whose first agent pane is still a bare shell when the launch reaches injection
  • WHEN the launch injects the boot block pre-fill
  • THEN the gate SHALL run first and the pre-fill SHALL NOT be typed into the bare shell

Scenario: Spec-driven start stuck on a first-run dialog fails loudly

  • GIVEN a spec-driven git paw start --specs a whose agent pane is blocked on an unanswered first-run acceptance dialog
  • WHEN the launch reaches injection for that pane
  • THEN the launch SHALL fail with an error naming the pane and the attach command
  • AND the boot block SHALL NOT be typed on top of the dialog

Scenario: Restart from pause gates a held prompt

  • GIVEN a paused session holding a boot prompt for an agent added while paused, whose pane is blocked on a first-run dialog
  • WHEN git paw start restarts the session
  • THEN the held prompt SHALL NOT be submitted to that pane
  • AND it SHALL remain held for the next restart
  • AND stderr SHALL name the blocked pane while the rest of the session restarts

Scenario: A held prompt whose pane cannot be found stays held

  • GIVEN a paused session holding a boot prompt for an agent whose worktree no longer resolves to a live pane, with another ready CLI at that agent’s computed pane index
  • WHEN git paw start restarts the session
  • THEN the held prompt SHALL NOT be submitted to any pane
  • AND it SHALL remain held for the next restart
  • AND stderr SHALL name the agent as “pane not found” while the rest of the session restarts

Requirement: The dry-run plan preview reflects worktree placement

The git paw start --dry-run session-plan preview SHALL render each agent’s worktree path according to the configured [WorktreePlacement], rather than assuming the sibling layout: .git-paw/worktrees/<branch-slug> (relative to the repo root) for child placement, and ../<project>-<branch-slug> for sibling placement. This applies uniformly to the plain, --supervisor, and --from-specs dry-run previews. Computing the preview path MUST NOT create any directory (the dry run stays read-only).

Test: git::tests::worktree_display_path_reflects_placement, git::tests::worktree_path_for_creates_nothing

Scenario: Child placement preview shows the in-repo path

  • GIVEN a repository configured with child worktree placement
  • WHEN the dry-run plan renders the worktree path for branch feat/auth-flow
  • THEN it SHALL show .git-paw/worktrees/feat-auth-flow
  • AND it SHALL NOT show a ../-prefixed sibling path

Scenario: Sibling placement preview shows the sibling path

  • GIVEN a repository configured with sibling worktree placement
  • WHEN the dry-run plan renders the worktree path for branch feat/auth-flow
  • THEN it SHALL show ../<project>-feat-auth-flow beside the repository

Scenario: Rendering the preview path creates nothing

  • WHEN the dry-run preview computes a child-placement worktree path
  • THEN no worktree directory or .git-paw/worktrees/ entry is created on disk

Requirement: Launch gate handles a first-run permission/trust acceptance dialog

The readiness gate SHALL, when the launch-readiness poll observes a pane presenting a first-run acceptance dialog for its CLI — a one-time bypass-permissions confirmation (e.g. the dialog Claude Code shows the first time --dangerously-skip-permissions runs against a config directory that never accepted it) or a “trust this folder?” prompt — either answer the dialog to reach the interactive ready state, or fail the launch loudly. It SHALL NOT treat the dialog as a bare shell and relaunch (which would re-open the dialog), and SHALL NOT treat it as ready and inject the boot block on top of it. A pane left blocked on an unanswered acceptance dialog SHALL NOT be reported as a healthy, running agent. Dialog detection SHALL be per-CLI and conservative: a CLI whose acceptance dialog the gate does not recognise SHALL fall back to the existing readiness / relaunch / fixed-budget behaviour, so launch is never worse than before.

Scenario: First-run bypass acceptance dialog is answered, not relaunched

  • GIVEN an agent pane launched with a full-auto bypass flag against a config directory that has never accepted bypass, showing the first-run acceptance dialog
  • WHEN the readiness gate polls the pane
  • THEN the gate SHALL answer the acceptance dialog (or fail the launch loudly) rather than relaunching the CLI into the dialog

Scenario: A pane stuck on an acceptance dialog is not reported healthy

  • GIVEN an agent pane blocked on an unanswered first-run acceptance dialog after the launch budget elapses
  • WHEN launch completes
  • THEN the pane SHALL NOT be reported as a healthy running agent (the launch fails loudly or the pane’s dead/blocked state is surfaced)

Scenario: Unrecognised CLI acceptance dialog falls back to prior behaviour

  • GIVEN a custom CLI whose first-run dialog the gate does not recognise
  • WHEN the readiness budget elapses
  • THEN the gate SHALL fall back to the existing readiness / relaunch / fixed-budget injection behaviour

Requirement: Start reattaches to an existing live session instead of forking

git paw start SHALL reattach to the repository’s existing live session rather than creating a numerically-suffixed parallel session (paw-<project>-2) when that repository already has a live session (resolved by repository path, per session-state find-by-repository). The -N collision suffix SHALL be reserved for genuinely distinct sessions (e.g. two different repositories that share a project name), not a re-invocation of start against the same repository’s running session. In a non-interactive context where reattaching is not possible, start SHALL refuse with an actionable error (or require an explicit opt-in flag) rather than silently forking a parallel wave.

With --unattended, start SHALL instead bind a drive loop to the live session, whether or not the invocation is interactive (per supervisor-unattended-operation, A drive loop can be bound to a live supervised session). It SHALL NOT attach the terminal, SHALL NOT create a suffixed parallel session, and SHALL refuse only for the reasons that requirement and At most one drive loop drives a session instance state. Without --unattended the behaviour above is unchanged.

Scenario: Re-start of a repo with a live session reattaches

  • GIVEN a repository with a live session paw-x
  • WHEN the user runs git paw start again in that repository (interactive)
  • THEN the system SHALL reattach to paw-x and SHALL NOT create paw-x-2

Scenario: A distinct repository sharing a name still gets its own session

  • GIVEN a live session paw-x belonging to a different repository
  • WHEN git paw start runs in a second repository whose project name is also x
  • THEN the system SHALL resolve a distinct session name (paw-x-2) for the second repository

Scenario: Non-interactive re-start refuses rather than forking

  • GIVEN a repository with a live session and a non-interactive (non-TTY) invocation
  • WHEN git paw start runs again in that repository without --unattended
  • THEN the system SHALL refuse with an actionable error (or require an explicit opt-in flag) rather than silently creating a suffixed parallel session

Test: tests/supervisor_reattach_integration.rs::supervisor_restart_refuses_rather_than_forking_a_parallel_session (unchanged).

Scenario: Non-interactive re-start with –unattended binds a drive loop

  • GIVEN a repository with a live supervised session paw-x, no drive loop running, and a non-interactive (non-TTY) invocation
  • WHEN git paw start --unattended runs again in that repository
  • THEN the system SHALL bind a drive loop to paw-x and run it to an exit condition
  • AND SHALL NOT create paw-x-2
  • AND SHALL NOT print the non-interactive reattach refusal

Test: tests/unattended_drive_e2e.rs::unattended_rebinds_a_loop_to_a_live_session_after_heartbeat.

Requirement: Every pane-less session uses the agent grid

Every launch path — bare start, spec-driven start, and supervised start — and the recovery of a stopped session SHALL build the window with the agent-grid geometry defined by “Supervisor-mode pane layout”: the dashboard as the full-width top row when the broker is enabled, coding agents row-major below at no more than five per row, each row rebalanced to equal width, and the 25-agent cap. When the session has no dashboard (broker disabled), there SHALL be no top row: coding agents SHALL start at pane 0 and the agent rows SHALL share the full window height evenly. The whole-window select-layout tiled and main-horizontal layouts SHALL NOT be used to arrange coding agents. Every coding agent’s pane index SHALL be its row-major position — the session’s first agent index plus the agent’s position — at any agent count: each split that creates an agent pane, including the one that opens a new row and the one git paw add performs, SHALL target the session’s last pane, so the new pane takes the next index and no existing pane is renumbered.

Scenario: Bare start builds the grid

  • GIVEN [broker] enabled = true and no supervisor mode
  • WHEN git paw start launches 7 branches
  • THEN pane 0 SHALL be the dashboard spanning the full width
  • AND panes 1-5 SHALL form the first agent row and panes 6-7 the second, each row at equal widths

Scenario: Spec-driven start builds the grid

  • GIVEN a spec-driven git paw start --specs a b c without supervisor mode and the broker enabled
  • WHEN the session is built
  • THEN pane 0 SHALL be the dashboard and panes 1-3 SHALL be a single equal-width agent row

Scenario: Broker-less start uses the full height

  • GIVEN [broker] enabled = false
  • WHEN git paw start launches 6 branches
  • THEN there SHALL be no dashboard pane
  • AND panes 0-4 SHALL form the first agent row and pane 5 the second, the two rows sharing the full height evenly

Scenario: Every agent’s pane index holds that agent

  • GIVEN a bare session started with 7 branches, with the broker enabled and again with it disabled
  • WHEN the window is built, and again when git paw add attaches a sixth agent to a 5-agent session
  • THEN pane first_agent + i SHALL run in the worktree of the i-th agent and run that agent’s CLI, for every agent
  • AND no launch command or prompt SHALL be typed into a pane other than the one its agent occupies

Scenario: Recovery rebuilds a grid

  • GIVEN a stopped bare session saved with the earlier tiled layout and 3 recorded worktrees with the broker enabled
  • WHEN git paw start recovers it
  • THEN the rebuilt window SHALL be the grid (dashboard row + one agent row of 3)
  • AND the re-saved session SHALL record the grid layout

Session State

Purpose

Persist session state to disk for recovery after crashes, reboots, or manual stops. Stores one JSON file per session under the XDG data directory, with atomic writes and tmux liveness checks. It also has git paw start write (and purge remove) a per-repo session JSON at .git-paw/sessions/paw-<project>.json — carrying the session name and each agent’s branch_id, worktree path, CLI, and pane index — as the discovery surface the bundled sweep.sh helper reads, with a live-tmux fallback when the file is absent; distinguishes stale session receipts from live ones via a single cheap tmux has-session probe (status reports 🔴 stale, start auto-invalidates stale receipts before launching, and purge --stale prunes only stale entries); and guarantees session recovery rebuilds exactly the panes a session defines (N + 2 in supervisor mode) on a headless canvas sized to tile them without overflow, preserving the persisted session JSON when a git paw start errors mid-launch. It also renders a Markdown session summary from BrokerState and PawSession — metadata, per-agent details (status, modified files, exports, blocked time), merge order, and totals — which the supervisor writes to a timestamped, filesystem-safe file under .git-paw/sessions/ so multiple runs coexist without overwriting.

Requirements

Requirement: Save session state atomically

The system SHALL serialize session data to JSON and write it atomically using a temp file and rename to prevent corruption.

The session data SHALL include optional broker fields: broker_port (Option<u16>), broker_bind (Option<String>), and broker_log_path (Option<PathBuf>). These fields SHALL be omitted from the JSON when None and SHALL default to None when absent during deserialization.

broker_log_path names one log per session — <session_state_dir>/<session_name>.broker.log — so two repositories on one machine never write to, or purge, the same file. A session saved by an earlier git-paw records the shared <session_state_dir>/broker.log instead; that value SHALL continue to load and remain authoritative for both the broker and purge until a resume or recovery re-stamps it.

Scenario: Saved session round-trips with all fields intact

  • GIVEN an active session with 3 worktrees
  • WHEN save_session() is called and the session is loaded back
  • THEN all fields (session_name, repo_path, project_name, created_at, status, worktrees) SHALL match the original

Scenario: Saved session with broker fields round-trips

  • GIVEN an active session with broker_port = Some(9119), broker_bind = Some("127.0.0.1"), broker_log_path = Some("/path/to/paw-my-project.broker.log")
  • WHEN save_session() is called and the session is loaded back
  • THEN all broker fields SHALL match the original

Scenario: Session without broker fields loads successfully

  • GIVEN a session JSON file saved by v0.2.0 (no broker fields)
  • WHEN the session is loaded
  • THEN broker_port, broker_bind, and broker_log_path SHALL all be None
  • AND all existing fields SHALL load correctly

Scenario: A session recording the old shared log name still loads

  • GIVEN a session JSON saved by an earlier git-paw with broker_log_path = Some("<session_state_dir>/broker.log")
  • WHEN the session is loaded
  • THEN broker_log_path SHALL be that recorded path
  • AND it SHALL be the path both the broker writes and purge deletes

Scenario: Saving again replaces previous state

  • GIVEN a previously saved session
  • WHEN save_session() is called with updated fields
  • THEN the new state SHALL overwrite the old state

Requirement: Load session by name

The system SHALL load a session from disk by name, returning None if the file does not exist.

Scenario: Loading a nonexistent session returns None

  • GIVEN no session file exists with the given name
  • WHEN load_session() is called
  • THEN it SHALL return Ok(None)

Test: session::tests::loading_nonexistent_session_returns_none

Requirement: Find session by repository path

The system SHALL scan all session files and return the session matching a given repository path.

The default lookup SHALL match a session’s repository path EXACTLY: a path that is the root of — or lies inside — a linked worktree SHALL NOT resolve to the owning repository’s session. This exactness is a safety boundary. Every command that MUTATES session state (stop, purge, add, remove, pause, and start’s recover/resume dispatch) resolves its repository to the root of the checkout it is run from, which inside an agent worktree is that WORKTREE’s root; were the default lookup to resolve the owner, such a command run by an agent in its own worktree would act on the whole session — deleting sibling worktrees and branches, or killing the session.

The system SHALL additionally provide a SEPARATE, read-only owner-resolving lookup that returns the session of the repository that OWNS a given path, by resolving a linked worktree to that repository. It SHALL try the exact match first and resolve the owner only on a miss. It SHALL be used ONLY by the MCP server’s repository context and its read-only session view, so a git paw mcp spawned by an agent’s CLI from inside the agent’s worktree with no --repo finds the session and its broker. No mutating command SHALL use it.

Scenario: Finds correct session among multiple

  • GIVEN two sessions for different repositories
  • WHEN find_session_for_repo() is called with one repo path
  • THEN it SHALL return the matching session

Test: session::tests::finds_correct_session_among_multiple_by_repo_path

Scenario: No matching session

  • GIVEN saved sessions for other repositories
  • WHEN find_session_for_repo() is called with a different path
  • THEN it SHALL return None

Test: session::tests::find_returns_none_when_no_repo_matches

Scenario: No sessions directory

  • GIVEN no sessions directory exists
  • WHEN find_session_for_repo() is called
  • THEN it SHALL return None

Test: session::tests::find_returns_none_when_no_sessions_exist

Scenario: A worktree root resolves to its repository’s session

  • GIVEN an active session for a repository and one of that session’s agent worktrees
  • WHEN find_session_for_repo_or_owner() is called with the worktree’s root
  • THEN it SHALL return the repository’s session

Scenario: The owner-resolving lookup prefers an exact match

  • GIVEN a session recorded for an agent worktree’s own root and another for the repository that owns it
  • WHEN find_session_for_repo_or_owner() is called with the worktree’s root
  • THEN it SHALL return the worktree’s own session, not the owner’s

Scenario: The default lookup does not cross the worktree boundary

  • GIVEN an active session for a repository and one of that session’s agent worktrees
  • WHEN find_session_for_repo() is called with the worktree’s root
  • THEN it SHALL return None

Scenario: A mutating command run inside an agent worktree does not act on the session

  • GIVEN an active session and one of its agent worktrees
  • WHEN git paw stop or git paw purge --force is run with that worktree as the working directory
  • THEN the session SHALL remain alive, its branches and its sibling agents’ worktrees SHALL remain, and the command SHALL report that there is no session for this repository

Scenario: The MCP server started in a worktree finds the broker

  • GIVEN an active broker-enabled session and git paw mcp started with its working directory inside an agent worktree and no --repo
  • WHEN the server resolves its repository context
  • THEN its broker URL SHALL be the session’s broker URL, not absent

Requirement: Delete session by name

The system SHALL delete a session file, succeeding even if the file does not exist (idempotent).

Scenario: Deleted session is no longer loadable

  • GIVEN a saved session
  • WHEN delete_session() is called
  • THEN load_session() SHALL return None

Test: session::tests::deleted_session_is_no_longer_loadable

Scenario: Deleting nonexistent session succeeds

  • GIVEN no session file with the given name
  • WHEN delete_session() is called
  • THEN it SHALL return Ok(())

Test: session::tests::deleting_nonexistent_session_succeeds

Requirement: Effective status combines file state with tmux liveness

Session::effective_status(is_tmux_alive) SHALL combine the persisted status field with the result of is_tmux_alive to produce the runtime-effective status:

Recorded statustmux alive?Effective status
ActiveyesActive
ActivenoStopped
PausedyesPaused
PausednoStopped
StoppedanyStopped

The rule for Paused: tmux must still be alive for the Paused state to be valid — pause’s whole purpose is to keep tmux + CLI panes running while the client is detached. If tmux died despite a recorded Paused state (e.g. tmux server crash), effective_status SHALL downgrade to Stopped, and cmd_start SHALL run the cold-recovery path (fresh CLI spawn) rather than the restart-from-pause path.

Scenario: Active + alive remains Active

  • GIVEN a session with status = Active
  • WHEN effective_status(|_| true) is called
  • THEN it SHALL return Active

Scenario: Active + dead downgrades to Stopped

  • GIVEN a session with status = Active
  • WHEN effective_status(|_| false) is called
  • THEN it SHALL return Stopped

Scenario: Paused + alive remains Paused

  • GIVEN a session with status = Paused
  • WHEN effective_status(|_| true) is called
  • THEN it SHALL return Paused

Scenario: Paused + dead downgrades to Stopped

  • GIVEN a session with status = Paused
  • WHEN effective_status(|_| false) is called
  • THEN it SHALL return Stopped

Scenario: Stopped remains Stopped regardless of tmux liveness

  • GIVEN a session with status = Stopped
  • WHEN effective_status is called with either liveness result
  • THEN it SHALL return Stopped

Requirement: SessionStatus display format

The SessionStatus enum SHALL display as lowercase strings.

Scenario: SessionStatus display strings

  • GIVEN SessionStatus::Active and SessionStatus::Stopped
  • WHEN formatted with Display
  • THEN they SHALL render as "active" and "stopped"

Test: session::tests::session_status_displays_as_lowercase_string

Requirement: Recovery data survives tmux crashes

After a tmux crash, the persisted session SHALL contain all data needed to reconstruct the session.

Scenario: Crashed session has all recovery data including broker fields

  • GIVEN a saved session with worktrees and broker enabled
  • WHEN tmux crashes and the session is loaded from disk
  • THEN it SHALL have the session name, repo path, all worktree details, AND broker_port, broker_bind, broker_log_path

Scenario: Session recovery recreates the broker host and dashboard pane when broker was enabled

  • GIVEN a saved session with broker_port = Some(9119) and broker_bind = Some("127.0.0.1")
  • WHEN recover_session() is called
  • THEN the rebuilt tmux session SHALL have:
    • A detached broker window running git-paw __broker
    • Dashboard pane in pane 0 running git-paw __dashboard
    • GIT_PAW_BROKER_URL environment variable set to http://127.0.0.1:9119
    • All original worktree panes in subsequent indices

Scenario: Session recovery uses original broker config, not current config

  • GIVEN a saved session with broker_port = Some(9119)
  • AND current repo config has broker.enabled = false
  • WHEN recover_session() is called
  • THEN the broker host and dashboard pane SHALL still be created with the original broker URL

Scenario: Session recovery without original broker creates no dashboard

  • GIVEN a saved session with broker_port = None
  • WHEN recover_session() is called
  • THEN no broker host and no dashboard pane SHALL be created

Requirement: Session persistence SHALL work through the public API

Scenario: Save and load round-trip

  • GIVEN a session with 2 worktrees
  • WHEN save_session_in() and load_session_from() are called
  • THEN all fields SHALL match

Test: session_integration::save_and_load_round_trip

Scenario: Find session by repo path

  • GIVEN a saved session
  • WHEN find_session_for_repo_in() is called with the matching repo path
  • THEN the correct session SHALL be returned

Test: session_integration::find_session_by_repo_path

Scenario: Find returns None for unknown repo

  • GIVEN no matching session
  • WHEN find_session_for_repo_in() is called
  • THEN it SHALL return None

Test: session_integration::find_session_returns_none_for_unknown_repo

Scenario: Find correct session among multiple

  • GIVEN two sessions for different repos
  • WHEN find_session_for_repo_in() is called for one
  • THEN the correct session SHALL be returned

Test: session_integration::find_correct_session_among_multiple

Scenario: Delete removes session

  • GIVEN a saved session
  • WHEN delete_session_in() is called
  • THEN load_session_from() SHALL return None

Test: session_integration::delete_removes_session

Scenario: Delete nonexistent is idempotent

  • GIVEN no session file
  • WHEN delete_session_in() is called
  • THEN it SHALL succeed

Test: session_integration::delete_nonexistent_is_idempotent

Scenario: Load nonexistent returns None

  • GIVEN no session file
  • WHEN load_session_from() is called
  • THEN it SHALL return None

Test: session_integration::load_nonexistent_returns_none

Scenario: Saving again replaces previous state

  • GIVEN a saved session
  • WHEN the status is changed and saved again
  • THEN the loaded session SHALL have the new status

Test: session_integration::saving_again_replaces_previous_state

Scenario: Effective status active when tmux alive

  • GIVEN a session with Active status and tmux alive
  • WHEN effective_status() is called
  • THEN it SHALL return Active

Test: session_integration::effective_status_active_when_tmux_alive

Scenario: Effective status stopped when tmux dead

  • GIVEN a session with Active status and tmux dead
  • WHEN effective_status() is called
  • THEN it SHALL return Stopped

Test: session_integration::effective_status_stopped_when_tmux_dead

Scenario: Effective status stopped stays stopped

  • GIVEN a session with Stopped status
  • WHEN effective_status() is called
  • THEN it SHALL return Stopped regardless of tmux

Test: session_integration::effective_status_stopped_stays_stopped

Scenario: Saved session has all recovery fields

  • GIVEN a saved and reloaded session
  • WHEN recovery fields are checked
  • THEN session_name, repo_path, project_name, and all worktree entries SHALL be non-empty

Test: session_integration::saved_session_has_all_recovery_fields

Requirement: Paused session status variant

The SessionStatus enum SHALL include a third variant Paused (alongside Active and Stopped). The serde representation SHALL serialize as the lowercase string "paused" and SHALL deserialize from the same string. The Display implementation SHALL render Paused as "paused".

The Paused state means: the tmux session is intended to remain alive, all coding-agent CLI panes are intended to remain running, the user’s tmux client is detached, and the broker is stopped. Session state files saved by v0.4.0 binaries (which only know Active and Stopped) SHALL continue to load successfully under v0.5+ binaries — the new variant only appears in files saved by v0.5+.

Scenario: Paused status serializes lowercase

  • GIVEN a Session with status = SessionStatus::Paused
  • WHEN save_session() is called and the JSON file is inspected
  • THEN the "status" field SHALL be "paused"

Scenario: Paused status round-trips

  • GIVEN a Session with status = SessionStatus::Paused saved to disk
  • WHEN the session is loaded back via load_session()
  • THEN status SHALL be SessionStatus::Paused

Scenario: v0.4-saved sessions load under v0.5

  • GIVEN a session JSON file saved by v0.4.0 (only "active" or "stopped" in the status field)
  • WHEN the file is loaded by a v0.5+ binary
  • THEN the load SHALL succeed
  • AND the status field SHALL match the original (Active or Stopped)

Scenario: Paused Display renders lowercase

  • WHEN format!("{}", SessionStatus::Paused) is evaluated
  • THEN the result SHALL be "paused"

Requirement: Dashboard pane index persisted in session state

The Session struct SHALL include an optional field dashboard_pane: Option<u32> that records the pane index of the dashboard pane within the tmux session. The field SHALL use #[serde(default, skip_serializing_if = "Option::is_none")] so sessions saved without it load with None. The field SHALL be populated with Some(0) by every supervised start flow (the supervised layout always places the dashboard at pane 0) and by every bare start flow that enables the broker, because the dashboard is pane 0 in both layouts; only a bare start without the broker records None.

Consumers that need the first coding-agent pane index or the dashboard index (status, stop, pause, restart from pause, remove, liveness, recovery, the drive loop) SHALL derive them from the session’s layout record, which reads the RECORDED dashboard_pane. When it is Some(n), the dashboard is pane n and the first coding agent is pane n + 1. A session saved by a pre-relocation binary with dashboard_pane = Some(1) (supervisor at 0, dashboard at 1, agents from 2) therefore keeps resolving its agent panes from index 2 while it is still alive after an upgrade. When the field is None, the layout record SHALL apply the earlier (pre-relocation) rules, so live sessions saved by older binaries keep resolving: a supervised session’s coding agents start at pane 2, a bare session’s at pane 1 when it recorded a broker and pane 0 when it did not, and the dashboard index defaults to 0 for pause and restart from pause. SessionMode SHALL be consulted only when dashboard_pane is None.

Scenario: Dashboard pane index round-trips

  • GIVEN a Session with dashboard_pane = Some(0) saved to disk
  • WHEN the session is loaded back
  • THEN dashboard_pane SHALL be Some(0)

Scenario: Session without dashboard_pane defaults to None on load

  • GIVEN a session JSON file with no dashboard_pane field
  • WHEN the session is loaded
  • THEN dashboard_pane SHALL be None

Scenario: Dashboard pane field is omitted when None

  • GIVEN a Session with dashboard_pane = None
  • WHEN save_session() is called and the JSON file is inspected
  • THEN the JSON SHALL NOT contain a dashboard_pane field

Scenario: Supervised start records the dashboard at pane 0

  • GIVEN [broker] enabled = true and a supervised launch
  • WHEN the session state is saved
  • THEN dashboard_pane SHALL be Some(0)
  • AND the first recorded coding agent’s pane_index in the per-repo discovery file SHALL be 1

Scenario: A pre-relocation live session keeps its agent offset

  • GIVEN a saved session with mode = Supervisor, dashboard_pane = Some(1) and broker enabled
  • WHEN git paw status resolves the session’s agent panes
  • THEN it SHALL treat pane 2 as the first coding-agent pane
  • AND no keystroke sender (the drive loop, or sweep.sh approve) SHALL treat pane 0 or pane 1 of that session as a coding agent

Scenario: A session without dashboard_pane follows the earlier rules

  • GIVEN a live saved session with mode = Supervisor, dashboard_pane = None and no broker (a broker-less supervised session from a pre-relocation binary)
  • WHEN git paw status resolves the session’s agent panes
  • THEN it SHALL treat pane 2 as the first coding-agent pane
  • AND a saved bare session with dashboard_pane = None SHALL resolve its first coding agent at pane 1 when it recorded a broker and at pane 0 when it did not

Scenario: A broker-less supervised start still records the dashboard

  • GIVEN [broker] enabled = false and a supervised launch
  • WHEN the session state is saved
  • THEN dashboard_pane SHALL be Some(0)

Requirement: start writes a per-repo session JSON

git paw start SHALL write a per-repo session JSON to .git-paw/sessions/paw-<project>.json describing the launched session. This is the discovery surface the bundled sweep.sh helper reads. The file SHALL be written on launch and removed on purge. Every path through which git paw start creates or rebuilds the session’s panes SHALL (re)write it — the bare launch, the spec-driven launch with or without supervisor mode, the supervisor launch, recovery of a stopped session, and restart of a paused session — and each agent entry’s pane_index SHALL identify the pane that agent occupies in the resulting session.

Scenario: start writes the per-repo JSON

  • WHEN git paw start (any flags) succeeds
  • THEN .git-paw/sessions/paw-<project>.json SHALL exist describing the session (name + agent list)

Scenario: A spec-driven launch without supervisor mode writes the per-repo JSON

  • WHEN git paw start --specs <names> succeeds without supervisor mode
  • THEN .git-paw/sessions/paw-<project>.json SHALL exist with one agents entry per launched spec branch
  • AND the bundled helper SHALL discover the session from it without relying on running inside the session

Scenario: Recovery rewrites the per-repo JSON

  • GIVEN a stopped session whose per-repo JSON is missing
  • WHEN git paw start recovers it
  • THEN .git-paw/sessions/paw-<project>.json SHALL exist and list every recovered agent with the pane index it occupies in the rebuilt session

Scenario: Restart from pause rewrites the per-repo JSON

  • GIVEN a paused session
  • WHEN git paw start resumes it
  • THEN .git-paw/sessions/paw-<project>.json SHALL list every agent with the pane index it occupies in the resumed session

Scenario: purge removes the per-repo JSON

  • GIVEN an active session with the per-repo JSON present
  • WHEN the user runs git paw purge --force
  • THEN the per-repo JSON SHALL be removed

Requirement: Per-repo JSON shape matches sweep.sh expectations

The per-repo paw-<project>.json SHALL include the session name plus an agent list, each entry carrying branch_id, worktree_path, cli, and pane_index. The shape SHALL match what the bundled assets/scripts/sweep.sh helper reads — invoked from the orchestrator’s own session at the repository root, outside the tmux session — so the helper works against the file without modification. The agent list SHALL list coding agents only.

Scenario: sweep.sh discovers agents via the per-repo JSON

  • GIVEN a freshly-started session and the bundled sweep.sh helper invoked from a shell at the repository root that is not attached to the tmux session
  • WHEN sweep.sh reads .git-paw/sessions/paw-<project>.json
  • THEN the helper SHALL enumerate the full agent list with branch_id, worktree path, CLI, and pane index — no fields missing or renamed relative to its expectations

Scenario: Adding a field is backwards-compatible

  • WHEN a future change adds a new field to the per-repo JSON
  • THEN the current sweep.sh SHALL still find every documented field; unknown extra fields SHALL be ignored

Requirement: sweep.sh falls back to live tmux when the file is absent

The bundled sweep.sh SHALL fall back to discovering the session name from the $TMUX environment variable, or tmux display-message -p '#S', when .git-paw/sessions/paw-<project>.json is absent (e.g. the supervisor attached to a pre-existing paw-* session created outside the normal git paw start flow). The helper SHALL NOT require manual authoring of the session JSON.

Scenario: Helper discovers the session with no JSON present

  • GIVEN a paw-myproj tmux session is active but .git-paw/sessions/paw-myproj.json does not exist
  • WHEN sweep.sh runs inside the session
  • THEN the helper SHALL resolve the session name via $TMUX / tmux display-message -p '#S' and operate without error, instead of failing to discover the session

Scenario: Per-repo JSON takes precedence when present

  • GIVEN both the per-repo JSON and a live tmux session exist
  • WHEN sweep.sh runs
  • THEN the helper SHALL prefer the per-repo JSON’s agent list (richer — it carries worktree paths + pane indices) over the bare session-name fallback

Requirement: Status distinguishes stale receipts from active sessions

git paw status SHALL probe tmux has-session -t paw-<project> when the receipt claims active. When the probe shows the tmux session is absent, status SHALL report 🔴 stale instead of 🟢 active. The system SHALL preserve the existing 🟢 active and 🟡 stopped displays unchanged.

Scenario: Active session reports active

  • GIVEN a session whose receipt says active AND whose tmux session exists
  • WHEN the user runs git paw status
  • THEN the output SHALL display 🟢 active

Scenario: Stale receipt reports stale

  • GIVEN a session whose receipt says active AND whose tmux session does NOT exist (crash or release-boundary carry-over)
  • WHEN the user runs git paw status
  • THEN the output SHALL display 🔴 stale (not 🟢 active)

Scenario: Stopped session reports stopped

  • GIVEN a session whose receipt says stopped
  • WHEN the user runs git paw status
  • THEN the output SHALL display 🟡 stopped regardless of tmux liveness

Requirement: JSON status output adds stale value

The system SHALL extend the JSON output of git paw status with a new "stale" value for the status field whenever the liveness probe identifies a stale receipt. The system SHALL preserve the existing "active" and "stopped" values from v0.5.0 without semantic change.

Scenario: JSON output reports stale

  • GIVEN a stale-receipt session
  • WHEN the user runs git paw status --json
  • THEN the response object SHALL contain "status": "stale"

Requirement: start invalidates stale receipts automatically

git paw start SHALL run the same tmux has-session probe before deciding whether to recover or launch fresh. When the receipt claims active but the tmux session is absent, the system SHALL invalidate the receipt (purging the recorded worktrees + branches equivalent to git paw purge --force) and SHALL emit a stderr notice naming the invalidated entry before proceeding with the requested launch.

Scenario: Stale receipt is invalidated before launch

  • GIVEN a stale receipt for paw-myproj
  • WHEN the user runs git paw start (any flags)
  • THEN the system SHALL purge the stale receipt’s worktrees + branches, SHALL emit a stderr notice identifying the purged entry, and SHALL proceed with the requested launch as if no prior session existed

Scenario: Active receipt is NOT invalidated

  • GIVEN a live session whose receipt says active AND whose tmux session exists
  • WHEN the user runs git paw start (no recovery flags)
  • THEN the system SHALL behave per its existing reattach-or-error semantics; it SHALL NOT purge anything

Scenario: Notice text identifies the purged entry

  • WHEN the auto-invalidation fires
  • THEN the stderr notice SHALL include the session name, the receipt’s last_seen timestamp (if present), and a one-line explanation that the tmux session no longer exists

Requirement: purge –stale flag

git paw purge SHALL accept a --stale flag. When passed, the system SHALL purge only sessions whose receipt is stale per the liveness probe, and by default SHALL scope the sweep to the CURRENT repository — it SHALL NOT purge or touch a session belonging to any other repository. A machine-wide sweep of stale receipts across all repositories SHALL require the explicit --all-repos flag. In every case the guarantee is fail-safe: a session that is live per the probe — or whose liveness cannot be positively confirmed — SHALL NEVER be purged by --stale, in the current repository or any other. The flag is additive to existing --force.

Scenario: –stale is scoped to the current repository by default

  • GIVEN a stale session belonging to the current repository AND a stale session belonging to a different repository
  • WHEN the user runs git paw purge --stale (without --all-repos)
  • THEN only the current repository’s stale session’s worktrees + branches + receipt SHALL be purged
  • AND the other repository’s session SHALL NOT be touched

Scenario: –stale never purges a live or liveness-indeterminate session

  • GIVEN a live session (in the current or another repository), or a session whose liveness cannot be positively confirmed
  • WHEN the user runs git paw purge --stale (with or without --all-repos)
  • THEN that session SHALL NOT be purged

Scenario: –all-repos sweeps stale receipts across repositories

  • GIVEN stale sessions belonging to multiple repositories
  • WHEN the user runs git paw purge --stale --all-repos
  • THEN every repository’s stale session SHALL be purged
  • AND every live session SHALL remain intact

Scenario: –stale with nothing stale exits cleanly

  • GIVEN no stale receipts in scope
  • WHEN the user runs git paw purge --stale
  • THEN the command SHALL exit 0 with a “nothing to purge” message, and SHALL NOT touch any active session

Scenario: –stale + –force is well-defined

  • GIVEN a stale receipt in the current repository
  • WHEN the user runs git paw purge --stale --force
  • THEN the system SHALL behave equivalently to --stale alone (the --force flag is redundant in this combination; explicitly documented as a no-op pairing)

Requirement: Liveness probe is cheap

The staleness check SHALL be a single tmux has-session -t paw-<project> invocation. The system SHALL NOT probe the broker, agent processes, or any other liveness signal as part of the receipt-staleness check.

Scenario: Probe runs only one tmux call

  • WHEN any of status, start, or purge --stale performs the staleness check
  • THEN the resulting process tree SHALL contain exactly one tmux has-session invocation per session being probed

Scenario: Probe failure modes are tolerated

  • GIVEN a system where the tmux binary is absent or unreachable
  • WHEN the staleness check runs
  • THEN the system SHALL treat the probe failure as inconclusive (preserve the receipt’s current state) and SHALL NOT report 🔴 stale based on a tmux-missing failure

Requirement: Recovery rebuilds exactly the session’s panes

Recovering a stopped session SHALL rebuild in window 0 exactly the panes the session defines — in a supervised session, the dashboard pane plus one pane per recorded worktree (N + 1). The recovery SHALL NOT create more panes than the session defines and SHALL NOT create an orchestrator pane, including when the stopped session was saved with an earlier layout. The detached broker service window is recreated separately (broker-runtime) and is not counted.

Scenario: Recovery of an N-worktree supervised session creates N+1 panes

  • GIVEN a stopped supervised session with 3 recorded worktrees
  • WHEN git paw start recovers it
  • THEN window 0 of the rebuilt tmux session SHALL have exactly 4 panes (dashboard + 3 agents)

Scenario: Recovery of a pre-relocation session rebuilds the new layout

  • GIVEN a stopped supervised session saved with dashboard_pane = Some(1) and 2 recorded worktrees
  • WHEN git paw start recovers it
  • THEN window 0 of the rebuilt session SHALL have exactly 3 panes with the dashboard at pane 0
  • AND the re-saved session SHALL record dashboard_pane = Some(0)

Scenario: Recovery that cannot fit fails cleanly

  • GIVEN a recovery whose panes cannot be tiled in the available canvas
  • WHEN the rebuild fails
  • THEN it SHALL NOT leave a half-tiled session and SHALL NOT mutate the persisted session state

Requirement: A failed start preserves persisted session state

The system SHALL leave the persisted per-repo session JSON unchanged when a git paw start invocation errors before completing its launch. The session state file SHALL be rewritten only after a launch completes successfully, so an aborted start cannot destroy a recoverable session (e.g. rewrite it to an empty worktree list).

Scenario: Aborted start does not corrupt the session JSON

  • GIVEN an existing saved session with N worktrees
  • WHEN a subsequent git paw start errors mid-launch
  • THEN the saved session JSON SHALL still list those N worktrees (unchanged), so a later git paw start can still recover it

Requirement: Headless canvas fits multi-agent supervisor layouts

The system SHALL size the detached/headless new-session canvas and the global default-size large enough to tile a supervisor session with the supported number of agents without tmux returning no space for new pane. An attached client still resizes the session to the real terminal on attach.

Scenario: Headless supervisor session tiles without overflow

  • GIVEN a detached (no attached client) supervisor launch with several agents
  • WHEN the session is built at the headless fallback size
  • THEN all panes SHALL tile successfully (no no space for new pane error)

Requirement: write_session_summary function

The system SHALL provide a public function pub fn write_session_summary(state: &BrokerState, session: &PawSession, merge_order: &[String], output_path: &Path) -> Result<(), PawError> in a new src/summary.rs module.

The function SHALL extract data from BrokerState (agent records and message log) and PawSession (project name, start time) to produce the summary. The merge_order parameter provides the sequence in which branches were merged.

The function SHALL write the summary to output_path as a UTF-8 Markdown file. If the file already exists, it SHALL be overwritten.

Scenario: Summary is written to the specified path

  • GIVEN a valid BrokerState with two agents and a PawSession
  • WHEN write_session_summary(&state, &session, &merge_order, &path) is called
  • THEN a file SHALL exist at path
  • AND the function returns Ok(())

Scenario: Existing summary is overwritten

  • GIVEN a summary file already exists at the output path
  • WHEN write_session_summary is called again
  • THEN the file SHALL contain only the new summary content

Scenario: Write failure returns PawError

  • GIVEN the output path is in a read-only directory
  • WHEN write_session_summary is called
  • THEN the function returns Err(PawError::...)

Requirement: Session metadata section

The summary SHALL begin with a # Session Summary heading followed by a metadata block containing:

  • Project name (from PawSession)
  • Session date (formatted as YYYY-MM-DD)
  • Session duration (from PawSession start time to now)
  • Number of agents

Scenario: Summary contains correct project name

  • GIVEN a PawSession with project_name = "my-app"
  • WHEN write_session_summary is called
  • THEN the output SHALL contain my-app

Scenario: Summary contains agent count

  • GIVEN a BrokerState with 3 known agents
  • WHEN write_session_summary is called
  • THEN the output SHALL contain the number 3 adjacent to “Agents”

Requirement: Per-agent details section

The summary SHALL include an ## Agents section with one subsection (### <branch> (<cli>)) per known agent, containing:

  • Status: — the agent’s final status from its last message
  • Files modified: — from the last agent.artifact message’s modified_files
  • Exports: — from the last agent.artifact message’s exports
  • Blocked time: — estimated from agent.blocked to next agent.status timestamps in the message log

When an agent has no agent.artifact message, the files and exports fields SHALL show (none).

Scenario: Per-agent section includes modified files

  • GIVEN agent "feat-config" published agent.artifact with modified_files = ["src/config.rs"]
  • WHEN write_session_summary is called
  • THEN the output SHALL contain src/config.rs under the feat-config agent section

Scenario: Per-agent section handles no artifact

  • GIVEN agent "feat-config" only published agent.status (never completed)
  • WHEN write_session_summary is called
  • THEN the output SHALL still include a section for feat-config
  • AND files and exports SHALL show (none)

Requirement: Merge order section

The summary SHALL include an ## Overview section listing the merge order as a comma-separated list or bullet list.

Scenario: Merge order is included

  • GIVEN merge_order = ["feat-errors", "feat-config", "feat-detect"]
  • WHEN write_session_summary is called
  • THEN the output SHALL list feat-errors, feat-config, feat-detect in that order under the merge order label

Requirement: Totals section

The summary SHALL include a ## Totals section with:

  • Total agents: count of all known agents
  • Total time: overall session duration

Scenario: Totals section is present

  • GIVEN a session with 3 agents
  • WHEN write_session_summary is called
  • THEN the output SHALL contain a “Totals” section with at least agent count and duration

Requirement: Summary module is public

The summary module SHALL be declared as pub mod summary in src/lib.rs. The write_session_summary function SHALL be callable from src/main.rs supervisor handler.

Scenario: Summary module is accessible from main

  • WHEN src/lib.rs is compiled
  • THEN git_paw::summary::write_session_summary SHALL be a callable public function

Requirement: Summary output path is timestamped under .git-paw/sessions/

The supervisor SHALL call write_supervisor_summary, which writes the rendered Markdown to a timestamped file under <repo_root>/.git-paw/sessions/<UTC-timestamp>.md. The timestamp SHALL be in YYYY-MM-DDTHH-MM-SSZ format (filesystem-safe — colons replaced with hyphens) so multiple supervisor runs against the same repository produce distinct files that can coexist on disk without overwriting each other. write_supervisor_summary SHALL create .git-paw/sessions/ if it does not already exist, and SHALL return the absolute path it wrote so the caller can log it.

Scenario: Supervisor writes timestamped summary under sessions dir

  • GIVEN a completed supervisor session with all agents verified and merged
  • WHEN the supervisor finishes
  • THEN a file <repo_root>/.git-paw/sessions/<UTC-timestamp>.md SHALL exist
  • AND the timestamp SHALL match YYYY-MM-DDTHH-MM-SSZ
  • AND the filename SHALL NOT contain a : character

Scenario: Two sequential supervisor runs produce distinct summary files

  • GIVEN an empty repository with no .git-paw/sessions/ directory
  • WHEN write_supervisor_summary is called twice with at least one second between calls
  • THEN both calls SHALL succeed
  • AND the returned paths SHALL differ
  • AND both files SHALL exist on disk

Scenario: Sessions directory is created on demand

  • GIVEN a repository where .git-paw/sessions/ does not exist
  • WHEN write_supervisor_summary is called
  • THEN .git-paw/sessions/ SHALL be created
  • AND the rendered summary SHALL be written inside it

Requirement: Session lookup matches on the canonical repository path

find_session_for_repo SHALL match a stored session to a repository by comparing the two paths in canonical form, so a repository referred to by a symlinked or non-normalized path (for example /var/… resolving to /private/var/…, or a path containing a .. segment) still resolves to its session. When a path cannot be canonicalized (for example the repository directory no longer exists), the system SHALL fall back to comparing the raw paths, preserving the prior behavior for that case.

Scenario: A session is found through a symlinked repo path

  • GIVEN a session registered at a repository directory
  • AND a symlink that resolves to that same directory
  • WHEN the session is looked up by the symlink path
  • THEN the session SHALL be found

Scenario: A non-existent path falls back to raw comparison

  • GIVEN a session registered at a repository path that does not exist on disk
  • WHEN it is looked up by that same (non-existent) path
  • THEN the session SHALL still be found via the raw-path fallback

Requirement: Liveness reflects dead agent panes, not just session existence

The tmux-liveness probe that feeds effective_status SHALL report a session as not alive when the tmux session object still exists but its agent panes’ CLI processes have all exited (the is_tmux_alive input becomes false) — so a session whose panes died shortly after launch is reported Stopped, not Active. Liveness SHALL NOT be satisfied by the mere existence of the named tmux session; it SHALL require at least one live agent pane (a pane whose launched CLI process is still running). This ensures git paw status and git paw doctor never report a session healthy after its panes have all died (the full-auto boot-exit failure mode). The probe SHALL tolerate the brief launch window in which panes are still starting their CLIs (it SHALL NOT flap a just-launched session to Stopped).

The probe SHALL report “agent panes exited” as a separate liveness outcome from “tmux session absent”. 🔴 stale and the JSON "stale" value SHALL mean only that the tmux session is absent. git paw status SHALL NOT report a session as stale when its tmux session exists and all its agent panes have exited. For such a session it SHALL:

  • display 🟡 stopped;
  • print a hint that the agent panes exited and that git paw start recovers them;
  • report the tmux session as running: Tmux: running in the human output and "tmux_running": true in JSON;
  • never say that the tmux session no longer exists.

start auto-invalidation and purge --stale SHALL keep deciding staleness with the single tmux has-session probe. As a result, git paw start recovers a session whose agent panes exited, and purge --stale never purges it.

The probe SHALL identify coding-agent panes by their pane-scoped @paw_role option rather than by pane position, because a pane index shifts when an earlier pane is killed. It SHALL classify each pane independently: a pane whose role is dashboard or supervisor is never a coding-agent pane, any other role is, and a pane carrying no role falls back to the positional rule. git-paw SHALL set @paw_role on every pane it creates, whether or not [layout].border_affordances is enabled. Consequently a paused session — git paw pause kills the dashboard pane, and tmux renumbers the survivors — SHALL NOT be reported as having exited agent panes while any of its agents is still running.

Scenario: A session whose agent panes all exited reports Stopped

  • GIVEN a session recorded Active whose tmux session object still exists but all agent panes’ CLI processes have exited
  • WHEN its effective status is resolved via the liveness probe
  • THEN the liveness probe SHALL report the session not alive
  • AND the effective status SHALL be Stopped (not Active)

Scenario: A session with at least one live agent pane remains Active

  • GIVEN a session recorded Active with at least one agent pane whose CLI process is still running
  • WHEN its effective status is resolved
  • THEN the liveness probe SHALL report the session alive
  • AND the effective status SHALL be Active

Scenario: Exited agent panes are a distinct liveness outcome from an absent session

  • GIVEN a tmux session that exists, created past the launch grace period, whose every agent pane runs a bare shell
  • WHEN the agent-pane liveness probe runs
  • THEN it SHALL return the agent-panes-exited outcome, not the outcome for an absent tmux session
  • AND for a tmux session that does not exist the same probe SHALL return the absent-session (stale) outcome

Scenario: Status shows exited agent panes as stopped with tmux running

  • GIVEN a session whose receipt says active, whose tmux session exists, and whose agent panes have all exited past the launch grace period
  • WHEN the user runs git paw status
  • THEN the output SHALL display 🟡 stopped, not 🔴 stale
  • AND it SHALL print a hint that the agent panes exited and that git paw start recovers them
  • AND it SHALL print Tmux: running
  • AND it SHALL NOT state that the tmux session no longer exists

Scenario: JSON status for exited agent panes reports stopped with tmux running

  • GIVEN the same session (tmux session present, all agent panes exited)
  • WHEN the user runs git paw status --json
  • THEN the response object SHALL contain "status": "stopped" and "tmux_running": true

Scenario: An absent tmux session still reports stale with tmux not running

  • GIVEN a session whose receipt says active and whose tmux session does not exist
  • WHEN the user runs git paw status --json
  • THEN the response object SHALL contain "status": "stale" and "tmux_running": false

Scenario: purge –stale leaves a session whose agent panes exited

  • GIVEN a session in the current repository whose receipt says active, whose tmux session exists, and whose agent panes have all exited
  • WHEN the user runs git paw purge --stale
  • THEN that session’s receipt SHALL NOT be purged
  • AND the command SHALL report that there is nothing to purge

Scenario: A paused session whose dashboard pane was killed reports paused, not exited

  • GIVEN a paused session whose dashboard pane git paw pause killed, whose remaining agent pane tmux has renumbered into the dashboard’s old index, and whose agent process is still running
  • WHEN the user runs git paw status and git paw doctor
  • THEN status SHALL display 🔵 paused and SHALL NOT print the agent-panes-exited hint
  • AND doctor’s session-state finding SHALL NOT say that its agent panes exited

Requirement: Worktree entry records the worker CLI session id

The WorktreeEntry struct SHALL include an optional field cli_session_id: Option<String> recording the session identifier git-paw assigned to the worker’s CLI at launch. The field SHALL be annotated #[serde(default, skip_serializing_if = "Option::is_none")] so session files written before this change load with None and an entry without an id serializes byte-identically to before.

Scenario: Assigned id round-trips

  • GIVEN a WorktreeEntry with cli_session_id = Some("3f2c…") saved to disk
  • WHEN the session is loaded back
  • THEN cli_session_id SHALL be Some("3f2c…")

Scenario: Older session files load with None

  • GIVEN a session JSON file with no cli_session_id field on its worktree entries
  • WHEN the session is loaded
  • THEN every entry’s cli_session_id SHALL be None
  • AND loading SHALL succeed without error

Scenario: Entry without an id serializes as before

  • GIVEN a WorktreeEntry with cli_session_id = None
  • WHEN the session is saved and the JSON inspected
  • THEN the JSON SHALL NOT contain a cli_session_id key

Requirement: Recovery relaunches a worker with its recorded session id

When git paw start recovers a stopped session, the system SHALL relaunch each worktree whose entry carries a cli_session_id AND whose CLI resolves a resume template with that template’s arguments appended to the CLI launch command, so the worker’s conversation context is restored. A worktree whose entry has no id, or whose CLI resolves no resume template, SHALL be relaunched exactly as before this change. Recovery SHALL NOT fail because a resume template is absent.

Scenario: Recovered Claude worker is resumed

  • GIVEN a stopped session whose worktree entry for feat/x has cli = "claude" and cli_session_id = Some("<uuid>"), and no [clis.claude] override
  • WHEN the session is recovered
  • THEN the pane command for feat/x SHALL contain --resume <uuid>

Scenario: Entry without an id launches fresh

  • GIVEN a stopped session whose worktree entry has cli_session_id = None
  • WHEN the session is recovered
  • THEN the pane command SHALL be identical to the pre-change recovery command for that entry

Scenario: CLI without a resume template launches fresh

  • GIVEN a worktree entry with cli = "my-agent", cli_session_id = Some("<id>"), and no [clis.my-agent].resume_args
  • WHEN the session is recovered
  • THEN the pane command SHALL contain no resume arguments
  • AND recovery SHALL succeed

Requirement: Status JSON exposes the worker CLI session id

git paw status --json SHALL include each agent’s cli_session_id (a string, or null when unassigned) so an operator or external orchestrator can resume a worker by hand. The human-readable git paw status output SHALL be unchanged.

Scenario: JSON status carries the id

  • GIVEN an active session whose feat/x entry has cli_session_id = Some("<uuid>")
  • WHEN git paw status --json is run
  • THEN the feat/x agent object SHALL contain "cli_session_id": "<uuid>"

Scenario: JSON status shows null when unassigned

  • GIVEN an agent whose entry has no session id
  • WHEN git paw status --json is run
  • THEN that agent object SHALL contain "cli_session_id": null

Scenario: Human-readable status is unchanged

  • WHEN git paw status is run without --json
  • THEN the output SHALL be byte-identical to the pre-change output for the same session

Requirement: Worktree entry records the spec it was launched from

The WorktreeEntry struct SHALL include an optional field spec_id: Option<String> recording the id of the SpecEntry an agent was launched from. Every launch path that creates an agent from a spec — the spec-driven launch with or without supervisor mode, and git paw add --from-spec — SHALL set it to that spec’s id; a launch without a spec (bare git paw start, git paw add <branch>) SHALL leave it None. The field SHALL be annotated #[serde(default, skip_serializing_if = "Option::is_none")] so session files written before this field existed load with None and an entry without a spec serializes byte-identically to before.

Scenario: A spec-driven launch records each agent’s spec id

  • GIVEN a spec-driven launch of specs 003-user-list and 004-error-handling
  • WHEN the session is saved and loaded back
  • THEN each agent’s worktree entry SHALL carry the spec_id of the spec it was launched from

Scenario: A launch without a spec records no spec id

  • GIVEN a bare git paw start with explicit branches
  • WHEN the session is saved and the JSON inspected
  • THEN no worktree entry SHALL contain a spec_id key

Scenario: Older session files load with no spec id

  • GIVEN a session JSON file whose worktree entries have no spec_id field
  • WHEN the session is loaded
  • THEN loading SHALL succeed without error
  • AND every entry’s spec_id SHALL be None

Requirement: Session records its pane layout

The Session struct SHALL include an optional field recording the window’s pane layout, written as the agent grid by every start, recovery and restart that builds or rebuilds the window. The field SHALL be annotated #[serde(default, skip_serializing_if = "Option::is_none")] so session files written earlier load unchanged. A session without the field SHALL be read as the agent grid when its mode is supervised, and as the earlier tiled layout when its mode is bare; consumers that depend on the geometry (add, remove) SHALL decide from this field and the mode, never from the mode alone.

Scenario: New sessions record the grid

  • GIVEN a bare git paw start with two branches
  • WHEN the session state is saved
  • THEN the session file SHALL record the grid layout

Scenario: Earlier session files load unchanged

  • GIVEN a session file written before this field existed
  • WHEN it is loaded
  • THEN loading SHALL succeed and the layout field SHALL be absent

Scenario: An earlier bare session reads as tiled

  • GIVEN a loaded bare session without the layout field
  • WHEN git paw add inspects its layout
  • THEN it SHALL be treated as the tiled layout

Scenario: An earlier supervised session reads as the grid

  • GIVEN a loaded supervised session without the layout field
  • WHEN git paw add inspects its layout
  • THEN it SHALL be treated as the agent grid

Requirement: Session file records the authority credential

The Session struct SHALL include an optional field broker_authority: Option<String> annotated #[serde(default, skip_serializing_if = "Option::is_none")], so session files written before this change load with None and a session without a broker serializes byte-identically to before. save_session SHALL write the session file with owner-only permissions (0600) on Unix. The per-repo discovery file (RepoSessionFile) SHALL NOT carry the credential.

Scenario: Credential round-trips

  • GIVEN a Session with broker_authority = Some("<hex>") saved to disk
  • WHEN the session is loaded back
  • THEN broker_authority SHALL be Some("<hex>")

Scenario: Older session files load with None

  • GIVEN a session JSON file with no broker_authority field
  • WHEN the session is loaded
  • THEN broker_authority SHALL be None
  • AND loading SHALL succeed

Scenario: Session file is owner-only

  • WHEN save_session writes a session file on Unix
  • THEN the file’s mode SHALL be 0600

Scenario: Discovery file never carries the credential

  • GIVEN a session with a credential
  • WHEN the per-repo discovery file is written
  • THEN it SHALL NOT contain a broker_authority key

Configuration

Purpose

Parse TOML configuration from global (~/.config/git-paw/config.toml) and per-repo (.git-paw/config.toml) files. Supports custom CLI definitions, presets, and programmatic add/remove of custom CLIs with repo config overriding global config.

Requirements

Requirement: Parse TOML config with all fields

The system SHALL parse a TOML configuration file containing default_cli, mouse, clis, presets, and optional sections [specs], [logging], [broker], and [supervisor].

Scenario: Config with all fields populated

  • GIVEN a TOML file with default_cli, mouse, custom CLIs, presets, [broker], and [supervisor] sections
  • WHEN the file is loaded
  • THEN all fields SHALL be correctly parsed including supervisor fields

Scenario: All fields are optional

  • GIVEN a TOML file with only default_cli
  • WHEN the file is loaded
  • THEN missing fields SHALL default to None or empty collections
  • AND supervisor SHALL be None

Requirement: Merge repo config over global config

The system SHALL merge per-repo configuration on top of global configuration, with repo values taking precedence for scalar fields and map entries.

Scenario: Repo overrides global scalar fields

  • GIVEN global config has default_cli = "claude" and mouse = true, and repo has default_cli = "gemini"
  • WHEN configs are merged
  • THEN default_cli SHALL be "gemini" and mouse SHALL be true (preserved from global)

Test: config::tests::repo_config_overrides_global_scalars

Scenario: CLI maps are merged

  • GIVEN global config has CLI agent-a and repo config has CLI agent-b
  • WHEN configs are merged
  • THEN both CLIs SHALL be present

Test: config::tests::repo_config_merges_cli_maps

Scenario: Repo CLI overrides global CLI with same name

  • GIVEN both global and repo define a CLI named my-agent
  • WHEN configs are merged
  • THEN the repo definition SHALL win

Test: config::tests::repo_cli_overrides_global_cli_with_same_name

Scenario: Only global config exists

  • GIVEN a global config file but no repo config
  • WHEN load_config() is called
  • THEN global values SHALL be used

Test: config::tests::load_config_from_reads_global_file_when_no_repo

Scenario: Only repo config exists

  • GIVEN a repo config file but no global config
  • WHEN load_config() is called
  • THEN repo values SHALL be used

Test: config::tests::load_config_from_reads_repo_file_when_no_global

Requirement: Preset lookup by name

The system SHALL provide access to named presets that define branches and a CLI.

Scenario: Preset accessible by name

  • GIVEN a config with a preset named "backend"
  • WHEN get_preset("backend") is called
  • THEN it SHALL return the preset with its branches and CLI

Test: config::tests::preset_accessible_by_name

Scenario: Missing preset returns None

  • GIVEN a config without the requested preset
  • WHEN get_preset("nonexistent") is called
  • THEN it SHALL return None

Test: config::tests::preset_returns_none_when_not_in_config

Requirement: Add custom CLIs to global config

The system SHALL add custom CLI definitions to the global config, resolving non-absolute commands via PATH.

Scenario: Add CLI with absolute path

  • GIVEN an absolute path to a CLI binary
  • WHEN add_custom_cli() is called
  • THEN the CLI SHALL be written to the config file

Test: config::tests::add_cli_writes_to_config_file

Scenario: Adding preserves existing entries

  • GIVEN an existing CLI in the config
  • WHEN a second CLI is added
  • THEN both CLIs SHALL be present

Test: config::tests::add_cli_preserves_existing_entries

Scenario: Adding CLI with missing command fails

  • GIVEN a command that does not exist on PATH
  • WHEN add_custom_cli() is called
  • THEN it SHALL return an error mentioning “not found on PATH”

Test: config::tests::add_cli_errors_when_command_not_on_path

Requirement: Remove custom CLIs from global config

The system SHALL remove a custom CLI by name, returning an error if the CLI is not found.

Scenario: Remove existing CLI

  • GIVEN a config with CLIs keep-me and remove-me
  • WHEN remove_custom_cli("remove-me") is called
  • THEN only keep-me SHALL remain

Test: config::tests::remove_cli_deletes_entry_from_config_file

Scenario: Remove nonexistent CLI returns error

  • GIVEN a config without the named CLI
  • WHEN remove_custom_cli() is called
  • THEN it SHALL return PawError::CliNotFound

Test: config::tests::remove_nonexistent_cli_returns_cli_not_found_error

Scenario: Remove CLI from empty/missing config returns error

  • GIVEN no config file exists
  • WHEN remove_custom_cli() is called
  • THEN it SHALL return PawError::CliNotFound

Test: config::tests::remove_cli_from_empty_config_returns_error

Requirement: Config survives round-trip serialization

A PawConfig SHALL be identical after save and reload.

Scenario: Config round-trip

  • GIVEN a fully populated config
  • WHEN saved and loaded back
  • THEN it SHALL be equal to the original

Test: config::tests::config_survives_save_and_load

Requirement: Config loading SHALL work with real files

The system SHALL provide a load_config(repo_root, user_config_path) function that loads the merged PawConfig from the per-repo .git-paw/config.toml and a user-level (global) config.toml. The second parameter user_config_path: Option<&Path> SHALL control which file is read as the user-level config:

  • When user_config_path is None, the loader SHALL resolve the user-level config path via the platform-default helper (global_config_path() → crate::dirs::config_dir().join("git-paw/config.toml")), preserving the v0.4 production behaviour.
  • When user_config_path is Some(p), the loader SHALL read p as the user-level config and SHALL NOT consult the platform-default helper. If p does not exist on disk, the user-level side of the merge SHALL be the default PawConfig, exactly as if no file existed at the platform-default path.

The merge semantics on top of the user-level config (per-repo config overrides user-level for scalar fields and map entries) are unchanged from prior requirements in this capability.

Scenario: Defaults when no files exist

  • GIVEN a temp directory with no config files
  • AND load_config is called with user_config_path = None
  • WHEN load_config() is called
  • THEN all fields SHALL be None/empty

Test: config_integration::load_config_returns_defaults_when_no_files_exist

Scenario: Reads repo .git-paw/config.toml

  • GIVEN a .git-paw/config.toml with default_cli and mouse
  • AND load_config is called with user_config_path = Some(&unused_temp_path)
  • WHEN load_config() is called
  • THEN the values SHALL be read correctly

Test: config_integration::load_config_reads_repo_config

Scenario: Repo config with custom CLIs

  • GIVEN a .git-paw/config.toml with two custom CLIs
  • AND load_config is called with user_config_path = Some(&unused_temp_path)
  • WHEN load_config() is called
  • THEN both CLIs SHALL be parsed with correct fields

Test: config_integration::repo_config_with_custom_clis

Scenario: Repo config with presets

  • GIVEN a .git-paw/config.toml with two presets
  • AND load_config is called with user_config_path = Some(&unused_temp_path)
  • WHEN load_config() is called
  • THEN presets SHALL be accessible with correct branches and CLI

Test: config_integration::repo_config_with_presets

Scenario: Default PawConfig has no presets

  • GIVEN a default PawConfig
  • WHEN get_preset("nonexistent") is called
  • THEN it SHALL return None

Test: config_integration::get_preset_returns_none_for_unknown

Scenario: Repo config overrides default fields

  • GIVEN a .git-paw/config.toml with specific values
  • AND load_config is called with user_config_path = Some(&unused_temp_path)
  • WHEN load_config() is called
  • THEN the repo values SHALL take precedence

Test: config_integration::repo_config_overrides_default_fields

Scenario: Repo config path is correct

  • GIVEN a temp directory
  • WHEN repo_config_path() is called
  • THEN it SHALL return <dir>/.git-paw/config.toml

Test: config_integration::repo_config_path_is_in_repo_root

Scenario: Malformed TOML returns error

  • GIVEN a .git-paw/config.toml with invalid TOML
  • AND load_config is called with user_config_path = Some(&unused_temp_path)
  • WHEN load_config() is called
  • THEN it SHALL return an error

Test: config_integration::malformed_toml_returns_error

Scenario: Empty config file is valid

  • GIVEN an empty .git-paw/config.toml
  • AND load_config is called with user_config_path = Some(&unused_temp_path)
  • WHEN load_config() is called
  • THEN it SHALL return a default config

Test: config_integration::empty_config_file_is_valid

Scenario: None preserves platform-default user-config resolution

  • GIVEN a repo TempDir with no .git-paw/config.toml
  • AND the platform-default user config path (crate::dirs::config_dir().join("git-paw/config.toml")) is a readable file containing a custom CLI named globally-registered
  • WHEN load_config(&repo, None) is called
  • THEN the returned PawConfig.clis SHALL contain globally-registered
  • AND the loader SHALL have resolved the user-level path via global_config_path(), exactly matching v0.4 behaviour

Test: config::tests::load_config_with_none_reads_platform_default_global

Scenario: Some(path) pins the user-level read to that path

  • GIVEN a TempDir containing two distinct files:
    • tmp/global-A.toml defining custom CLI cli-A
    • tmp/global-B.toml defining custom CLI cli-B
  • AND an unrelated CLI cli-C is registered at the platform-default user-config path
  • WHEN load_config(&repo, Some(&tmp.join("global-A.toml"))) is called
  • THEN the returned PawConfig.clis SHALL contain cli-A
  • AND it SHALL NOT contain cli-B or cli-C

Test: config::tests::load_config_with_some_pins_global_to_override_path

Scenario: Some(nonexistent path) returns defaults for the user-level side

  • GIVEN a TempDir and a path tmp/does-not-exist.toml that has never been written
  • AND an unrelated CLI cli-leak is registered at the platform-default user-config path
  • WHEN load_config(&repo, Some(&tmp.join("does-not-exist.toml"))) is called
  • THEN the user-level side of the merge SHALL be the default PawConfig
  • AND the returned PawConfig.clis SHALL NOT contain cli-leak
  • AND no error SHALL be returned (a missing user-config file is not an error)

Test: config::tests::load_config_with_some_nonexistent_returns_defaults

Scenario: Override path does not affect repo-config resolution

  • GIVEN a TempDir with .git-paw/config.toml defining default_cli = "claude"
  • AND a separate path tmp/global.toml defining default_cli = "gemini"
  • WHEN load_config(&tmp, Some(&tmp.join("global.toml"))) is called
  • THEN the repo-level default_cli = "claude" SHALL override the user-level default_cli = "gemini" per the existing repo-overrides-user merge semantics
  • AND the override parameter SHALL only control which user-level file is read, never the repo-level resolution

Test: config::tests::load_config_override_does_not_affect_repo_resolution

Requirement: Custom CLI management SHALL persist through file I/O

Scenario: Add CLI with absolute path

  • GIVEN no config file
  • WHEN add_custom_cli_to() is called with an absolute path
  • THEN the CLI SHALL be persisted and reloadable

Test: config_integration::add_custom_cli_with_absolute_path

Scenario: Add CLI with display name

  • GIVEN no config file
  • WHEN add_custom_cli_to() is called with a display name
  • THEN the display name SHALL be persisted

Test: config_integration::add_custom_cli_with_display_name

Scenario: Multiple CLIs preserved across adds

  • GIVEN 4 CLIs added sequentially
  • WHEN the config is loaded
  • THEN all 4 SHALL be present with correct fields

Test: config_integration::add_multiple_custom_clis_preserves_all

Scenario: Adding overwrites existing entry

  • GIVEN a CLI with name my-agent already exists
  • WHEN add_custom_cli_to() is called with the same name but different values
  • THEN the new values SHALL replace the old

Test: config_integration::add_cli_overwrites_existing_entry

Scenario: Add CLI with nonexistent command fails

  • GIVEN a non-absolute command that is not on PATH
  • WHEN add_custom_cli_to() is called
  • THEN it SHALL return an error

Test: config_integration::add_cli_with_nonexistent_path_command_fails

Scenario: Remove custom CLI

  • GIVEN two CLIs in the config
  • WHEN one is removed
  • THEN only the other SHALL remain

Test: config_integration::remove_custom_cli

Scenario: Remove nonexistent CLI returns error

  • GIVEN no CLIs in the config
  • WHEN remove_custom_cli_from() is called
  • THEN it SHALL return an error

Test: config_integration::remove_nonexistent_cli_returns_error

Scenario: Remove all CLIs leaves empty config

  • GIVEN one CLI in the config
  • WHEN it is removed
  • THEN the CLI map SHALL be empty

Test: config_integration::remove_all_custom_clis_leaves_empty_config

Requirement: Global and repo config SHALL merge custom CLIs correctly

Scenario: Repo custom CLIs merge with global

  • GIVEN global config with 2 CLIs and repo config with 2 CLIs (one overlapping)
  • WHEN load_config_from() is called
  • THEN the result SHALL have 3 CLIs, with repo winning on collision

Test: config_integration::repo_custom_clis_merge_with_global_custom_clis

Requirement: Config SHALL handle many custom CLIs

Scenario: Config with 10 custom CLIs

  • GIVEN a config file with 10 custom CLI definitions
  • WHEN load_config() is called
  • THEN all 10 SHALL be parsed correctly

Test: config_integration::config_with_many_custom_clis

Requirement: The system SHALL support a default_spec_cli config field

The system SHALL support a default_spec_cli field in PawConfig that specifies the CLI to use for --from-specs branches that don’t have a paw_cli override, bypassing the interactive picker.

Scenario: default_spec_cli set

  • WHEN a config has default_spec_cli = "claude"
  • THEN PawConfig.default_spec_cli SHALL be Some("claude")

Scenario: default_spec_cli absent

  • WHEN a config has no default_spec_cli field
  • THEN PawConfig.default_spec_cli SHALL be None

Scenario: Merge preserves repo override

  • WHEN global config has default_spec_cli = "claude" and repo config has default_spec_cli = "gemini"
  • THEN the merged config SHALL have default_spec_cli = Some("gemini")

Requirement: Repo SHALL override new v0.2.0 scalar fields

Scenario: Repo overrides new v0.2.0 scalar fields

  • GIVEN global config has default_spec_cli = "claude" and repo has default_spec_cli = "gemini"
  • WHEN configs are merged
  • THEN default_spec_cli SHALL be "gemini"

Requirement: Specs configuration section

The system SHALL support an optional [specs] section with a dir field and a type field. Field names SHALL match the spec-scanning capability and the implementation in src/config.rs::SpecsConfig.

  • dir: String — path (relative to the repo root) to the directory containing spec files
  • type: String — backend identifier (e.g. "openspec", "markdown"); the field is exposed as spec_type in Rust to avoid clashing with the type keyword and is serialised as type in TOML/JSON via #[serde(rename = "type")]

When the [specs] section is absent, the optional specs field on PawConfig SHALL be None.

Scenario: Specs section with all fields

  • GIVEN a TOML file with [specs] containing dir = "openspec/specs" and type = "openspec"
  • WHEN the file is loaded
  • THEN specs.dir SHALL be "openspec/specs"
  • AND specs.spec_type SHALL be "openspec"

Scenario: Specs section defaults

  • GIVEN a TOML file without a [specs] section
  • WHEN the file is loaded
  • THEN specs SHALL be None

Scenario: Round-trip preserves rename

  • GIVEN a SpecsConfig { dir: "openspec/specs".into(), spec_type: "openspec".into() }
  • WHEN the value is serialised to TOML and parsed back
  • THEN the resulting TOML SHALL contain type = "openspec" (not spec_type)
  • AND parsing SHALL succeed and reproduce the original struct

Requirement: Logging configuration section

The system SHALL support an optional [logging] section with enabled and log_dir fields.

Scenario: Logging section with all fields

  • GIVEN a TOML file with [logging] containing enabled = true and log_dir = ".git-paw/logs"
  • WHEN the file is loaded
  • THEN logging.enabled SHALL be true and logging.log_dir SHALL be ".git-paw/logs"

Scenario: Logging section defaults

  • GIVEN a TOML file without a [logging] section
  • WHEN the file is loaded
  • THEN logging SHALL be None

Requirement: Default config generation

The system SHALL provide a function to generate a default config.toml string with active defaults and commented-out fields including the [supervisor] section.

Scenario: Generated config contains commented supervisor examples

  • WHEN the default config string is generated
  • THEN it SHALL contain commented-out examples for [supervisor] with enabled, cli, test_command, and agent_approval fields

Scenario: Generated config contains commented examples

  • WHEN the default config string is generated
  • THEN it SHALL contain commented-out examples for default_spec_cli, branch_prefix, [specs], [logging], [broker], and [supervisor]

Requirement: Config round-trip with new fields

A PawConfig with v0.2.0 fields populated SHALL be identical after save and reload.

Scenario: Config with specs and logging round-trips

  • GIVEN a config with default_spec_cli, branch_prefix, specs, and logging populated
  • WHEN saved and loaded back
  • THEN it SHALL be equal to the original

Requirement: Broker configuration section

The system SHALL support an optional [broker] section with the following fields:

  • enabled: bool — defaults to false when the field or section is absent
  • port: u16 — defaults to 9119 when absent
  • bind: String — defaults to "127.0.0.1" when absent

The BrokerConfig struct SHALL provide a url(&self) -> String method returning http://<bind>:<port>.

Scenario: Broker section with all fields

  • GIVEN a TOML file with [broker] containing enabled = true, port = 9200, bind = "127.0.0.1"
  • WHEN the file is loaded
  • THEN broker.enabled SHALL be true, broker.port SHALL be 9200, broker.bind SHALL be "127.0.0.1"

Scenario: Broker section defaults

  • GIVEN a TOML file without a [broker] section
  • WHEN the file is loaded
  • THEN broker SHALL have enabled = false, port = 9119, bind = "127.0.0.1"

Scenario: Partial broker section

  • GIVEN a TOML file with [broker] containing only enabled = true
  • WHEN the file is loaded
  • THEN broker.enabled SHALL be true, broker.port SHALL be 9119, broker.bind SHALL be "127.0.0.1"

Scenario: BrokerConfig url method

  • GIVEN BrokerConfig { enabled: true, port: 9200, bind: "127.0.0.1" }
  • WHEN url() is called
  • THEN the result SHALL be "http://127.0.0.1:9200"

Scenario: Broker config round-trips through save and load

  • GIVEN a config with [broker] fully populated
  • WHEN saved and loaded back
  • THEN all broker fields SHALL match the original

Requirement: Internal callers SHALL preserve v0.4 behaviour by passing None

All production call sites of load_config inside the git-paw binary SHALL pass None as the user_config_path argument, so production behaviour is byte-identical to the v0.4 single-argument load_config(repo_root) API.

The Option<&Path> argument SHALL exist only to give test code a discoverable way to isolate the user-level config read from whatever exists at the dev machine’s platform-default path. No production code path SHALL pass Some(_).

Scenario: All production call sites pass None

  • GIVEN the source tree
  • WHEN every call site of config::load_config inside src/ is inspected
  • THEN every call SHALL be of the form config::load_config(&repo_root, None) (modulo whitespace and the exact name of the repo_root binding)
  • AND no production call site SHALL pass Some(_)

Test: covered by cargo build (compile-time) plus a focused grep-style assertion in src/main.rs::tests or equivalent — see tasks.md task 2.

Scenario: Production behaviour is byte-identical to v0.4

  • GIVEN a binary built from this change
  • AND the same .git-paw/config.toml and platform-default user config that a v0.4 binary would read
  • WHEN any production command that calls load_config runs (e.g. git paw start, git paw add-cli, git paw dashboard)
  • THEN the merged PawConfig the command operates on SHALL be equal to the merged PawConfig v0.4 would have produced

Test: behaviour preserved by construction (every production call passes None); verified by the v0.4 test suite continuing to pass unchanged plus the new load_config_with_none_reads_platform_default_global unit test.

Requirement: MCP configuration section

The system SHALL parse an optional [mcp] section in .git-paw/config.toml into an McpConfig exposed as PawConfig.mcp. The struct SHALL contain an optional name: Option<String> field defaulting to None when absent, and an allow_repo_writes: bool field defaulting to false when absent and omitted from serialized output when false. A config file with no [mcp] section SHALL load with McpConfig::default() (name: None, allow_repo_writes: false), so pre-existing configs load unchanged. When the user-level and repository configs are merged, allow_repo_writes SHALL take the repository config’s value only; a value set in the user-level config SHALL have no effect.

Scenario: Config with [mcp] name parses the field

  • GIVEN a config file with [mcp] setting name = "my-project"
  • WHEN the config is loaded
  • THEN PawConfig.mcp.name SHALL be Some("my-project")

Scenario: Config with [mcp] allow_repo_writes parses the field

  • GIVEN a repository config file with [mcp] setting allow_repo_writes = true
  • WHEN the config is loaded
  • THEN PawConfig.mcp.allow_repo_writes SHALL be true

Scenario: Config without [mcp] section loads with defaults

  • GIVEN a config file with no [mcp] section
  • WHEN the config is loaded
  • THEN PawConfig.mcp SHALL equal McpConfig::default() with name set to None and allow_repo_writes set to false
  • AND loading SHALL NOT error

Scenario: The user-level config cannot enable repository writes

  • GIVEN a user-level config setting [mcp] allow_repo_writes = true
  • AND a repository config that does not set it
  • WHEN the merged config is loaded
  • THEN PawConfig.mcp.allow_repo_writes SHALL be false

Scenario: MCP config survives round-trip serialization

  • GIVEN a PawConfig whose mcp.name is set and whose mcp.allow_repo_writes is true
  • WHEN it is serialized to TOML and re-parsed
  • THEN the re-parsed mcp field SHALL equal the original

Scenario: A false switch is not serialized

  • GIVEN a PawConfig whose mcp.allow_repo_writes is false
  • WHEN it is serialized to TOML
  • THEN the output SHALL NOT contain allow_repo_writes

Requirement: Worktree placement configuration field

The system SHALL support an optional worktree_placement field on PawConfig accepting the string values "child" or "sibling". When the field is absent, the effective placement SHALL be sibling, preserving v0.7.0 behaviour. The field SHALL participate in the repo-overrides-global merge as a scalar (repo value wins). A default-valued field SHALL be skipped on serialization so that a config without an explicit worktree_placement round-trips without the field appearing.

Scenario: worktree_placement set to child

  • GIVEN a TOML file with worktree_placement = "child"
  • WHEN the config is loaded
  • THEN the effective placement SHALL be child

Scenario: worktree_placement set to sibling

  • GIVEN a TOML file with worktree_placement = "sibling"
  • WHEN the config is loaded
  • THEN the effective placement SHALL be sibling

Scenario: worktree_placement absent defaults to sibling

  • GIVEN a TOML file with no worktree_placement field
  • WHEN the config is loaded
  • THEN the effective placement SHALL be sibling

Scenario: Repo placement overrides global

  • GIVEN global config has worktree_placement = "sibling" and repo config has worktree_placement = "child"
  • WHEN configs are merged
  • THEN the merged placement SHALL be child

Scenario: Placement config survives round-trip

  • GIVEN a PawConfig with worktree_placement = "child"
  • WHEN it is serialized to TOML and re-parsed
  • THEN the re-parsed placement SHALL be child

Scenario: Pre-existing config without the field loads unchanged

  • GIVEN a .git-paw/config.toml written before this field exists
  • WHEN the config is loaded
  • THEN loading SHALL NOT error
  • AND the effective placement SHALL be sibling

Requirement: Broker log panel height configuration

The system SHALL support an optional height_lines field on the [dashboard.broker_log] table, a positive integer giving the number of terminal rows the Broker log panel occupies when visible. When the field or the [dashboard.broker_log] table is absent, the effective height SHALL default to a value strictly greater than the v0.6.0 fixed 12 (the documented default), so configs written before this field exists — including existing configs — load unchanged. The field SHALL participate in the repo-overrides-global merge as a scalar (repo value wins) and SHALL survive round-trip serialization.

Scenario: height_lines explicitly configured

  • GIVEN a TOML file with [dashboard.broker_log] height_lines = 24
  • WHEN the config is loaded
  • THEN dashboard.broker_log.height_lines SHALL be 24

Scenario: height_lines absent uses the default

  • GIVEN a TOML file with a [dashboard.broker_log] table that omits height_lines
  • WHEN the config is loaded
  • THEN dashboard.broker_log.height_lines SHALL equal the documented default, which SHALL be strictly greater than 12

Scenario: Pre-existing config without the field loads unchanged

  • GIVEN a .git-paw/config.toml written before this field exists (e.g. a [dashboard] section with no broker_log table)
  • WHEN the config is loaded
  • THEN loading SHALL NOT error
  • AND dashboard.broker_log.height_lines SHALL equal the documented default

Scenario: height_lines round-trips through save and load

  • GIVEN a config with [dashboard.broker_log] height_lines = 30
  • WHEN the config is serialized to TOML and re-parsed
  • THEN the re-parsed dashboard.broker_log.height_lines SHALL be 30

Requirement: Configuration reference documents the specs type value

docs/src/configuration/README.md’s commented [specs] example SHALL document type as accepting "openspec", "markdown", AND "speckit". The v0.4-era example listing only "openspec" and "markdown" SHALL be updated to add "speckit".

Scenario: Configuration reference lists all three specs.type values

  • WHEN the [specs] example in docs/src/configuration/README.md is inspected
  • THEN it documents "speckit" as a valid value alongside "openspec" and "markdown"

docs/src/configuration/README.md SHALL NOT contain Markdown links pointing at anchors that do not exist in their target files. Specifically the link previously pointing at coordination.md#automatic-conflict-detection-v050 SHALL either target a real anchor that exists in coordination.md post-refresh or be removed.

Scenario: Configuration reference has no broken conflict-detection anchor

  • WHEN docs/src/configuration/README.md is inspected
  • THEN any [...]( ... ) link targeting coordination.md points at an anchor that exists in coordination.md (verified by the anchor heading being present in the target file’s rendered TOC)

Requirement: Configuration reference documents the configuration tables

docs/src/configuration/README.md SHALL include subsections documenting [supervisor.conflict], [supervisor.learnings_config], and [governance]. Each subsection SHALL list every field, its type, its default value (where applicable), and a one-line description. It SHALL also document [supervisor.auto_approve] and [supervisor.common_dev_allowlist] as deprecated: both are still accepted, have no effect, print a deprecation warning when present, and can be removed.

Scenario: Configuration reference documents supervisor.conflict

  • WHEN docs/src/configuration/README.md is inspected
  • THEN it contains a subsection or table for [supervisor.conflict]
  • AND the subsection mentions window_seconds, warn_on_intent_overlap, and escalate_on_violation

Scenario: Configuration reference marks the auto-approval tables deprecated

  • WHEN docs/src/configuration/README.md is inspected
  • THEN it names [supervisor.auto_approve] and [supervisor.common_dev_allowlist]
  • AND states that both are deprecated and have no effect
  • AND points to the agent CLI’s own permission rules, or the full-auto approval level inside a sandbox, as the replacement

Scenario: Configuration reference documents supervisor.learnings_config

  • WHEN docs/src/configuration/README.md is inspected
  • THEN it contains a subsection or table for [supervisor.learnings_config]
  • AND the subsection mentions flush_interval_seconds

Scenario: Configuration reference documents governance

  • WHEN docs/src/configuration/README.md is inspected
  • THEN it contains a subsection or table for [governance]
  • AND the subsection lists all five fields: adr, test_strategy, security, dod, constitution

Interactive Selection

Purpose

Interactive selection prompts for choosing branches and AI CLIs. Supports uniform (same CLI for all branches) and per-branch assignment modes, with CLI flags that skip prompts. Logic is separated from UI via the Prompter trait for testability.

Requirements

Requirement: CLI flags skip all prompts when both provided

When both --cli and --branches flags are provided, the system SHALL skip all interactive prompts and map the CLI to all specified branches.

Scenario: Both flags skip all prompts

  • GIVEN --cli alpha and --branches feature/auth,fix/api flags
  • WHEN run_selection() is called
  • THEN it SHALL return mappings without invoking any prompts

Test: interactive::tests::both_flags_skips_all_prompts_and_maps_cli_to_all_branches

Requirement: CLI flag skips CLI prompt but prompts for branches

When only --cli is provided, the system SHALL prompt for branch selection but skip CLI selection.

Scenario: CLI flag provided, branches prompted

  • GIVEN --cli alpha flag and no branches flag
  • WHEN run_selection() is called
  • THEN branch selection SHALL be prompted and the flag CLI SHALL be used

Test: interactive::tests::cli_flag_skips_cli_prompt_but_prompts_for_branches

Requirement: Branches flag skips branch prompt but prompts for CLI

When only --branches is provided, the system SHALL skip branch selection but prompt for CLI assignment.

Scenario: Branches flag provided, CLI prompted in uniform mode

  • GIVEN --branches flag and no CLI flag
  • WHEN user selects uniform mode
  • THEN the selected CLI SHALL be mapped to all flagged branches

Test: interactive::tests::branches_flag_skips_branch_prompt_but_prompts_for_cli_uniform

Requirement: Uniform mode maps same CLI to all branches

In uniform mode, the system SHALL assign the selected CLI to every selected branch.

Scenario: Uniform mode selection

  • GIVEN user selects uniform mode, picks 2 branches and 1 CLI
  • WHEN run_selection() completes
  • THEN both branches SHALL be mapped to the same CLI

Test: interactive::tests::uniform_mode_maps_same_cli_to_all_selected_branches

Requirement: Per-branch mode maps different CLIs to each branch

In per-branch mode, the system SHALL prompt for a CLI for each selected branch individually.

Scenario: Per-branch mode selection

  • GIVEN user selects per-branch mode with 2 branches
  • WHEN different CLIs are chosen for each branch
  • THEN each branch SHALL be mapped to its respective CLI

Test: interactive::tests::per_branch_mode_maps_different_cli_to_each_branch

Scenario: Per-branch mode with branches flag

  • GIVEN branches provided via flag and per-branch mode selected
  • WHEN different CLIs are chosen
  • THEN each flagged branch SHALL be mapped to its selected CLI

Test: interactive::tests::per_branch_mode_with_branches_flag

Requirement: Error when no CLIs available

The system SHALL return PawError::NoCLIsFound when the CLI list is empty.

Scenario: Empty CLI list

  • GIVEN no CLIs available
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::NoCLIsFound)

Test: interactive::tests::no_clis_available_returns_error

Requirement: Error when no branches available

The system SHALL return PawError::BranchError when the branch list is empty.

Scenario: Empty branch list

  • GIVEN no branches available
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::BranchError)

Test: interactive::tests::no_branches_available_returns_error

Requirement: User cancellation propagates as PawError::UserCancelled

The system SHALL propagate cancellation (Ctrl+C or empty selection) as PawError::UserCancelled.

Scenario: User cancels branch selection

  • GIVEN user presses Ctrl+C during branch selection
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::UserCancelled)

Test: interactive::tests::user_cancels_branch_selection_returns_cancelled

Scenario: User selects no branches

  • GIVEN user confirms with zero branches selected
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::UserCancelled)

Test: interactive::tests::user_selects_no_branches_returns_cancelled

Scenario: User cancels CLI selection

  • GIVEN user presses Ctrl+C during CLI selection
  • WHEN run_selection() is called
  • THEN it SHALL return Err(PawError::UserCancelled)

Test: interactive::tests::user_cancels_cli_selection_returns_cancelled

Requirement: Subset branch selection

The system SHALL support selecting a subset of available branches.

The branch picker (select_branches) SHALL present a fuzzy-filter multi-select. The user SHALL be able to type a query that filters the visible branch candidates by case-insensitive substring match against the branch name. An empty query SHALL show the full list of available branches in their original order. Toggling selection SHALL operate on the currently visible (filtered) rows; branches selected under one query that are then hidden by a different query SHALL remain selected and SHALL be returned on confirm. Clearing the query (back to empty) SHALL restore the full list with all prior selections intact.

Cancellation semantics SHALL be unchanged: pressing Ctrl+C SHALL yield PawError::UserCancelled, and confirming with zero branches selected SHALL yield PawError::UserCancelled.

Scenario: Selecting one of two branches

  • GIVEN 2 available branches
  • WHEN user selects only the second
  • THEN only that branch SHALL appear in the result

Test: interactive::tests::selecting_subset_of_branches_works

Scenario: Typing a query filters the branch candidates

  • GIVEN branches feature/auth, fix/api, feature/login
  • WHEN the user types feature into the branch picker filter
  • THEN only feature/auth and feature/login SHALL be visible
  • AND fix/api SHALL NOT be visible

Scenario: Empty filter shows the full branch list

  • GIVEN the branch picker with 3 candidates
  • WHEN the filter query is empty
  • THEN all 3 branches SHALL be visible in their original order

Scenario: Selection under an active filter is preserved when the filter changes

  • GIVEN branches feature/auth, fix/api, feature/login
  • WHEN the user types feature, toggles feature/auth, then changes the query to fix and toggles fix/api, then confirms
  • THEN the result SHALL contain both feature/auth and fix/api

Scenario: Clearing the filter restores the full list with selections intact

  • GIVEN the branch picker where the user has typed feature and toggled feature/auth
  • WHEN the user clears the query back to empty
  • THEN all branches SHALL be visible again
  • AND feature/auth SHALL still be marked as selected

Scenario: User cancels the filtered branch picker via Ctrl+C

  • GIVEN the branch picker is open with an active filter query
  • WHEN the user presses Ctrl+C
  • THEN select_branches SHALL return Err(PawError::UserCancelled)

Scenario: Confirming with zero branches selected cancels

  • GIVEN the branch picker is open
  • WHEN the user confirms without toggling any branch
  • THEN select_branches SHALL return Err(PawError::UserCancelled)

Requirement: CliMode display format

The CliMode enum SHALL display as human-readable descriptions.

Scenario: CliMode display strings

  • GIVEN CliMode::Uniform and CliMode::PerBranch
  • WHEN formatted with Display
  • THEN they SHALL render as "Same CLI for all branches" and "Different CLI per branch"

Test: interactive::tests::cli_mode_display

Requirement: CliInfo display format

CliInfo SHALL display as the binary name when it matches the display name, or as "DisplayName (binary)" when they differ.

Scenario: Same display and binary name

  • GIVEN a CliInfo where display_name equals binary_name
  • WHEN formatted with Display
  • THEN it SHALL render as just the binary name

Test: interactive::tests::cli_info_display_same_names

Scenario: Different display and binary name

  • GIVEN a CliInfo where display_name differs from binary_name
  • WHEN formatted with Display
  • THEN it SHALL render as "DisplayName (binary_name)"

Test: interactive::tests::cli_info_display_different_names

Requirement: CLI picker with optional pre-selection

The select_cli method on the Prompter trait SHALL accept an optional default CLI name for pre-selection in the interactive picker.

Scenario: Picker with default pre-selected

  • WHEN select_cli() is called with default = Some("claude") and "claude" is in the CLI list
  • THEN the picker SHALL display with "claude" highlighted as the default selection

Scenario: Picker without default

  • WHEN select_cli() is called with default = None
  • THEN the picker SHALL display with the first item selected (no pre-selection)

Scenario: Default CLI not in available list

  • WHEN select_cli() is called with default = Some("nonexistent") and that CLI is not available
  • THEN the picker SHALL display with no pre-selection (graceful fallback)

Requirement: Spec multi-select picker

The Prompter trait SHALL include a select_specs(&self, specs: &[SpecEntry]) -> Result<Vec<SpecEntry>, PawError> method that presents a multi-select picker for spec entries and returns the user’s chosen subset.

The default TerminalPrompter implementation SHALL display one row per logical spec unit (a feature in Spec Kit terms; a change in OpenSpec; a file in plain Markdown), grouping multiple SpecEntry values that decompose from the same Spec Kit feature into a single row. Selecting a row SHALL cause every SpecEntry belonging to that row’s logical unit to be returned.

Each row’s display label SHALL include the unit identifier and, for Spec Kit features that decompose into multiple worktrees, a worktree-count hint summarising the breakdown (e.g. "003-user-list — 3 worktrees: 2 [P] + 1 phase/"). For OpenSpec changes and Markdown specs, the label SHALL be the unit identifier alone (one entry → one worktree, no hint needed).

The spec picker SHALL present a fuzzy-filter multi-select that behaves identically to the branch picker (select_branches). The user SHALL be able to type a query that filters the visible rows by case-insensitive substring match against the row’s display label. An empty query SHALL show every grouped row in its original order. Toggling selection SHALL operate on the currently visible (filtered) rows; rows selected under one query that are then hidden by a different query SHALL remain selected and SHALL be returned on confirm. Clearing the query (back to empty) SHALL restore the full set of rows with all prior selections intact. The logical-unit grouping and worktree-count hint labels SHALL be unchanged from the non-filtered behavior — filtering matches against the displayed labels, and selecting a visible row SHALL still expand to every underlying SpecEntry for that unit.

The picker SHALL behave the same way as select_branches for cancellation:

  • User pressing Ctrl+C → PawError::UserCancelled.
  • User confirming with zero rows selected → PawError::UserCancelled.

Scenario: select_specs returns the chosen subset

  • GIVEN 3 OpenSpec entries add-auth, fix-session, add-logging
  • WHEN the user toggles add-auth and add-logging and presses enter
  • THEN select_specs returns a Vec containing those two entries

Scenario: select_specs groups Spec Kit entries by feature

  • GIVEN 4 SpecEntry values from a Spec Kit project: two [P] entries (003-user-list-T009, 003-user-list-T010), one consolidated entry (003-user-list-phase-2), and one entry from a different feature (004-error-handling-phase-1)
  • WHEN the picker renders
  • THEN it displays exactly 2 rows — one per logical feature
  • AND the row for feature 003-user-list shows a worktree-count hint summarising the 3 underlying entries (2 [P] + 1 phase/)

Scenario: Selecting a Spec Kit feature row pulls in all its entries

  • GIVEN a picker rendering one row for feature 003-user-list (3 underlying SpecEntry values)
  • WHEN the user selects only that row and confirms
  • THEN select_specs returns all 3 underlying SpecEntry values

Scenario: User cancels spec picker via Ctrl+C

  • GIVEN the spec picker is open
  • WHEN the user presses Ctrl+C
  • THEN select_specs returns Err(PawError::UserCancelled)

Scenario: User confirms with zero rows selected

  • GIVEN the spec picker is open with N rows displayed
  • WHEN the user confirms without toggling any row
  • THEN select_specs returns Err(PawError::UserCancelled)

Scenario: Typing a query filters the spec rows

  • GIVEN OpenSpec entries add-auth, fix-session, add-logging
  • WHEN the user types add into the spec picker filter
  • THEN only the rows for add-auth and add-logging SHALL be visible
  • AND the row for fix-session SHALL NOT be visible

Scenario: Empty filter shows every grouped spec row

  • GIVEN the spec picker with 3 grouped rows
  • WHEN the filter query is empty
  • THEN all 3 rows SHALL be visible in their original order

Scenario: Selecting a filtered Spec Kit row still expands to all its entries

  • GIVEN a Spec Kit feature 003-user-list (3 underlying SpecEntry values) and another feature 004-error-handling
  • WHEN the user types 003, selects the visible 003-user-list row, and confirms
  • THEN select_specs SHALL return all 3 underlying SpecEntry values for 003-user-list

Scenario: Clearing the spec filter restores all rows with selections intact

  • GIVEN the spec picker where the user has typed add and toggled the add-auth row
  • WHEN the user clears the query back to empty
  • THEN all rows SHALL be visible again
  • AND the add-auth row SHALL still be marked as selected

Requirement: Spec picker requires an interactive terminal

When the start command would invoke select_specs (i.e. the user passed --specs with no values), the system SHALL detect whether stdin is connected to a terminal before invoking the picker. If stdin is NOT a terminal (CI, scripted invocation, redirected input), the system SHALL exit with an actionable error pointing at the explicit forms (--specs NAME[,NAME...] to narrow, --from-all-specs to launch every discovered spec).

The system SHALL NOT block waiting for picker input on a non-interactive stdin.

Scenario: Bare –specs in non-TTY environment exits with guidance

  • GIVEN the user runs git paw start --specs with stdin redirected (or no controlling terminal)
  • WHEN the start command attempts to open the picker
  • THEN the command SHALL exit with a non-zero status before any picker UI is drawn
  • AND the error message SHALL point the user at --specs NAME[,NAME...] and --from-all-specs

Scenario: Bare –specs on TTY proceeds to picker

  • GIVEN the user runs git paw start --specs from an interactive terminal
  • WHEN the start command runs
  • THEN the picker SHALL open
  • AND no TTY-required error SHALL be emitted

Requirement: Spec name resolution for narrow mode

When --specs is passed with one or more values (narrow mode), the system SHALL resolve each value against the discovered SpecEntry set returned by scan_specs(). Resolution SHALL apply the following matching strategies in order, taking the first that succeeds:

  1. Exact match on SpecEntry.id (case-sensitive). For Spec Kit, this matches a specific decomposed entry like 003-user-list-T009. For OpenSpec / Markdown, it matches the change name or filename stem.
  2. Spec Kit feature match on the feature directory prefix of the SpecEntry.id (e.g. 003-user-list matches all entries belonging to that feature). When the value matches a Spec Kit feature unambiguously, ALL entries belonging to that feature SHALL be selected.
  3. Spec Kit numeric prefix match (e.g. 003) matching a Spec Kit feature directory name’s leading numeric portion. The match SHALL succeed only when exactly one feature directory begins with the given prefix followed by a non-digit boundary; ambiguous prefixes SHALL be rejected (see below).

Resolution SHALL fail (and the start command SHALL exit before any worktrees are created) when:

  • A value matches no SpecEntry and no feature.
  • A Spec Kit numeric prefix matches more than one feature directory (ambiguous).

The resulting error SHALL list the unresolved or ambiguous names AND the discovered candidate names so the user can correct quickly.

Scenario: Exact match resolves to a single SpecEntry

  • GIVEN a discovered set including OpenSpec change add-auth
  • WHEN the user passes --specs add-auth
  • THEN the resolved set SHALL contain exactly that one SpecEntry

Scenario: Spec Kit feature name resolves to all decomposed entries

  • GIVEN a Spec Kit feature 003-user-list decomposing into 3 SpecEntry values (2 [P] + 1 consolidated)
  • WHEN the user passes --specs 003-user-list
  • THEN the resolved set SHALL contain all 3 entries belonging to that feature

Scenario: Spec Kit numeric prefix resolves unambiguously

  • GIVEN a Spec Kit project with a single feature directory beginning with 003- (e.g. 003-user-list)
  • WHEN the user passes --specs 003
  • THEN the resolved set SHALL contain all entries belonging to that feature

Scenario: Ambiguous numeric prefix is rejected

  • GIVEN a Spec Kit project containing both 003-user-list and 003a-experiment
  • WHEN the user passes --specs 003
  • THEN the start command SHALL exit with an error
  • AND the error message SHALL list both candidate feature names

Scenario: Unknown spec name is rejected with candidate list

  • GIVEN a discovered set containing add-auth, fix-session
  • WHEN the user passes --specs no-such-spec
  • THEN the start command SHALL exit with an error
  • AND the error message SHALL include no-such-spec
  • AND the error message SHALL list add-auth and fix-session as candidates

Scenario: Multiple values are resolved independently

  • GIVEN a discovered set including add-auth, fix-session, add-logging
  • WHEN the user passes --specs add-auth,add-logging
  • THEN the resolved set SHALL contain entries for add-auth and add-logging
  • AND the resolved set SHALL NOT contain the entry for fix-session

Scenario: Partial-failure batches do not partially start

  • GIVEN a user passes --specs add-auth,no-such-spec
  • WHEN resolution runs
  • THEN the start command SHALL exit with the unknown-name error
  • AND no worktrees SHALL be created
  • AND the error message SHALL include no-such-spec (the unresolved name)

Requirement: Configured default CLI is pre-selected in the interactive CLI picker

run_selection() SHALL accept an optional default CLI name and SHALL pass it to the CLI picker in both assignment modes: to select_cli() when the user chooses uniform mode, and to select_cli_for_branch() for every branch when the user chooses per-branch mode. The Prompter trait’s select_cli_for_branch() SHALL therefore accept an optional default with the same semantics as select_cli()’s. Every caller of the interactive selection flow (git paw start, and the supervisor launch’s branch selection) SHALL supply the loaded configuration’s default_cli.

default_cli SHALL only choose the picker’s starting entry. It SHALL NOT skip the picker: when --cli is not given, the mode prompt and the CLI picker (or, in per-branch mode, each per-branch picker) SHALL still appear, and the user can accept the pre-selected CLI or pick any other listed CLI. If default_cli is unset, or names a CLI that is not available, the picker SHALL start on the first entry (the existing select_cli() behaviour). --cli SHALL still skip the CLI prompt entirely.

Scenario: default_cli pre-selects in the uniform-mode picker

  • GIVEN default_cli = "beta", no --cli flag, and the user chooses uniform mode
  • WHEN run_selection() is called with the default CLI "beta"
  • THEN select_cli() SHALL receive default = Some("beta")
  • AND every selected branch SHALL map to the CLI the picker returns

Scenario: default_cli pre-selects in every per-branch picker

  • GIVEN default_cli = "beta", no --cli flag, two selected branches, and the user chooses per-branch mode
  • WHEN run_selection() is called with the default CLI "beta"
  • THEN select_cli_for_branch() SHALL receive default = Some("beta") for each branch
  • AND each branch SHALL map to the CLI its own picker returns

Scenario: The picker is still shown when default_cli is set

  • GIVEN default_cli = "beta" and no --cli flag
  • WHEN run_selection() is called
  • THEN the mode prompt and the CLI picker SHALL both be invoked
  • AND a different CLI the user picks there SHALL be the one mapped to the branches

Scenario: No default_cli starts the picker on the first entry

  • GIVEN no default_cli and no --cli flag, with the user choosing uniform mode
  • WHEN run_selection() is called with no default CLI
  • THEN select_cli() SHALL receive default = None

Scenario: Per-branch picker starts on a configured default

  • GIVEN a terminal prompter and a CLI list in which "beta" is the second entry
  • WHEN select_cli_for_branch() is called with default = Some("beta")
  • THEN the picker SHALL display with "beta" highlighted as the default selection
  • AND with default = None or a CLI that is not listed, it SHALL start on the first entry

Scenario: –cli flag wins over default_cli

  • GIVEN --cli alpha and default_cli = "beta"
  • WHEN run_selection() is called
  • THEN no mode prompt or CLI picker SHALL be invoked
  • AND every selected branch SHALL map to "alpha"

Scenario: Bare git paw start pre-selects the configured default CLI

  • GIVEN a repository configuring two available CLIs and default_cli naming the one that sorts second in the picker
  • WHEN the user runs git paw start --branches <branch> --dry-run in a terminal, chooses uniform mode, and confirms the CLI picker without moving the cursor
  • THEN the dry-run plan SHALL map the branch to the configured default_cli

Error Handling

Purpose

Define the central error type PawError used across all git-paw modules. Every variant carries an actionable, user-facing message and maps to a process exit code.

Requirements

Requirement: Actionable error messages for each variant

Each PawError variant SHALL produce a user-facing message that explains the problem and suggests a remedy where appropriate.

Scenario: NotAGitRepo is actionable

  • GIVEN PawError::NotAGitRepo
  • WHEN formatted with Display
  • THEN the message SHALL mention “git repository” and name the tool

Test: error::tests::test_not_a_git_repo_is_actionable

Scenario: TmuxNotInstalled includes install instructions

  • GIVEN PawError::TmuxNotInstalled
  • WHEN formatted with Display
  • THEN the message SHALL include both brew install and apt install hints

Test: error::tests::test_tmux_not_installed_includes_install_instructions

Scenario: NoCLIsFound suggests add-cli

  • GIVEN PawError::NoCLIsFound
  • WHEN formatted with Display
  • THEN the message SHALL suggest the add-cli command

Test: error::tests::test_no_clis_found_suggests_add_cli

Scenario: WorktreeError includes detail

  • GIVEN PawError::WorktreeError("failed to create")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_worktree_error_includes_detail

Scenario: SessionError includes detail

  • GIVEN PawError::SessionError("file corrupt")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_session_error_includes_detail

Scenario: ConfigError includes detail

  • GIVEN PawError::ConfigError("invalid toml")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_config_error_includes_detail

Scenario: BranchError includes detail

  • GIVEN PawError::BranchError("not found")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_branch_error_includes_detail

Scenario: UserCancelled has a message

  • GIVEN PawError::UserCancelled
  • WHEN formatted with Display
  • THEN the message SHALL not be empty

Test: error::tests::test_user_cancelled_is_not_empty

Scenario: TmuxError includes detail

  • GIVEN PawError::TmuxError("session failed")
  • WHEN formatted with Display
  • THEN the message SHALL include the inner detail string

Test: error::tests::test_tmux_error_includes_detail

Scenario: CliNotFound includes CLI name

  • GIVEN PawError::CliNotFound("my-agent")
  • WHEN formatted with Display
  • THEN the message SHALL include the missing CLI name

Test: error::tests::test_cli_not_found_includes_cli_name

Requirement: Exit codes distinguish cancellation from errors

UserCancelled and NotAttempted SHALL exit with code 2; all other errors SHALL exit with code 1. NotAttempted reports that a command wrote nothing because a condition for proceeding was not met — a declined confirmation, a confirmation that could not be shown because standard input is not a terminal, or a requested item that is not supported or not enabled for its target. Its message SHALL state that condition and, where one exists, name the flag or command that proceeds, and SHALL NOT read only Cancelled..

Scenario: UserCancelled exit code

  • GIVEN PawError::UserCancelled
  • WHEN exit_code() is called
  • THEN it SHALL return 2

Test: error::tests::test_user_cancelled_exit_code

Scenario: NotAttempted exit code and message

  • GIVEN PawError::NotAttempted built for a confirmation that could not be shown, naming --yes
  • WHEN exit_code() is called and the error is formatted with Display
  • THEN exit_code() SHALL return 2
  • AND the message SHALL name --yes
  • AND the message SHALL NOT be Cancelled.

Test: error::tests::test_not_attempted_exit_code_and_message

Scenario: General errors exit code

  • GIVEN any error variant other than UserCancelled and NotAttempted
  • WHEN exit_code() is called
  • THEN it SHALL return 1

Test: error::tests::test_general_errors_exit_code

Requirement: Exit method prints to stderr and exits with correct code

PawError::exit() SHALL print the error message to stderr and terminate with the appropriate exit code.

Scenario: NotAGitRepo exits with code 1

  • GIVEN the binary is run outside a git repository
  • WHEN the error propagates to exit()
  • THEN the process SHALL exit with code 1 and stderr SHALL contain the error message

Test: e2e_tests::error_exit_code_is_1_for_not_a_git_repo

Scenario: ConfigError exits with code 1

  • GIVEN a nonexistent preset is requested
  • WHEN the error propagates to exit()
  • THEN the process SHALL exit with code 1 and stderr SHALL mention “not found”

Test: e2e_tests::error_exit_code_is_1_for_preset_not_found

Requirement: Debug representation is derivable

All PawError variants SHALL support Debug formatting.

Scenario: Debug format includes variant name

  • GIVEN PawError::NotAGitRepo
  • WHEN formatted with Debug
  • THEN the output SHALL contain "NotAGitRepo"

Test: error::tests::test_debug_derived

Requirement: SkillError variants with actionable messages

The system SHALL define a SkillError type with variants for skill loading failures. Each variant SHALL produce a user-facing message that explains the problem and suggests a remedy. SkillError SHALL be wrappable inside PawError as a variant.

The following variants SHALL exist:

  • UnknownSkill { name: String } — no embedded or user override found for the requested skill name
  • UserOverrideRead { path: PathBuf, source: std::io::Error } — a user override file exists but cannot be read

Scenario: UnknownSkill is actionable

  • GIVEN SkillError::UnknownSkill { name: "nonexistent" }
  • WHEN formatted with Display
  • THEN the message SHALL mention the skill name "nonexistent" and indicate no embedded default exists

Scenario: UserOverrideRead is actionable

  • GIVEN SkillError::UserOverrideRead { path: "/home/user/.config/git-paw/agent-skills/coordination.md", .. }
  • WHEN formatted with Display
  • THEN the message SHALL include the file path and suggest checking permissions

Scenario: SkillError exit code

  • GIVEN any SkillError variant wrapped in PawError
  • WHEN exit_code() is called
  • THEN it SHALL return 1

Requirement: BrokerError variants with actionable messages

The system SHALL define a BrokerError type with variants for broker-specific failures. Each variant SHALL produce a user-facing message that explains the problem and suggests a remedy. BrokerError SHALL be wrappable inside PawError as a variant.

The following variants SHALL exist:

  • PortInUse { port: u16, source: std::io::Error } — the configured port is already occupied by a process that is not a git-paw broker; source carries the underlying bind/probe io::Error so callers can chain or log the original cause
  • PortHeldByOtherRepo { port: u16, repo_root: Option<String> } — the configured port is held by a git-paw broker serving a different repository, or one that reports no repository at all (an older git-paw). Distinct from PortInUse, because the two have different causes and different fixes and must be distinguishable to users and to tests
  • ProbeTimeout { port: u16 } — the stale-broker probe timed out
  • BindFailed(std::io::Error) — socket bind failed for a reason other than port-in-use
  • RuntimeFailed(std::io::Error) — tokio runtime construction failed

PortInUse.source SHALL be marked #[source] (or equivalent thiserror attribute) so it participates in std::error::Error::source() chains. The Display output SHALL NOT include the source by default — it is reserved for explicit chaining via {:?} or programmatic .source() access — to avoid duplicated diagnostics in user-facing CLI output.

PortHeldByOtherRepo.repo_root is reported by whatever process answered the probe and is therefore untrusted; it SHALL be stripped of control characters and capped in length before it appears in the Display output.

Scenario: PortInUse is actionable

  • GIVEN BrokerError::PortInUse { port: 9119, source: io::Error::from(io::ErrorKind::AddrInUse) }
  • WHEN formatted with Display
  • THEN the message SHALL mention port 9119 and suggest changing [broker] port in config
  • AND the message SHALL NOT contain the underlying io::Error Display text

Scenario: PortInUse exposes underlying cause

  • GIVEN a PortInUse value with an AddrInUse source
  • WHEN std::error::Error::source() is called on it
  • THEN the result SHALL be Some(&dyn Error) referencing the wrapped io::Error

Scenario: PortHeldByOtherRepo is actionable

  • GIVEN BrokerError::PortHeldByOtherRepo { port: 9119, repo_root: Some("/home/you/other-repo".to_string()) }
  • WHEN formatted with Display
  • THEN the message SHALL mention port 9119
  • AND the message SHALL name the holding repository /home/you/other-repo
  • AND the message SHALL tell the user to set a distinct [broker] port

Scenario: PortHeldByOtherRepo names an unknown holder without guessing

  • GIVEN BrokerError::PortHeldByOtherRepo { port: 9119, repo_root: None }
  • WHEN formatted with Display
  • THEN the message SHALL mention port 9119 and the [broker] port fix
  • AND the message SHALL say an older git-paw broker may be holding the port
  • AND the message SHALL NOT claim the broker belongs to the caller

Scenario: PortHeldByOtherRepo renders untrusted text safely

  • GIVEN a PortHeldByOtherRepo whose repo_root was reported with ANSI escape sequences and other control characters
  • WHEN formatted with Display
  • THEN the rendered message SHALL contain no control characters

Scenario: ProbeTimeout is actionable

  • GIVEN BrokerError::ProbeTimeout { port: 9119 }
  • WHEN formatted with Display
  • THEN the message SHALL mention the port and suggest checking for stuck processes

Scenario: BrokerError exit code

  • GIVEN any BrokerError variant wrapped in PawError
  • WHEN exit_code() is called
  • THEN it SHALL return 1

Requirement: A wrapping error’s Display does not embed a redundant error prefix

A PawError variant that wraps a message SHALL NOT embed the word error in its own Display, because the top-level exit path prepends error: when printing. Doubling it produces user-facing output like error: Worktree error: …. Each string-wrapping variant SHALL render as <Category>: <message> (e.g. Worktree: …, Session: …), so the printed line carries exactly one error: prefix.

Scenario: A wrapping variant prints a single error prefix

  • GIVEN a PawError variant that wraps a message (e.g. a worktree failure)
  • WHEN its Display is rendered and the exit path prepends error:
  • THEN the resulting line SHALL contain exactly one error: prefix
  • AND the variant’s own Display SHALL NOT contain the word error

Scenario: Standalone messages are unchanged

  • GIVEN a variant whose message stands alone (e.g. the not-a-git-repository or cancelled messages)
  • WHEN its Display is rendered
  • THEN it SHALL be unchanged by this requirement

Complete capability index

Every capability spec in the repository, grouped by its <namespace>- prefix (links go to the canonical openspec/specs/ source on GitHub). Each entry’s blurb is condensed from that spec’s ## Purpose.

A few entries are internal process specs — they govern the test suite, CI, or the verification workflow itself (e.g. core-ci-hygiene, core-opsx-role-gating) rather than a user-facing feature. These intentionally have no user-guide chapter: the spec itself is their documentation.

core-

  • core-configuration — parses and merges TOML config from global and per-repo files, with repo config overriding global.
  • core-init — bootstraps a repo for git-paw (.git-paw/ scaffold, default config, .gitignore update), idempotently.
  • core-error-handling — the central PawError type: every variant carries an actionable message and maps to a process exit code.
  • core-lang-agnostic — keeps bundled skills project-agnostic via config-sourced placeholder substitutions, an authored-in-assets rule for agent-facing prose, and a CI no-language-leak audit.
  • core-ci-hygiene — isolates the test suite from the live environment and closes the local-vs-CI gap via cold-start smoke recipes and convention-enforcement tests.
  • core-memory-isolation — keeps coding agents from writing outside their worktree into the operator’s config and memory territory, carried by the bundled skills’ doctrine and the supervisor’s violation response.
  • core-project-conventions — repo-wide contributor conventions and their doc accuracy: the AGENTS.md dependency table in sync with Cargo.toml, the commit-scope list, the mdBook architecture module list and changelog include, and the README’s enumeration of the current user-facing surface.
  • core-governance-config — PawConfig.governance: optional root-relative pointers to a project’s governance/doc artifacts, paths-only with no gating semantics.
  • core-opsx-role-gating — enforces the supervisor-only boundary on /opsx:verify and /opsx:archive, with a post-commit guard detecting archive activity by coding agents.
  • core-selftest — git paw selftest runs an isolated end-to-end session lifecycle against a throwaway repo with a dummy CLI and reports a single pass/fail verdict.
  • core-security-posture — the consolidated trust-model disclaimer (what the agent CLI’s permission system, the sandbox and remote control do and do not guarantee, and that git-paw makes no safety decision and approves no prompt) and the documented FS-scoped sandbox workflow (sandbox-exec/bwrap via the custom-CLI seam), plus the git paw doctor sandbox-availability pointer.

cli-

  • cli-parsing — the clap v4 CLI: all subcommands, flags, and argument validation, defaulting to start when no subcommand is given.
  • cli-resolution — detects AI coding CLIs on PATH, merges user-defined custom CLIs, and resolves which CLI each branch uses via a priority chain.
  • cli-interactive-selection — interactive prompts for choosing branches and CLIs (uniform or per-branch), with logic separated from UI via the Prompter trait.
  • cli-prompt-profiles — per-CLI prompt-shape profiles: the readiness/approval/mid-response/mode markers and command-header, file-prompt and option-line forms describing how one CLI’s terminal surface looks — appearance only, never permission policy, and never able to widen the live-prompt gate or change which option sweep.sh approve presses — with the Claude Code profile embedded as the per-field default and shared as the single source of prompt-shape literals for the compiled code and the supervisor helper script.
  • cli-setup — git paw setup-cli <CLI>, the only path by which git-paw writes into an agent CLI’s settings, run by the user as their consent: a compiled per-CLI fact table with unverified items left plan-only, a printed plan and a confirmation before any write, path-scoped helper allow rules shipped with the rules that guard those helpers against modification, a setup record, and an exact --undo.

code-

  • code-architecture — the enforceable structural and regression contract for behavior-preserving refactors: byte-identical observable surface (CLI/config/wire), injectable process/tmux/git seam, single newtype construction boundary, domain-module organization with preserved re-exports, frozen serde/wire/lock/SIGHUP surfaces untouched, and test-gated waves.

git-

  • git-operations — validates repos, lists branches, creates/removes worktrees, and derives worktree directory names — the git plumbing under parallel sessions.
  • git-hook-injection — installs a post-commit dispatcher, pre-push block, and per-worktree branch-guard hooks: committing publishes agent.artifact, pushes are blocked, and pre-existing user content is preserved.
  • git-add-remove-branch — git paw add/remove attach or detach a single agent worktree+pane on a live session, re-tiling the grid without a restart.

tmux-

  • tmux-orchestration — orchestrates tmux sessions with per-pane CLIs in worktrees via a testable builder with dry-run and the agent-grid layout.

safe-

  • safe-process-invocation — safe construction of the tmux/process commands built from untrusted inputs: the tmux session name is sanitized at a single boundary and shell-typed paths/commands are shell-quoted, so names with spaces or shell metacharacters cannot break out of a tmux target or a shell word.

session-

  • session-state — persists one JSON file per session for crash recovery, with atomic writes, tmux-liveness stale detection, and per-repo session receipts.
  • session-logging — captures raw per-pane terminal output via tmux pipe-pane and replays it (ANSI strip/preserve, fuzzy branch match, most-recent auto-select).
  • session-runtime-isolation — per-worktree runtime provisioning: verbatim env-file copy, index-derived port blocks written to a generated .env.local, and consumer on_create/on_remove lifecycle hooks (stdout merged into .env.local).

boot-

  • boot-block — the standardized boot-instruction block injected into each agent: its format/content, {{VARIABLE}} substitution, and the shared build_boot_block renderer.
  • boot-agents-md — injects a marker-delimited git-paw section into AGENTS.md and writes per-worktree AGENTS.md combining root content with worktree assignment sections.

broker-

  • broker-protocol — the complete wire protocol: the BrokerMessage tagged enum, agent_id slugification, publish validation, and the delivery layer’s per-variant routing, sequencing, and cursor polling.
  • broker-runtime — the in-process coordination server: [broker] config, the tokio/axum lifecycle, HTTP surface (/publish, /messages, /status, /log, /watch), and session-lifecycle integration.
  • broker-watcher-and-state — keeps the /status roster honest as worktrees change: filesystem watcher, roster hygiene, working/committed republishing, terminal-status stickiness, and the introspection surface.
  • broker-agent-helper — bundled shell helpers (broker.sh for agents, sweep.sh for the supervisor) that wrap every broker interaction and detect stuck-agent shapes.
  • broker-conflict-detection — a broker-internal, supervisor-mode detector that flags forward/in-flight/ownership conflicts across agent intents and modified-file sets (with sub-file regions).
  • broker-dashboard — a ratatui TUI observing broker state: an agent-status table with a pinned supervisor row plus a scrolling, filterable broker-log panel.
  • broker-messages — forward-compatible payload deserialization for the wire freeze: no-non-empty-contract list fields default to empty when absent (lean/third-party publishers parse), contracted fields stay required, and serialized output is unchanged.
  • broker-server — runtime robustness of the broker HTTP server: blocking subprocess work runs off the tokio worker threads so a publish burst cannot stall other endpoints, and a poisoned state lock is recovered rather than fatal.

supervisor-

  • supervisor-launch — orchestrates the full supervisor session launch via git_paw::commands::supervisor: layout, worktrees, pane structure, boot prompts, and the attach vs --unattended drive-loop branch.
  • supervisor-config — defines the [supervisor] config schema: the approval level and its per-CLI flag mapping, nested sub-tables, gate-command templates, and the deprecated-but-parsed auto-approval tables.
  • supervisor-skill-discipline — encodes the disciplines the bundled supervisor skill teaches: sweep-driven pane work, isolated verification worktrees, stream-timeout recovery, per-event verification, and the no-fail-fast testing gate.
  • supervisor-unattended-operation — the in-process drive loop under --unattended that keeps a wave moving with no human: polling every pane, escalating each live permission prompt for a human decision without blocking, recovering buffered input, detecting completion and CLI unavailability, and exiting with a per-agent summary. It approves nothing.
  • supervisor-learnings — an opt-in, broker-internal aggregator that derives deterministic and qualitative learning signals into .git-paw/session-learnings.md (and the agent.learning broker variant), performing no telemetry.
  • supervisor-correction-loop — the optional [supervisor.correction] self-healing loop that re-engages a gate-failed worker’s pane with the gate feedback, bounds retries by a per-branch cycle count, and applies an escalate-or-abandon policy when the budget is exhausted.
  • supervisor-autonomous-orchestrator — the pump-to-brain layer: when a supervisor pane is present, the unattended loop injects each judgment call (risky prompt, agent.question, merge decision, non-converging branch) into that pane and nudges its orchestration sweep, instead of parking them in an unread inbox; with no supervisor pane nothing is injected.
  • supervisor-branch-refresh — opt-in, default-off post-merge rebase of a live worker branch onto the default branch, gated on a conjunction of safety preconditions (clean per the watcher, idle, claimed, predicted conflict-free, behind), with conflict prediction, failed-rebase abort-and-restore, exclusion of verified-awaiting-merge branches, and a HEAD-moved notification that is never agent.advanced-main.

approval-

  • approval-auto — the approval-send discipline that remains after the classifier’s retirement: the shared live-prompt gate, and approval keystrokes reaching a pane only through an explicit sweep.sh approve that re-confirms on a fresh capture, refuses pane 0 and answers once.
  • approval-pattern-surfacing — records every permission prompt the drive loop escalates to per-session JSONL and provides git paw approvals to aggregate recurring patterns, hinting which of the agent CLI’s own allowlists could absorb each.
  • approval-command-safety — detects agent-CLI permission prompts non-invasively via tmux capture-pane and the resolved prompt profile, reporting only whether an approval marker is present. git-paw makes no safety decision about an agent’s command; the CLI’s own permission system or the human decides.

spec-

  • spec-providers — discovers pending specs and represents them as launchable SpecEntry values through a pluggable backend system (OpenSpec, Markdown, Spec Kit, superpowers plans).

preflight-

  • preflight-diagnostics — git paw doctor runs read-only environment/config/repository checks, reporting each as ✓/⚠/✗ with a remedy, a --json surface, and an exit code equal to the worst check.

mcp-

  • mcp-server — git paw mcp runs a stdio JSON-RPC MCP server exposing read-only, deterministically-sourced tools over a resolved repo/worktree root.
  • mcp-agent-docs — makes the docs site machine-consumable (llms.txt, sitemap, robots, per-page metadata) and bundles a path-allowlisted docs-fetch helper and skill.
  • mcp-agent-publish — the agent→broker write path over MCP: a fixed set of publish tools for the four agent boot events (status, artifact, blocked, question), wire-identical to the shell helper, acting as the calling agent only (derived agent_id, no impersonation) and excluding the supervisor authority verbs so an agent cannot self-verify.

skill-

  • skill-agent — defines the embedded coordination and supervisor skill templates git-paw injects, their resolution order (user override then embedded default), and the placeholder-rendering contract.
  • skill-standardized — supports the agentskills.io standardized skill format (a SKILL.md directory): auto-detecting, loading, and schema-validating it with actionable errors.