From f7ab0160e87918403b00e58a73b9aae7de6ec285 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Wed, 16 Sep 2026 22:45:15 +0200 Subject: [PATCH] feat(session): maintain canonical task state --- scripts/session-state.ps1 | 35 +++- scripts/session-state.sh | 8 +- scripts/test-session-state.py | 170 ++++++++++++++++++ shared/hooks/scripts/Record-Compact.ps1 | 3 +- .../scripts/Show-SessionStatePointer.ps1 | 4 +- shared/hooks/scripts/record-compact.sh | 1 + shared/hooks/scripts/session-state.py | 128 +++++++++++++ .../scripts/show-session-state-pointer.sh | 4 +- 8 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 scripts/test-session-state.py create mode 100644 shared/hooks/scripts/session-state.py diff --git a/scripts/session-state.ps1 b/scripts/session-state.ps1 index 234ad3f..077358b 100644 --- a/scripts/session-state.ps1 +++ b/scripts/session-state.ps1 @@ -1,8 +1,31 @@ [CmdletBinding()] -param([string]$Goal,[string[]]$ChangedFiles=@(),[string[]]$Verified=@(),[string[]]$Pending=@(),[string[]]$ImportantFindings=@(),[switch]$Pointer) +param( + [string]$Workspace = (Get-Location).Path, + [AllowEmptyString()][string]$Goal, + [AllowEmptyCollection()][string[]]$Decisions, + [AllowEmptyCollection()][string[]]$ChangedFiles, + [AllowEmptyCollection()][string[]]$Verified, + [AllowEmptyCollection()][string[]]$Pending, + [AllowEmptyCollection()][string[]]$Risks, + [AllowEmptyCollection()][string[]]$ImportantFindings, + [switch]$Pointer, + [switch]$Reset +) $ErrorActionPreference = 'Stop' -$root = Split-Path $PSScriptRoot -Parent; $dir = Join-Path $root '.ai-session'; $path = Join-Path $dir 'state.json' -if ($Pointer) { if (Test-Path $path) { Write-Output 'Task state is available in .ai-session/state.json; read it only if needed.' }; exit 0 } -function Limit([string[]]$items) { @($items | Where-Object { $_ } | Select-Object -First 12 | ForEach-Object { if ($_.Length -gt 180) { $_.Substring(0,180) } else { $_ } }) } -New-Item $dir -ItemType Directory -Force | Out-Null -[ordered]@{ goal=if($Goal){$Goal.Substring(0,[Math]::Min(240,$Goal.Length))}else{''}; changedFiles=Limit $ChangedFiles; verified=Limit $Verified; pending=Limit $Pending; importantFindings=Limit $ImportantFindings; updatedAt=(Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Depth 4 | Set-Content $path -Encoding UTF8 +$helper = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared/hooks/scripts/session-state.py' +$values = @{ workspace = $Workspace } +$fields = @{ Goal = 'goal'; Decisions = 'decisions'; ChangedFiles = 'changed'; Verified = 'verified'; Pending = 'open'; Risks = 'risks' } +foreach ($parameter in $fields.Keys) { + if ($PSBoundParameters.ContainsKey($parameter)) { $values[$fields[$parameter]] = $PSBoundParameters[$parameter] } +} +if ($PSBoundParameters.ContainsKey('ImportantFindings') -and -not $PSBoundParameters.ContainsKey('Decisions')) { + $values.decisions = $ImportantFindings +} +$OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$arguments = @($helper, '--update-json') +if ($Pointer) { $arguments += '--pointer' } +if ($Reset) { $arguments += '--reset' } +$payload = $values | ConvertTo-Json -Depth 4 -Compress +$payload = [regex]::Replace($payload, '[^\x00-\x7F]', { param($match) '\u{0:x4}' -f [int][char]$match.Value }) +$payload | & python @arguments +exit $LASTEXITCODE diff --git a/scripts/session-state.sh b/scripts/session-state.sh index 4bad3bb..d13e9e0 100644 --- a/scripts/session-state.sh +++ b/scripts/session-state.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd); dir="$root/.ai-session"; path="$dir/state.json" -if [ "${1:-}" = --pointer ]; then [ -f "$path" ] && printf 'Task state is available in .ai-session/state.json; read it only if needed.\n'; exit 0; fi -goal=${AI_TASK_GOAL:-}; goal=${goal:0:240}; goal_json=$(printf '%s' "$goal" | sed 's/[\\]/\\\\/g; s/"/\\"/g'); mkdir -p "$dir" -printf '{"goal":"%s","changedFiles":[],"verified":[],"pending":[],"importantFindings":[],"updatedAt":"%s"}\n' "$goal_json" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$path" +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +arguments=() +if [ "${AI_TASK_GOAL+x}" = x ]; then arguments+=(--goal "$AI_TASK_GOAL"); fi +python3 "$root/shared/hooks/scripts/session-state.py" "${arguments[@]}" "$@" diff --git a/scripts/test-session-state.py b/scripts/test-session-state.py new file mode 100644 index 0000000..0f01b50 --- /dev/null +++ b/scripts/test-session-state.py @@ -0,0 +1,170 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +SHELLS = [ + ('powershell', shutil.which('powershell') or shutil.which('pwsh')), + ('bash', str(Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe') + if os.name == 'nt' else shutil.which('bash')), +] + + +class SessionStateHookTests(unittest.TestCase): + def test_writer_migrates_legacy_state_and_requires_reset_for_new_goal(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + state_file = workspace / '.ai-session/state.json' + state_file.parent.mkdir() + state_file.write_text(json.dumps({'goal': 'Original', 'changedFiles': ['a'], + 'pending': ['b'], 'importantFindings': ['c']})) + command = [sys.executable, str(ROOT / 'shared/hooks/scripts/session-state.py'), + '--workspace', str(workspace)] + result = subprocess.run(command + ['--verified', 'passed'], capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + state = json.loads(state_file.read_text()) + self.assertEqual(state['changed'], ['a']) + self.assertEqual(state['open'], ['b']) + self.assertEqual(state['decisions'], ['c']) + self.assertFalse({'changedFiles', 'pending', 'importantFindings'} & state.keys()) + before = state_file.read_bytes() + result = subprocess.run(command + ['--goal', 'New'], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(state_file.read_bytes(), before) + result = subprocess.run(command + ['--reset', '--goal', 'New'], capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + state = json.loads(state_file.read_text()) + self.assertEqual(state['goal'], 'New') + self.assertEqual(state['verified'], []) + self.assertEqual(state['changed'], []) + state_file.write_text('{"goal":"New","verified":"invalid"}') + before = state_file.read_bytes() + result = subprocess.run(command + ['--open', 'next'], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(state_file.read_bytes(), before) + + def test_writers_preserve_partial_updates_and_escape_values(self): + for shell, executable in SHELLS: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + repository = base / 'repository' + shutil.copytree(ROOT / 'shared/hooks/scripts', repository / 'shared/hooks/scripts') + (repository / 'scripts').mkdir() + for filename in ('session-state.ps1', 'session-state.sh'): + shutil.copy(ROOT / 'scripts' / filename, repository / 'scripts' / filename) + workspace = base / 'workspace ü' + workspace.mkdir() + environment = dict(os.environ, TASK_WORKSPACE=str(workspace), + TASK_GOAL='Repair "quotes"\n' + 'x' * 300) + if shell == 'powershell': + prefix = [executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command'] + commands = [ + '& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Goal $env:TASK_GOAL -Decisions @("keep", "line two") -Pending @("check")', + '& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Verified @("passed")', + '& ./scripts/session-state.ps1 -Workspace $env:TASK_WORKSPACE -Pointer', + ] + else: + prefix = [executable, '-c'] + commands = [ + 'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --goal "$TASK_GOAL" --decisions keep "line two" --open check', + 'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --verified passed', + 'bash ./scripts/session-state.sh --workspace "$TASK_WORKSPACE" --pointer', + ] + for command in commands: + result = subprocess.run(prefix + [command], cwd=repository, capture_output=True, + encoding='utf-8', timeout=20, env=environment) + self.assertEqual(result.returncode, 0, result.stderr) + state_file = workspace / '.ai-session/state.json' + self.assertIn(str(state_file), result.stdout) + state = json.loads(state_file.read_text(encoding='utf-8-sig')) + self.assertEqual(state['goal'], environment['TASK_GOAL']) + self.assertEqual(state['decisions'], ['keep', 'line two']) + self.assertEqual(state['open'], ['check']) + self.assertEqual(state['verified'], ['passed']) + self.assertEqual(state['workspace'], str(workspace)) + self.assertFalse((repository / '.ai-session').exists()) + self.assertEqual([p.name for p in state_file.parent.iterdir()], ['state.json']) + + def run_hook(self, shell, executable, hook, config, workspace, payload): + names = {'pointer': ('Show-SessionStatePointer.ps1', 'show-session-state-pointer.sh'), + 'compact': ('Record-Compact.ps1', 'record-compact.sh')} + script = config / 'hooks/scripts' / names[hook][shell == 'bash'] + command = ([executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(script)] + if shell == 'powershell' else [executable, str(script)]) + result = subprocess.run(command, cwd=workspace, input=payload, capture_output=True, + encoding='utf-8', timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, '') + return result.stdout + + def test_workspace_selection_and_read_only_state(self): + for shell, executable in SHELLS: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + config = base / 'installed config' + shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts') + workspace = base / 'working project' + selected = base / 'selected project ü' + empty = base / 'empty project' + for folder in (config, workspace, selected, empty): + folder.mkdir(exist_ok=True) + for folder in (config, workspace, selected): + (folder / '.ai-session').mkdir() + (folder / '.ai-session/state.json').write_text('private state content') + for hook in ('pointer', 'compact'): + for payload, expected in ( + ('', workspace), + ('{}', workspace), + (json.dumps({'cwd': str(selected)}, ensure_ascii=False), selected), + (json.dumps({'cwd': str(empty)}), None), + ('{"cwd":"relative/path"}', None), + ('{"cwd":null}', None), + ('{"cwd":12}', None), + ('{"cwd":""}', None), + ('{bad json', None), + ('[]', None), + ): + with self.subTest(hook=hook, payload=payload): + output = self.run_hook(shell, executable, hook, config, workspace, payload) + if expected is None: + self.assertEqual(output, '') + else: + self.assertIn(str(expected / '.ai-session/state.json'), output) + self.assertNotIn(str(config), output) + self.assertNotIn('private state content', output) + for folder in (config, workspace, selected): + self.assertEqual((folder / '.ai-session/state.json').read_text(), 'private state content') + self.assertFalse((empty / '.ai-session').exists()) + events = (config / '.ai-session/telemetry.jsonl').read_text(encoding='utf-8-sig').splitlines() + self.assertEqual(len(events), 10) + self.assertTrue(all(json.loads(line)['event'] == 'compact' for line in events)) + + def test_missing_workspace_state_succeeds_without_config_fallback(self): + for shell, executable in SHELLS: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + config = base / 'config' + shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts') + (config / '.ai-session').mkdir() + (config / '.ai-session/state.json').write_text('{}') + workspace = base / 'workspace' + workspace.mkdir() + for hook in ('pointer', 'compact'): + self.assertEqual(self.run_hook(shell, executable, hook, config, workspace, ''), '') + self.assertEqual(list(workspace.iterdir()), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/shared/hooks/scripts/Record-Compact.ps1 b/shared/hooks/scripts/Record-Compact.ps1 index 6ce6c84..fa2cc96 100644 --- a/shared/hooks/scripts/Record-Compact.ps1 +++ b/shared/hooks/scripts/Record-Compact.ps1 @@ -4,4 +4,5 @@ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $stateDir = Join-Path $root '.ai-session' New-Item $stateDir -ItemType Directory -Force | Out-Null ('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content (Join-Path $stateDir 'telemetry.jsonl') -exit 0 +& python (Join-Path $PSScriptRoot 'session-state.py') --hook +exit $LASTEXITCODE diff --git a/shared/hooks/scripts/Show-SessionStatePointer.ps1 b/shared/hooks/scripts/Show-SessionStatePointer.ps1 index c688e31..67e01c3 100644 --- a/shared/hooks/scripts/Show-SessionStatePointer.ps1 +++ b/shared/hooks/scripts/Show-SessionStatePointer.ps1 @@ -1,4 +1,4 @@ [CmdletBinding()] param() -$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -if (Test-Path (Join-Path $root '.ai-session/state.json')) { Write-Output 'Task state is available in .ai-session/state.json; read it only if needed.' } +& python (Join-Path $PSScriptRoot 'session-state.py') --hook +exit $LASTEXITCODE diff --git a/shared/hooks/scripts/record-compact.sh b/shared/hooks/scripts/record-compact.sh index 605faf1..3f81144 100644 --- a/shared/hooks/scripts/record-compact.sh +++ b/shared/hooks/scripts/record-compact.sh @@ -3,3 +3,4 @@ set -euo pipefail root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) mkdir -p "$root/.ai-session" printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl" +python3 "$root/hooks/scripts/session-state.py" --hook diff --git a/shared/hooks/scripts/session-state.py b/shared/hooks/scripts/session-state.py new file mode 100644 index 0000000..d23f9e9 --- /dev/null +++ b/shared/hooks/scripts/session-state.py @@ -0,0 +1,128 @@ +import argparse +import json +import os +from pathlib import Path +import sys +import tempfile +from datetime import datetime, timezone + + +def state_path(workspace): + if not isinstance(workspace, str) or not workspace or any(ord(c) < 32 for c in workspace): + raise ValueError('Invalid workspace') + root = Path(workspace) + if not root.is_absolute() or not root.is_dir(): + raise ValueError('Invalid workspace') + root = root.resolve() + directory = root / '.ai-session' + path = directory / 'state.json' + if directory.is_symlink() or path.is_symlink() or directory.resolve().parent != root: + raise ValueError('Invalid state path') + return path + + +def show_pointer(path): + if path.is_file(): + print('Task state is available at ' + str(path) + '; validate its workspace and goal before reuse.') + + +def hook_pointer(): + try: + raw = '' if sys.stdin.isatty() else sys.stdin.buffer.read(1048577).decode('utf-8-sig') + if len(raw) > 1048576: + return + event = json.loads(raw) if raw.strip() else {} + if not isinstance(event, dict): + return + show_pointer(state_path(event.get('cwd', os.getcwd()))) + except (ValueError, OSError, RecursionError): + return + + +def update_state(args): + path = state_path(args.workspace) + if args.pointer: + show_pointer(path) + return + state = json.loads(path.read_text(encoding='utf-8-sig')) if path.exists() else {} + if not isinstance(state, dict): + raise ValueError('Invalid task state') + workspace = str(path.parent.parent) + if state.get('workspace', workspace) != workspace: + raise ValueError('Task state belongs to another workspace') + for old, new in (('changedFiles', 'changed'), ('pending', 'open'), ('importantFindings', 'decisions')): + if old in state: + state.setdefault(new, state.pop(old)) + for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks'): + if field not in state: + continue + value = state[field] + if field == 'goal': + valid = isinstance(value, str) + else: + valid = isinstance(value, list) and all(isinstance(item, str) for item in value) + if not valid: + raise ValueError('Invalid task state field') + if args.reset: + state = {} + elif args.goal is not None and state.get('goal') and state['goal'] != args.goal: + raise ValueError('A different goal requires reset') + state['workspace'] = workspace + for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks'): + value = getattr(args, field) + if value is not None: + state[field] = value + else: + state.setdefault(field, '' if field == 'goal' else []) + state['updatedAt'] = datetime.now(timezone.utc).isoformat() + path.parent.mkdir(exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, + prefix='.state-', suffix='.tmp', delete=False) as output: + temporary = output.name + json.dump(state, output, ensure_ascii=False, indent=2) + output.write('\n') + os.replace(temporary, path) + temporary = None + finally: + if temporary is not None: + os.unlink(temporary) + + +def main(): + sys.stdout.reconfigure(encoding='utf-8') + parser = argparse.ArgumentParser() + parser.add_argument('--hook', action='store_true') + parser.add_argument('--update-json', action='store_true') + parser.add_argument('--workspace', default=os.getcwd()) + parser.add_argument('--pointer', action='store_true') + parser.add_argument('--reset', action='store_true') + parser.add_argument('--goal') + for field in ('decisions', 'changed', 'verified', 'open', 'risks'): + parser.add_argument('--' + field, nargs='*') + args = parser.parse_args() + if args.hook: + hook_pointer() + else: + try: + if args.update_json: + values = json.loads(sys.stdin.buffer.read().decode('utf-8-sig')) + if not isinstance(values, dict): + raise ValueError('Invalid task state update') + for field, value in values.items(): + if field not in ('workspace', 'goal', 'decisions', 'changed', 'verified', 'open', 'risks'): + raise ValueError('Invalid task state field') + if field in ('workspace', 'goal'): + if not isinstance(value, str): + raise ValueError('Invalid task state value') + elif not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise ValueError('Invalid task state list') + setattr(args, field, value) + update_state(args) + except (ValueError, OSError): + parser.exit(1, 'Cannot read or update task state for this workspace.\n') + + +if __name__ == '__main__': + main() diff --git a/shared/hooks/scripts/show-session-state-pointer.sh b/shared/hooks/scripts/show-session-state-pointer.sh index c8f38e1..b2cf60e 100644 --- a/shared/hooks/scripts/show-session-state-pointer.sh +++ b/shared/hooks/scripts/show-session-state-pointer.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail -root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) -[ -f "$root/.ai-session/state.json" ] && printf 'Task state is available in .ai-session/state.json; read it only if needed.\n' +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +python3 "$script_dir/session-state.py" --hook