feat(session): maintain canonical task state

This commit is contained in:
fraujulian committed 2026-09-16 22:45:15 +02:00
1 parent 6d4ab9ddac
commit f7ab0160e8
8 files changed
+338 -15

No files matched your search

+29 -6
View File
@@ -1,8 +1,31 @@
[CmdletBinding()] [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' $ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent; $dir = Join-Path $root '.ai-session'; $path = Join-Path $dir 'state.json' $helper = Join-Path (Split-Path $PSScriptRoot -Parent) 'shared/hooks/scripts/session-state.py'
if ($Pointer) { if (Test-Path $path) { Write-Output 'Task state is available in .ai-session/state.json; read it only if needed.' }; exit 0 } $values = @{ workspace = $Workspace }
function Limit([string[]]$items) { @($items | Where-Object { $_ } | Select-Object -First 12 | ForEach-Object { if ($_.Length -gt 180) { $_.Substring(0,180) } else { $_ } }) } $fields = @{ Goal = 'goal'; Decisions = 'decisions'; ChangedFiles = 'changed'; Verified = 'verified'; Pending = 'open'; Risks = 'risks' }
New-Item $dir -ItemType Directory -Force | Out-Null foreach ($parameter in $fields.Keys) {
[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 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
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd); dir="$root/.ai-session"; path="$dir/state.json" root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
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 arguments=()
goal=${AI_TASK_GOAL:-}; goal=${goal:0:240}; goal_json=$(printf '%s' "$goal" | sed 's/[\\]/\\\\/g; s/"/\\"/g'); mkdir -p "$dir" if [ "${AI_TASK_GOAL+x}" = x ]; then arguments+=(--goal "$AI_TASK_GOAL"); fi
printf '{"goal":"%s","changedFiles":[],"verified":[],"pending":[],"importantFindings":[],"updatedAt":"%s"}\n' "$goal_json" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$path" python3 "$root/shared/hooks/scripts/session-state.py" "${arguments[@]}" "$@"
+170
View File
@@ -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()
+2 -1
View File
@@ -4,4 +4,5 @@ $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$stateDir = Join-Path $root '.ai-session' $stateDir = Join-Path $root '.ai-session'
New-Item $stateDir -ItemType Directory -Force | Out-Null New-Item $stateDir -ItemType Directory -Force | Out-Null
('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content (Join-Path $stateDir 'telemetry.jsonl') ('{"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
@@ -1,4 +1,4 @@
[CmdletBinding()] [CmdletBinding()]
param() param()
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent & python (Join-Path $PSScriptRoot 'session-state.py') --hook
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.' } exit $LASTEXITCODE
+1
View File
@@ -3,3 +3,4 @@ set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
mkdir -p "$root/.ai-session" mkdir -p "$root/.ai-session"
printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl" 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
+128
View File
@@ -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()
@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) script_dir=$(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' python3 "$script_dir/session-state.py" --hook