Skip to content

Commit 628a673

Browse files
committed
feat: add session paths for terminal-native branching and merge workflows
1 parent 95e31c1 commit 628a673

14 files changed

Lines changed: 1462 additions & 36 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Session paths** — terminal-native branching without a worktree.
12+
`/path fork [name]` snapshots the workspace and clones the transcript;
13+
`/path switch <name>` restores another path; `/path diff` and `/path pick`
14+
compare and merge file changes with approval preview. State lives under
15+
`.sessions/paths/<session-id>/`. Config block `paths` controls enablement,
16+
max paths, and snapshot byte caps. Status line shows `path: name · N paths`
17+
when multiple paths exist. Resumes via `--continue`, `/resume`, and session
18+
metadata `activePathId`.
19+
920
## [0.3.0] - 2026-05-24
1021

1122
A polish + capability pass. Direct OpenAI provider, per-turn cost on the

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ shell, run tests — plus a few that aren't usual:
4545
- **Real undo.** `/undo` reverts the last agent turn's file mutations,
4646
including files the agent created or files that weren't tracked when the
4747
turn started.
48+
- **Session paths.** `/path fork` branches the conversation and workspace so
49+
you can try two fixes, diff them, and `/path pick` the winner — no
50+
worktree required.
4851
- **MCP-native.** Drop servers into `mcpServers` in your config; their tools
4952
show up as `mcp__<server>__<tool>` to the model on next launch.
5053
- **`/auth` instead of `.env`.** Paste API keys once into a `0600` file
@@ -117,6 +120,8 @@ A handful of moves worth knowing right away:
117120
- `/mode explore | edit | ship | review` toggles tool + approval + step-budget
118121
presets.
119122
- `/undo` reverts the last turn's file mutations.
123+
- `/path fork` branches the session to try an alternate approach; `/path switch`,
124+
`/path diff`, and `/path pick` compare and merge paths.
120125
- `/shipcheck` summarizes git state; `/handoff` drafts a commit message,
121126
changelog bullets, and a release post from local context.
122127
- `/play fix-failing-test` runs a bundled demo in an isolated sandbox so you
@@ -206,6 +211,8 @@ this exact call`. The session cache resets on `/new`.
206211
/resume latest|<id> resume a saved session
207212
/export current|<id> export transcript to markdown or json
208213
/undo revert the last agent turn's file mutations
214+
/path fork, switch, diff, pick, or drop parallel session paths
215+
/paths list saved session paths
209216
```
210217

211218
**Operator modes and workflow**
@@ -413,6 +420,12 @@ root. Common shape:
413420
"allowCloudContext": false
414421
},
415422
"checkpoints": { "enabled": true, "maxTurns": 10 },
423+
"paths": {
424+
"enabled": true,
425+
"maxPaths": 5,
426+
"maxSnapshotBytes": 52428800,
427+
"maxFileBytes": 1048576
428+
},
416429
"mcpServers": {
417430
"fs": { "command": "/usr/local/bin/some-mcp-server", "args": [] }
418431
},

docs/INTEGRATION_MATRIX.md

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,21 @@ This file is the **checklist** (G/F/P rows below). It is not what you paste into
1818
The agent must paste this filled in before you merge:
1919

2020
```
21-
## Integration matrix — [plan name]
21+
## Integration matrix — Session Paths
2222
23-
Global: G1 [ ] G2 [ ] G3 [ ] G4 [ ] G5 [ ] G6 [ ]
24-
Feature: F1 [ ] F2 [ ] F3 [ ] F4 [ ] F5 [ ] F6 [ ] F7 [ ] F8 [ ] (N/A where unchecked)
23+
Global: G1 [x] G2 [x] G3 [x] G4 [x] G5 [x] G6 [x]
24+
Feature: F1 [x] F2 [x] F3 [x] F4 [x] F5 [ ] F6 [ ] F7 [ ] F8 [x] (N/A where unchecked)
2525
2626
Plan-specific:
27-
| P1 | … | pass | test: … |
28-
| P2 | … | pass | test: … |
27+
| P1 | Fork → mutate → switch → workspace round-trips with workspaceRoot "." | pass | test: session_paths.rs::fork_switch_round_trip |
28+
| P2 | /path pick uses approval preview; dry-run does not write | pass | test: session_paths.rs::pick_dry_run_does_not_write |
29+
| P3 | /new clears path registry; resume restores active path id | pass | test: commands.rs::new_resets_path_store_for_fresh_session |
30+
| P4 | Refuse fork/switch/pick during active /play | pass | test: commands.rs::path_fork_refuses_during_play_session |
31+
| P5 | Pick result parsing uses structured applied field | pass | test: session_paths.rs::pick_applies_structured_result |
32+
| P6 | Untracked file created on branch A absent after switch to main | pass | test: session_paths.rs::fork_switch_round_trip |
2933
3034
Commands: cargo test / fmt / clippy — all green
31-
Follow-ups (if any):
35+
Follow-ups (if any): none
3236
```
3337

3438
**Your job at the end:** skim the report (~2 min). If P-rows or G-rows are blank but the feature clearly touches those areas, send it back.
@@ -77,40 +81,40 @@ Follow-ups (if any): …
7781

7882
## Current build
7983

80-
_Plan name: Play and Fix Features
81-
Date: 2026-05-23
84+
_Plan name: Session Paths
85+
Date: 2026-05-25
8286

8387
### Plan-specific rows
8488

8589
| # | Scenario | Pass? | Test added (path) |
8690
|---|----------|-------|-------------------|
87-
| P1 | `/play exit` restores original `workspace_root`, mode, and full mode-mutated config | [x] | `playground.rs::play_session_restore_round_trip` |
88-
| P2 | `/play` refuses nested start; `/fix` refuses while play active | [x] | `playground.rs::play_refuses_while_active`, `fix_loop.rs::fix_refuses_during_play_session` |
89-
| P3 | `/fix` stops when tests pass; respects `--attempts N` and `--attempts=N`; restores full mode-mutated config | [x] | `fix_loop.rs::fix_loop_stops_when_tests_pass`, `fix_loop.rs::fix_mode_restore_round_trip`, `fix_loop.rs::fix_loop_respects_max_attempts_config` |
90-
| P4 | `/play battle` produces comparison table + JSON export | [x] | `playground.rs::play_battle_saves_json_and_markdown`, `run_play_battle` wiring |
91-
| P5 | `/fix` sets `auto_verify_tests: false` to avoid double test runs | [x] | `fix_loop.rs` (explicit re-run after each turn) |
91+
| P1 | Fork → mutate → switch → workspace round-trips with `workspaceRoot: "."` | [x] | `session_paths.rs::fork_switch_round_trip`, `relative_workspace_root_capture_and_restore` |
92+
| P2 | `/path pick` dry-run does not write; live pick uses approval in `cmd_path` | [x] | `session_paths.rs::pick_dry_run_does_not_write`, `pick_applies_structured_result` |
93+
| P3 | `/new` resets path store; `/resume` + `--continue` load `activePathId` | [x] | `commands.rs::new_resets_path_store_for_fresh_session` |
94+
| P4 | Refuse `/path` fork during active `/play` | [x] | `commands.rs::path_fork_refuses_during_play_session` |
95+
| P5 | Pick uses structured `applied` field, not `"error"` substring | [x] | `session_paths.rs::pick_applies_structured_result` |
96+
| P6 | Branch-only file removed when switching back to main | [x] | `session_paths.rs::fork_switch_round_trip` |
9297

9398
### Global baseline
9499

95100
- [x] G1 [x] G2 [x] G3 [x] G4 [x] G5 [x] G6
96101

97102
Notes:
98-
- G1/G3: `turn_checkpoint.rs::relative_workspace_root_restore_round_trip`, `turn_checkpoint.rs::snapshot_works_with_relative_workspace_root`
99-
- G2/G5: `playground.rs::play_session_restore_round_trip`, `fix_loop.rs::fix_mode_restore_round_trip`, `commands.rs::mode_ship_syncs_session_checkpoints_flag`
100-
- G4: `tools::tests::batch_edit_dry_run_does_not_count_as_mutation`, `turn_checkpoint.rs::batch_edit_dry_run_should_not_push`
101-
- G6: `tools::tests::mutation_detection_uses_structured_fields_not_error_substrings`
103+
- G1/G3: `session_paths.rs::relative_workspace_root_capture_and_restore`, `resolve_under_workspace`
104+
- G2: paths are config-only (`paths.enabled`); no dual session toggle — `/new` resets `PathStore`
105+
- G4: `/path pick --dry-run` and `PickResult { dry_run, applied }` do not write files
106+
- G5: paths work in all operator modes; pick always approval-gated
107+
- G6: `PickResult.applied` gates success in tests and command handler
102108

103109
### Feature-specific (check all that apply)
104110

105-
- [x] F1 [x] F2 [x] F3 [x] F4 [x] F5 [x] F6 [x] F7 [x] F8
111+
- [x] F1 [x] F2 [x] F3 [x] F4 [ ] F5 [ ] F6 [ ] F7 [x] F8
106112

107113
Notes:
108-
- F1/F2/F3: `turn_checkpoint.rs::relative_workspace_root_restore_round_trip`, `restore_removes_created_file`, `lazy_snapshot_before_untracked_edit`
109-
- F4: `turn_checkpoint.rs::batch_edit_dry_run_should_not_push`
110-
- F5: `test_integration.rs::smart_selection_includes_untracked_files`
111-
- F6: `session_turn.rs` auto-verify path plus `tools::tests::file_edit_success_counts_as_mutation`
112-
- F7: `context_guard.rs::partition_keeps_tool_rounds_intact`, `partition_can_split_large_single_user_turn_on_tool_boundaries`
113-
- F8: `commands.rs::new_restores_play_session_and_clears_session_state`
114+
- F1/F3: `session_paths.rs::relative_workspace_root_capture_and_restore`, `untracked_file_captured`
115+
- F2: branch file removal on switch uses snapshot diff (`remove_files_not_in_snapshot`)
116+
- F4: N/A (paths are slash commands, not batch_edit)
117+
- F8: `commands.rs::new_resets_path_store_for_fresh_session`; resume loads `activePathId` from session meta
114118

115119
### Commands run
116120

src/app_state.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::backends::{backend, default_model, validate, BackendDescriptor};
77
use crate::config::AgentConfig;
88
use crate::openai::ChatMessage;
99
use crate::renderer::TuiRenderer;
10+
use crate::session_paths::PathStore;
1011
use crate::tools::Tool;
1112
use crate::turn_checkpoint::CheckpointStack;
1213
use std::sync::Arc;
@@ -77,9 +78,23 @@ pub struct AppState {
7778
/// (in `main`) and appended to every turn's tool list. Kept as Arcs so
7879
/// each turn shares the same live JSON-RPC connection per server.
7980
pub mcp_tools: Vec<Arc<dyn Tool>>,
81+
pub path_store: PathStore,
8082
}
8183

8284
impl AppState {
85+
pub fn workspace_root(&self) -> PathBuf {
86+
crate::session_paths::workspace_root_path(&self.config)
87+
}
88+
89+
pub fn paths_enabled(&self) -> bool {
90+
self.config.paths.enabled
91+
}
92+
93+
pub fn save_active_path_metadata(&self) -> Result<()> {
94+
let mut metadata = crate::session::load_session_metadata(&self.session_path)?;
95+
metadata.active_path_id = Some(self.path_store.active_id().to_string());
96+
crate::session::save_session_metadata(&self.session_path, &metadata)
97+
}
8398
pub fn rebuild_client(&mut self) -> Result<()> {
8499
let new_backend = backend(self.config.backend);
85100
validate(&new_backend)?;
@@ -98,6 +113,7 @@ impl AppState {
98113

99114
pub fn reset_session(&mut self) {
100115
self.session_path = crate::session::new_session_path(&self.session_dir);
116+
self.path_store = PathStore::new(&self.session_dir, &self.session_path, &self.config.paths);
101117
}
102118

103119
pub fn in_play_session(&self) -> bool {

0 commit comments

Comments
 (0)