diff --git a/.github/workflows/agent-checks.yml b/.github/workflows/agent-checks.yml index 58fdce6..d4a7eed 100644 --- a/.github/workflows/agent-checks.yml +++ b/.github/workflows/agent-checks.yml @@ -30,3 +30,5 @@ jobs: run: python -m unittest discover -s experiments/command_specialist -p test_contract.py -v - name: Frozen main candidate and patch notes run: python scripts/check_release.py + - name: Session workspace isolation + run: python -m unittest discover -s scripts -p test_workspace.py -v diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b8eba49..d2ea3e6 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,6 +18,7 @@ existing PRs for the same work. Run commands from the repository root with Pytho python -m compileall -q scripts experiments/command_specialist python -m unittest discover -s experiments/command_specialist -p test_bindings.py -v python -m unittest discover -s experiments/command_specialist -p test_contract.py -v +python -m unittest discover -s scripts -p test_workspace.py -v ``` Also verify the affected user operation through the public CLI and reopen its saved @@ -75,3 +76,41 @@ announce release readiness during normal work. The agents GitHub PR/check gates by Jon and agents: human authorization is a workflow rule, not an independently verified GitHub reviewer identity. The coordinator cannot merge stable. No package or runtime deployment is implied by either merge. + +## Fresh session workspaces + +Install the PowerShell shortcut once from a reviewed checkout: + +```powershell +./scripts/install_workspace.ps1 -Repository C:/Users/Jk101/Projects/shell-forensics +``` + +The installer copies the helper into `~/.local/share/shell-forensics` and puts +`sf.ps1` in `~/.local/bin` (which must be on PATH). It requires Git and Python; +`sf codex` also requires the Codex CLI. Re-run the installer to update the helper. + +| Command | Effect | +| --- | --- | +| `sf agents` | Select development for future workspaces (the initial default). | +| `sf main` | Select stable for future workspaces. | +| `sf status` | Show the saved selection. | +| `sf new --name my-task` | Fetch the selected branch and print a fresh owned workspace path. | +| `sf codex` | Create a fresh workspace and launch Codex CLI with `-C` pointing there. | + +Run these from any directory. For the Codex desktop app, open the path printed by +`sf new`. Existing sessions opened at home, Projects, or the old repository folder +are not automatically moved. The root checkout is an anchor, not a rolling live +installation. Never flip its branch underneath another session. + +Each session gets its own writable branch under `.worktrees/sessions`, starting +at an exact fetched commit. Integration goes through the normal agents PR/CI path; +new sessions receive later integrated changes, while active sessions keep their +files. The selector neither merges nor verifies CI itself. Selection and creation +receipts persist under the common Git directory's `workspace-selector`; `sf new +--json` returns the receipt. A fetch failure stops creation rather than using stale +source. Dependencies and runtime state still need the normal per-session setup; +this does not isolate the shared model service or implement a host adapter. + +No workspaces are automatically deleted or refreshed. Once a session is finished, +inspect its changes before removing its worktree with Git. Workspace creation can +consume disk space; keep unfinished work and evidence when cleaning up. diff --git a/scripts/install_workspace.ps1 b/scripts/install_workspace.ps1 new file mode 100644 index 0000000..d5af057 --- /dev/null +++ b/scripts/install_workspace.ps1 @@ -0,0 +1,29 @@ +param( + [Parameter(Mandatory=$true)][string]$Repository, + [string]$Destination = "$env:USERPROFILE/.local/bin" +) +$ErrorActionPreference = 'Stop' +$repositoryPath = (Resolve-Path -LiteralPath $Repository).Path +$helperDirectory = "$env:USERPROFILE/.local/share/shell-forensics" +New-Item -ItemType Directory -Force -Path $helperDirectory, $Destination | Out-Null +$helper = Join-Path $helperDirectory 'workspace.py' +Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'workspace.py') -Destination $helper +$wrapper = @' +param([Parameter(ValueFromRemainingArguments=$true)][string[]]$CommandArgs) +$ErrorActionPreference = 'Stop' +$pythonScript = '__HELPER__' +$repository = '__REPOSITORY__' +if ($CommandArgs.Count -gt 0 -and $CommandArgs[0] -eq 'codex') { + $result = & python $pythonScript --repo $repository new --json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $workspace = $result | ConvertFrom-Json + $launchArgs = @('-C', $workspace.path) + @($CommandArgs | Select-Object -Skip 1) + & codex @launchArgs + exit $LASTEXITCODE +} +& python $pythonScript --repo $repository @CommandArgs +exit $LASTEXITCODE +'@ +$wrapper = $wrapper.Replace('__HELPER__', $helper.Replace("'", "''")).Replace('__REPOSITORY__', $repositoryPath.Replace("'", "''")) +Set-Content -LiteralPath (Join-Path $Destination 'sf.ps1') -Value $wrapper -Encoding utf8 +Write-Output "Installed sf in $Destination. This directory must be on PATH." diff --git a/scripts/test_workspace.py b/scripts/test_workspace.py new file mode 100644 index 0000000..361189e --- /dev/null +++ b/scripts/test_workspace.py @@ -0,0 +1,38 @@ +"""Consequential invariant: channel switching preserves active workspaces.""" +from pathlib import Path +import tempfile +import unittest +from workspace import create, git, selected, state_directory, write_json + +class WorkspaceIsolation(unittest.TestCase): + def test_switch_preserves_dirty_workspace_and_fetches_new_tip(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + origin, repo = root / 'origin', root / 'checkout' + origin.mkdir() + git(origin, 'init', '-b', 'main') + git(origin, 'config', 'user.name', 'Fixture') + git(origin, 'config', 'user.email', 'fixture@example.invalid') + (origin / 'source').write_text('stable') + git(origin, 'add', 'source') + git(origin, 'commit', '-m', 'stable') + stable = git(origin, 'rev-parse', 'HEAD') + git(origin, 'checkout', '-b', 'agents') + git(root, 'clone', str(origin), str(repo)) + first = create(repo, 'first') + dirty = Path(first['path']) / 'source' + dirty.write_text('active edits') + write_json(state_directory(repo) / 'selection.json', {'channel': 'main'}) + self.assertEqual(create(repo, 'stable')['revision'], stable) + (origin / 'source').write_text('new development') + git(origin, 'commit', '-am', 'new development') + write_json(state_directory(repo) / 'selection.json', {'channel': 'agents'}) + fresh = create(repo, 'fresh') + self.assertEqual(fresh['revision'], git(origin, 'rev-parse', 'HEAD')) + self.assertEqual(git(first['path'], 'rev-parse', 'HEAD'), first['revision']) + self.assertEqual(dirty.read_text(), 'active edits') + self.assertEqual(selected(repo), 'agents') + self.assertEqual(git(repo, 'rev-parse', 'HEAD'), stable) + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/workspace.py b/scripts/workspace.py new file mode 100644 index 0000000..d35c21b --- /dev/null +++ b/scripts/workspace.py @@ -0,0 +1,83 @@ +"""Select a source channel and create independent, fresh session worktrees.""" +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import re +import subprocess +import uuid + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, + text=True, encoding="utf-8", timeout=90, + ).stdout.strip() + + +def state_directory(repo): + return Path(git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir")) / "workspace-selector" + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix("." + uuid.uuid4().hex + ".tmp") + temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def selected(repo): + path = state_directory(repo) / "selection.json" + channel = json.loads(path.read_text(encoding="utf-8"))["channel"] if path.exists() else "agents" + if channel not in ("agents", "main"): + raise ValueError("Invalid saved channel") + return channel + + +def create(repo, name="session"): + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}", name): + raise ValueError("Name must be 1-48 letters, digits, underscores or hyphens") + channel = selected(repo) + # Private fetch refs prevent races with other sessions fetching origin. + fetch_ref = "refs/workspace-selector/" + uuid.uuid4().hex + try: + git(repo, "fetch", "--no-write-fetch-head", "origin", f"refs/heads/{channel}:{fetch_ref}") + revision = git(repo, "rev-parse", fetch_ref) + finally: + git(repo, "update-ref", "-d", fetch_ref) + state = state_directory(repo) + root = Path(git(repo, "worktree", "list", "--porcelain").splitlines()[0].removeprefix("worktree ")) + identifier = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:8] + path = root / ".worktrees" / "sessions" / (channel + "-" + name + "-" + identifier) + branch = "session/" + name + "-" + identifier + git(repo, "worktree", "add", "-b", branch, str(path), revision) + receipt = dict(channel=channel, revision=revision, branch=branch, path=str(path), created_at=identifier) + write_json(state / "sessions" / (identifier + ".json"), receipt) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parent.parent) + commands = parser.add_subparsers(dest="command", required=True) + for command in ("agents", "main", "status"): + commands.add_parser(command) + new = commands.add_parser("new") + new.add_argument("--name", default="session") + new.add_argument("--json", action="store_true") + args = parser.parse_args() + try: + if args.command in ("agents", "main"): + write_json(state_directory(args.repo) / "selection.json", {"channel": args.command}) + print(f"Selected {args.command} for new workspaces only.") + elif args.command == "status": + print(selected(args.repo)) + else: + receipt = create(args.repo, args.name) + print(json.dumps(receipt) if args.json else receipt["path"]) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + parser.exit(1, f"Workspace creation/selection failed: {error}\n") + + +if __name__ == "__main__": + main()