Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/agent-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
29 changes: 29 additions & 0 deletions scripts/install_workspace.ps1
Original file line number Diff line number Diff line change
@@ -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."
38 changes: 38 additions & 0 deletions scripts/test_workspace.py
Original file line number Diff line number Diff line change
@@ -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()
83 changes: 83 additions & 0 deletions scripts/workspace.py
Original file line number Diff line number Diff line change
@@ -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()
Loading