initial commit
Foundation checks / foundation (push) Successful in 1m12s

This commit is contained in:
wt committed 2026-10-14 17:47:38 +07:00
commit 54bfc0f34f
357 files changed
+41075

No files matched your search

+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""Sequential fresh-process agent coordinator. Requires a configured tool-capable CLI."""
from __future__ import annotations
import argparse
import contextlib
import fnmatch
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
import uuid
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tasks import Tracker, atomic_write, now, read_json
EXCLUDED_DIRS = {'.git', 'node_modules', '.venv', '__pycache__', '.state.lock', '.runner.lock', 'dist', 'coverage'}
GENERATED = {'planning/tasks.json', 'planning/TASK_MATRIX.md', 'planning/task_matrix.csv',
'planning/ROADMAP.md', 'planning/TRACEABILITY.md'}
def snapshot(root):
result = {}
for path in root.rglob('*'):
relative = path.relative_to(root)
if any(x in EXCLUDED_DIRS for x in relative.parts):
continue
name = relative.as_posix()
if name.startswith(('state/', 'planning/tasks/', 'planning/stages/')) or name in GENERATED:
continue
if path.is_symlink():
result[name] = 'symlink:' + os.readlink(path)
elif path.is_file():
result[name] = hashlib.sha256(path.read_bytes()).hexdigest()
return result
def scope_violations(before, after, scope):
changed = {p for p in set(before) | set(after) if before.get(p) != after.get(p)}
return sorted(p for p in changed if not any(fnmatch.fnmatchcase(p, pattern) for pattern in scope))
class Runner:
def __init__(self, root, config):
self.root = Path(root).resolve()
self.tracker = Tracker(self.root)
self.config = config
self.lock_path = self.root / '.runner.lock'
self.max_sessions = config.get('max_sessions', 1000)
self.timeout = config.get('timeout_seconds', 1800)
self.max_prompt = config.get('max_prompt_chars', 80000)
if not isinstance(self.max_sessions, int) or not 1 <= self.max_sessions <= 10000:
raise ValueError('max_sessions must be 1..10000')
if not isinstance(self.timeout, (int, float)) or not 0 < self.timeout <= 86400:
raise ValueError('timeout_seconds must be >0 and <=86400')
if not isinstance(self.max_prompt, int) or not 48000 <= self.max_prompt <= 90000:
raise ValueError('max_prompt_chars must be 48000..90000')
@contextlib.contextmanager
def lock(self):
try:
self.lock_path.mkdir()
except FileExistsError:
raise ValueError('Another runner may be active; inspect .runner.lock/owner.json, do not start a second coordinator')
try:
atomic_write(self.lock_path / 'owner.json', json.dumps({'pid': os.getpid(), 'at': now()}))
yield
finally:
(self.lock_path / 'owner.json').unlink(missing_ok=True)
self.lock_path.rmdir()
def choose(self, stage=None):
ready = self.tracker.ready()
if stage:
index = self.tracker.index()
ready = [r for r in ready if index[r['id']]['stage'] == stage]
return ready[0] if ready else None
def command(self, item, agent, prompt_path):
commands = self.config.get('commands_by_role', {})
argv = commands.get(item['role'], self.config.get('argv'))
if not isinstance(argv, list) or not argv or not all(isinstance(x, str) and x for x in argv):
raise ValueError('Configure argv as a nonempty JSON array (no shell string)')
values = {'root': str(self.root), 'task': item['id'], 'role': item['role'],
'agent': agent, 'action': item['action'], 'prompt_file': str(prompt_path)}
expanded = []
for argument in argv:
for key, value in values.items():
argument = argument.replace('{' + key + '}', value)
if 'REPLACE_' in argument:
raise ValueError('Set your tool-capable CLI command in runner.local.json first')
expanded.append(argument)
return expanded
def prompt(self, item, agent):
tracker = self.tracker
task = tracker.task(item['id'])
assigned = f'''# Assignment from coordinator
Agent id: {agent}
Role: {item['role']}
Task: {item['id']}
Action: {item['action']}
This is a fresh session. Do not resume a CLI conversation or choose another task.
The coordinator has claimed executor work; reviewers only review this task.
Read master_prompt and instructions below in order. Your scope is only this card.
At a blocker: write one issue JSON, call tasks.py escalate, then EXIT immediately.
Do not solve the new remediation, do not unblock yourself, do not launch another agent.
After submit/approve/reject EXIT. The coordinator will launch the next fresh process.
'''
paths = ['AGENTS.md', 'README.md', 'master_prompt.md', 'roles/common.md', f'roles/{item["role"]}.md',
'docs/WEB_PROFILE.md', 'docs/ARCHITECTURE.md', 'docs/CONTRACTS.md', 'docs/WORKFLOW.md', 'docs/AUTOMATION.md']
# Only bounded, concrete referenced upstream artifacts, never the entire repository.
for raw in task['inputs']:
candidate = raw.split(' (')[0]
if candidate not in paths and (self.root / candidate).is_file():
paths.append(candidate)
row = tracker.state()['tasks'][item['id']]
if row.get('handoff'):
paths.append(row['handoff'])
if item['action'] == 'review' and row.get('evidence'):
paths.append(row['evidence'])
chunks = [assigned]
for rel in dict.fromkeys(paths):
path = (self.root / rel).resolve()
if not path.is_relative_to(self.root):
raise ValueError('Prompt input escapes project root')
if not path.is_file():
raise ValueError('Required prompt input missing: ' + rel)
chunks.append('\n# File: ' + rel + '\n\n' + path.read_text(encoding='utf-8'))
chunks.append(tracker.packet(item['id'], agent))
result = '\n'.join(chunks)
if len(result) > self.max_prompt:
raise ValueError('Prompt exceeds configured character budget; split context/task, do not truncate requirements')
return result
def quarantine(self, task_id, previous, agent):
with self.tracker.lock():
state = self.tracker.state()
current = state['tasks'][task_id]
# Preserve the real transition history; withdraw an untrusted approval/submission.
state['tasks'][task_id] = dict(previous)
state['revision'] += 1
state['events'].append({'revision': state['revision'], 'at': now(), 'action': 'runner-quarantine',
'task': task_id, 'agent': agent, 'from': current['status'], 'to': previous['status']})
state['retired_agents'] = [x for x in state.get('retired_agents', []) if x != agent]
self.tracker.write_state(state)
def execution_issue(self, item, agent, reason, external=False, previous=None):
task = self.tracker.task(item['id'])
state = self.tracker.state()
row = state['tasks'][item['id']]
if row['status'] == 'blocked' and agent in state.get('retired_agents', []):
return {'already_escalated': True}
if row['status'] not in {'in_progress', 'review'} or (item['action'] == 'claim' and row.get('owner') != agent):
if previous is None:
raise ValueError('Cannot safely escalate unexpected state; inspect runner record')
self.quarantine(item['id'], previous, agent)
issue = {
'title': 'Устранить прерывание: ' + task['title'], 'reason': reason,
'role': task['role'], 'reviewer_role': task['reviewer_role'], 'scope': task['scope'][:6],
'outcome': 'Устранить одну указанную причину прерывания и доказать готовность исходной задачи к новому запуску; не выполнять соседние задачи.',
'steps': ['Прочитать blocker report и handoff.', 'Воспроизвести конкретную причину.', 'Исправить только локальную причину в scope или уточнить внешний input.', 'Выполнить focused проверку и submit.'],
'acceptance': ['Причина воспроизводимо устранена.', 'Исходная задача не завершена обходом требований.'],
'checks': ['Повторить сценарий прерывания.', 'Проверить отсутствие изменений вне scope.'],
'requirements': [], 'requires_user_input': external,
}
return self.tracker.escalate(item['id'], agent, item['role'], issue)
def launch(self, item):
agent = 'auto-' + item['role'] + '-' + uuid.uuid4().hex[:12]
session = self.root / 'state/runs' / agent
prompt_path = session / 'prompt.md'
# Validate command before claiming; missing local configuration is not a product task blocker.
argv = self.command(item, agent, prompt_path)
if item['action'] == 'claim':
self.tracker.mutate('claim', item['id'], agent, role=item['role'])
previous = dict(self.tracker.state()['tasks'][item['id']])
try:
prompt = self.prompt(item, agent)
except (ValueError, OSError) as exc:
result = self.execution_issue(item, agent, 'Context preparation failed: ' + str(exc), previous=previous)
self.tracker.render()
return {'agent': agent, 'task': item['id'], 'action': item['action'], 'result': result}
atomic_write(prompt_path, prompt)
record = {'agent': agent, 'task': item['id'], 'role': item['role'], 'action': item['action'], 'started_at': now(), 'status': 'running'}
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
before = snapshot(self.root)
env = os.environ.copy()
env.update({'LUMA_TASK_ID': item['id'], 'LUMA_ROLE': item['role'], 'LUMA_AGENT_ID': agent,
'LUMA_ACTION': item['action'], 'LUMA_PROJECT_ROOT': str(self.root), 'LUMA_PROMPT_FILE': str(prompt_path)})
failure = None
stopped_after_transition = False
returncode = None
with (session / 'output.log').open('wb') as output:
try:
process = subprocess.Popen(argv, cwd=self.root, env=env, stdin=subprocess.PIPE,
stdout=output, stderr=subprocess.STDOUT, start_new_session=(os.name == 'posix'))
deadline = time.monotonic() + self.timeout
first_communicate = True
stopped_after_transition = False
try:
while True:
try:
process.communicate(prompt.encode('utf-8') if first_communicate else None,
timeout=min(.2, max(.01, deadline - time.monotonic())))
break
except subprocess.TimeoutExpired:
first_communicate = False
try:
live = self.tracker.state()
current = live['tasks'][item['id']]
blocker_exit = current['status'] == 'blocked' and agent in live.get('retired_agents', [])
executor_exit = item['action'] == 'claim' and current['status'] == 'review' and current.get('owner') == agent
reviewer_exit = item['action'] == 'review' and current['status'] in {'done', 'todo'} and current.get('reviewer') == agent
except ValueError:
blocker_exit = executor_exit = reviewer_exit = False
if blocker_exit or executor_exit or reviewer_exit:
stop_process_tree(process)
process.communicate()
stopped_after_transition = True
break
if time.monotonic() >= deadline:
stop_process_tree(process)
process.communicate()
failure = 'Agent timed out; process terminated before escalation'
break
except BaseException:
stop_process_tree(process)
process.communicate()
raise
finally:
# Even an exited harness must not leave source-writing descendants alive.
stop_process_tree(process)
returncode = process.returncode
except OSError as exc:
failure = 'Configured agent executable failed to start: ' + type(exc).__name__
violations = scope_violations(before, snapshot(self.root), task_scope(self.tracker, item['id']))
row = self.tracker.state()['tasks'][item['id']]
if violations:
self.quarantine(item['id'], previous, agent)
result = self.execution_issue(item, agent, 'Scope violation: ' + ', '.join(violations[:10]) + '; inspect run record before permitting further edits.', external=True, previous=previous)
elif row['status'] == 'blocked' and agent in self.tracker.state().get('retired_agents', []):
result = {'blocked': True, 'resolution_tasks': row.get('blocked_by', [])}
elif failure or (returncode != 0 and not stopped_after_transition):
result = self.execution_issue(item, agent, failure or 'Agent exited with code ' + str(returncode), external=returncode is None, previous=previous)
elif item['action'] == 'claim' and row['status'] != 'review':
result = self.execution_issue(item, agent, 'Agent exited without submit or escalate', previous=previous)
elif item['action'] == 'review' and row['status'] not in {'done', 'todo'}:
result = self.execution_issue(item, agent, 'Reviewer exited without approve/reject/escalate', previous=previous)
else:
result = {'status': row['status']}
record.update(status='finished', finished_at=now(), returncode=returncode, stopped_after_transition=stopped_after_transition, scope_violations=violations, result=result)
atomic_write(session / 'run.json', json.dumps(record, ensure_ascii=False, indent=2))
self.tracker.render()
self.tracker.validate()
return record
def run(self, stage=None, dry_run=False):
if stage and stage not in {s['id'] for s in self.tracker.definitions()['stages']}:
raise ValueError('Unknown stage: ' + stage)
if dry_run:
item = self.choose(stage)
if not item:
return self.summary('idle', [])
prompt = self.prompt(item, 'auto-preview')
try:
self.command(item, 'auto-preview', self.root / 'state/runs/preview/prompt.md')
configuration_required = False
except ValueError:
configuration_required = True
return {'dry_run': True, 'next': item, 'prompt_characters': len(prompt),
'configuration_required': configuration_required, 'state_unchanged': True}
records = []
with self.lock():
self.tracker.validate()
for _ in range(self.max_sessions):
item = self.choose(stage)
if not item:
return self.summary('idle', records)
records.append(self.launch(item))
if any(r.get('external_wait') for r in self.tracker.state()['tasks'].values()):
return self.summary('external', records)
return self.summary('session_limit', records)
def summary(self, stop, records):
state = self.tracker.state()
done = all(r['status'] == 'done' for r in state['tasks'].values())
external = {k: r.get('reason') for k, r in state['tasks'].items() if r.get('external_wait')}
return {'stop': 'complete' if done else 'needs_input' if external and stop in {'idle', 'external'} else stop,
'launched': len(records), 'revision': state['revision'], 'external_blockers': external,
'unfinished': sum(r['status'] != 'done' for r in state['tasks'].values()),
'last_session': records[-1].get('agent') if records else None}
def stop_process_tree(process):
if os.name == 'posix':
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
elif process.poll() is None:
process.terminate()
if process.poll() is None:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
if os.name == 'posix':
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
else:
process.kill()
process.wait()
if os.name == 'posix':
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
def task_scope(tracker, task_id):
return tracker.task(task_id)['scope']
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config', default='runner.local.json')
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--stage')
parser.add_argument('--max-sessions', type=int)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
path = root / args.config
try:
if path.is_file():
config = read_json(path)
elif args.dry_run:
config = {}
else:
raise ValueError('Create runner.local.json from runner.example.json and set a tool-capable CLI argv once')
if args.max_sessions is not None:
config['max_sessions'] = args.max_sessions
result = Runner(root, config).run(stage=args.stage, dry_run=args.dry_run)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 3 if result.get('stop') == 'needs_input' else 0
except (ValueError, OSError, KeyError) as exc:
print(json.dumps({'stop': 'configuration_or_workflow_error', 'reason': str(exc)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
"""Blocker -> bounded remediation task -> fresh execution. Uses Tracker's lock/journal."""
from __future__ import annotations
import copy
import hashlib
import json
import re
from pathlib import Path
def check_dag(data):
index = {t['id']: t for t in data['tasks']}
visited, active = set(), set()
def visit(key):
if key in active:
raise ValueError('Remediation would create a dependency cycle: ' + key)
if key in visited:
return
if key not in index:
raise ValueError('Unknown remediation dependency: ' + key)
active.add(key)
for dep in index[key]['depends_on']:
visit(dep)
active.remove(key)
visited.add(key)
for key in index:
visit(key)
def escalate(tracker, task_id, agent, role, issue, handoff=None):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}', agent or ''):
raise ValueError('A safe agent id is required')
if not isinstance(issue, dict):
raise ValueError('Issue must be a JSON object')
for field in ['title', 'reason', 'outcome', 'role']:
if not isinstance(issue.get(field), str) or not issue[field].strip():
raise ValueError('Issue requires nonempty ' + field)
for field, maximum in [('scope', 6), ('steps', 8), ('acceptance', 6), ('checks', 6)]:
value = issue.get(field)
if not isinstance(value, list) or not 1 <= len(value) <= maximum or not all(isinstance(x, str) and x.strip() for x in value):
raise ValueError('Issue requires a bounded nonempty list: ' + field)
if 'requires_user_input' in issue and not isinstance(issue['requires_user_input'], bool):
raise ValueError('requires_user_input must be a JSON boolean')
for section_key, maximum in [('spec_sections', 27), ('ui_sections', 26)]:
sections = issue.get(section_key, [])
if not isinstance(sections, list) or len(sections) > 6 or any(type(x) is not int or not 1 <= x <= maximum for x in sections):
raise ValueError('Invalid/broad remediation context sections')
if len(json.dumps(issue, ensure_ascii=False)) > 16000:
raise ValueError('Issue too large; describe one atomic cause')
with tracker.lock():
data, state = tracker.definitions(), tracker.state()
if agent in state.get('retired_agents', []):
raise ValueError('Agent has ended its session after a blocker; use a fresh agent')
index = {t['id']: t for t in data['tasks']}
if task_id not in index:
raise ValueError('Unknown original task')
parent, row = index[task_id], state['tasks'][task_id]
allowed_owner = row['status'] == 'in_progress' and row.get('owner') == agent and role == parent['role']
allowed_reviewer = row['status'] == 'review' and agent != row.get('owner') and role == parent['reviewer_role']
if not (allowed_owner or allowed_reviewer):
raise ValueError('Only current executor or assigned-role reviewer may escalate its current task')
roles = tracker.roles()
if issue['role'] not in roles or issue.get('reviewer_role', parent['reviewer_role']) not in roles:
raise ValueError('Unknown remediation operator/reviewer')
for scope in issue['scope']:
if Path(scope).is_absolute() or '..' in Path(scope).parts or scope in {'*', '**', '**/*', '.'}:
raise ValueError('Remediation must have bounded project-local scope')
if scope.startswith(('specs/', 'planning/', 'state/')):
raise ValueError('Remediation source scope cannot rewrite specs/workflow state')
extra = issue.get('depends_on', [])
if not isinstance(extra, list) or any(d not in index for d in extra):
raise ValueError('Remediation dependencies must reference existing task IDs')
if task_id in extra:
raise ValueError('Remediation cannot depend on its blocked parent')
depth = parent.get('blocker_depth', 0) + 1
root_id = parent.get('root_task', task_id)
previous = sum(t.get('kind') == 'blocker' and t.get('root_task') == root_id for t in data['tasks'])
external = bool(issue.get('requires_user_input')) or depth > 3 or previous >= 6
trigger = issue['reason']
if depth > 3 or previous >= 6:
trigger += '\nAutomatic blocker limit reached; explicit external resolution required.'
ordinal = max(int(t['id'].split('-')[1]) for t in data['tasks'] if t['stage'] == parent['stage']) + 1
if ordinal > 999:
raise ValueError('Stage task ID space exhausted')
child_id = f'{parent["stage"]}-{ordinal:03}'
requirements = issue.get('requirements', parent['requirements'])
allowed_reqs = {r['id'] for r in tracker.requirements() if r['applicability'] != 'out_of_scope'}
if not isinstance(requirements, list) or any(r not in allowed_reqs for r in requirements):
raise ValueError('Unknown/out-of-scope remediation requirement')
report = f'state/blockers/{child_id}.md'
child = {
'id': child_id, 'stage': parent['stage'], 'kind': 'blocker', 'root_task': root_id,
'blocker_depth': depth, 'unblocks': [task_id], 'role': issue['role'],
'reviewer_role': issue.get('reviewer_role', parent['reviewer_role']),
'title': issue['title'], 'outcome': issue['outcome'],
'depends_on': list(dict.fromkeys(parent['depends_on'] + extra)),
'requirements': list(dict.fromkeys(requirements)), 'scope': issue['scope'],
'inputs': list(dict.fromkeys(parent['inputs'] + [report])),
'spec_sections': issue.get('spec_sections', []), 'ui_sections': issue.get('ui_sections', []),
'steps': issue['steps'], 'acceptance': issue['acceptance'], 'checks': issue['checks'],
'context_budget_chars': 48000,
}
# Keep the original stage gate last; dynamic task IDs are never reused.
gate_id = next(s.get('exit_gate') for s in data['stages'] if s['id'] == parent['stage'])
insert_at = next((i for i, t in enumerate(data['tasks']) if t['id'] == gate_id), len(data['tasks']))
data['tasks'].insert(insert_at, child)
parent['depends_on'].append(child_id)
check_dag(data)
old_status, old_owner = row['status'], row.get('owner')
row.update(status='blocked', reason=trigger, blocked_by=[child_id], resume_status=old_status,
blocker_report=report, updated_at=tracker.timestamp())
if handoff:
row['handoff'] = tracker.safe_file(handoff, 'state/handoffs')
else:
row['handoff'] = f'state/handoffs/{task_id}.md'
state['tasks'][child_id] = {'status': 'blocked' if external else 'todo', 'owner': None,
'external_wait': external, 'reason': trigger if external else None}
state.setdefault('retired_agents', []).append(agent)
state['retired_agents'] = list(dict.fromkeys(state['retired_agents']))
state['revision'] += 1
state['events'].append({'revision': state['revision'], 'at': tracker.timestamp(), 'action': 'escalate',
'task': task_id, 'resolution_task': child_id, 'agent': agent, 'role': role,
'from': old_status, 'to': 'blocked', 'reason': trigger})
issue_text = '\n'.join([
'# Blocker ' + child_id, '', '- Original task: ' + task_id, '- Root task: ' + root_id,
'- Reporter: ' + agent, '- Role: ' + role, '- Original owner: ' + str(old_owner),
'- Waits for user/external condition: ' + str(external), '', '## Cause', '', trigger,
'', '## One remediation outcome', '', issue['outcome'], '',
'## Evidence / reproduction', '', issue.get('reproduction', 'See original handoff and task evidence.'), '',
'The reporting agent must stop now. A new agent implements only ' + child_id + '.', '',
])
artifacts = {report: issue_text, f"planning/tasks/{child_id}.md": tracker.card(child)}
if not handoff:
existing = tracker.root / row['handoff']
previous_text = existing.read_text(encoding='utf-8') if existing.is_file() else ''
artifacts[row['handoff']] = (previous_text + '\n\n# Blocked handoff\n\n' +
f'Original: {task_id}; reporter: {agent}; resolution: {child_id}.\n\n' + trigger +
'\n\nNext: complete and review the remediation; only a fresh session may continue.\n')
tracker.commit_plan_state(data, state, artifacts)
return {'task': task_id, 'status': 'blocked', 'resolution_task': child_id,
'resolution_status': state['tasks'][child_id]['status'], 'agent_must_exit': True,
'requires_user_input': external, 'revision': state['revision']}
def unblock_resolved(tracker, state, completed_id):
"""Called under lock as part of approval; no human/old-agent unblock needed."""
for parent_id, row in state['tasks'].items():
blockers = row.get('blocked_by', [])
if row['status'] != 'blocked' or completed_id not in blockers:
continue
if not all(state['tasks'][b]['status'] == 'done' for b in blockers):
continue
row['previous_owner'] = row.get('owner')
if row.get('resume_status') == 'review':
row.update(status='review', reason=None)
else:
row.update(status='todo', owner=None, reason=None)
row.update(blocked_by=[], resumed_after_blocker=True, updated_at=tracker.timestamp())
state['events'].append({'revision': state['revision'] + 1, 'at': tracker.timestamp(),
'action': 'auto-unblock', 'task': parent_id, 'resolved_by': completed_id,
'to': row['status'], 'fresh_agent_required': True})
def release_external(tracker, task_id, agent, role, reason):
if role != 'orchestrator' or not reason or not reason.strip():
raise ValueError('External release requires orchestrator and concrete resolution reason')
with tracker.lock():
state = tracker.state()
if agent in state.get('retired_agents', []):
raise ValueError('Use a fresh coordinator session')
row = state['tasks'].get(task_id)
if not row or row['status'] != 'blocked' or not row.get('external_wait'):
raise ValueError('Not an externally waiting remediation')
row.update(status='todo', owner=None, external_wait=False, reason=reason)
state['revision'] += 1
state['events'].append({'revision': state['revision'], 'at': tracker.timestamp(),
'action': 'release-external', 'task': task_id, 'agent': agent, 'reason': reason})
tracker.write_state(state)
return {'id': task_id, 'status': 'todo', 'verification_required': True}
+553
View File
@@ -0,0 +1,553 @@
#!/usr/bin/env python3
"""Dependency-free, repository-local task workflow. No model/network execution."""
from __future__ import annotations
import argparse
import contextlib
import csv
import hashlib
import io
import json
import os
import re
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import task_extensions
STATUSES = {'todo', 'in_progress', 'review', 'done', 'blocked'}
ACTIVE = {'in_progress', 'review', 'blocked'}
def now():
return datetime.now(timezone.utc).isoformat(timespec='seconds')
def read_json(path):
return json.loads(path.read_text(encoding='utf-8'))
def atomic_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
class Tracker:
def __init__(self, root):
self.root = Path(root).resolve()
self.defs_path = self.root / 'planning/tasks.json'
self.state_path = self.root / 'state/tasks.json'
self.lock_path = self.root / '.state.lock'
self.journal_path = self.root / "state/plan-transaction.json"
self._locked = False
def definitions(self):
self.recover_if_needed()
return read_json(self.defs_path)
def state(self):
self.recover_if_needed()
return read_json(self.state_path)
def plan_state(self):
if self._locked:
return read_json(self.defs_path), read_json(self.state_path)
with self.lock():
return read_json(self.defs_path), read_json(self.state_path)
def roles(self):
return read_json(self.root / "roles/index.json")["roles"]
def requirements(self):
return read_json(self.root / "planning/requirements.json")
@staticmethod
def timestamp():
return now()
def write_state(self, state):
atomic_write(self.state_path, json.dumps(state, ensure_ascii=False, indent=2) + "\n")
def recover_if_needed(self):
if self.journal_path.exists() and not self._locked:
with self.lock():
pass
def recover_transaction(self):
if not self.journal_path.exists():
return
journal = read_json(self.journal_path)
for rel, content in journal.get("artifacts", {}).items():
path = (self.root / rel).resolve()
is_state = path.is_relative_to(self.root / "state")
is_card = path.is_relative_to(self.root / "planning/tasks") and path.suffix == ".md"
if not (is_state or is_card):
raise ValueError("Unsafe plan transaction artifact")
atomic_write(path, content)
atomic_write(self.defs_path, json.dumps(journal["definitions"], ensure_ascii=False, indent=2) + "\n")
self.write_state(journal["state"])
self.journal_path.unlink()
def commit_plan_state(self, definitions, state, artifacts=None):
if not self._locked:
raise ValueError("Plan/state transaction requires lock")
journal = {"definitions": definitions, "state": state, "artifacts": artifacts or {}}
atomic_write(self.journal_path, json.dumps(journal, ensure_ascii=False, indent=2) + "\n")
self.recover_transaction()
def escalate(self, task_id, agent, role, issue, handoff=None):
return task_extensions.escalate(self, task_id, agent, role, issue, handoff)
@contextlib.contextmanager
def lock(self):
try:
self.lock_path.mkdir()
except FileExistsError:
raise ValueError('State lock exists. Inspect .state.lock/owner.json; do not steal a live lock.')
try:
(self.lock_path / 'owner.json').write_text(json.dumps({'pid': os.getpid(), 'at': now()}), encoding='utf-8')
self._locked = True
self.recover_transaction()
yield
finally:
self._locked = False
(self.lock_path / 'owner.json').unlink(missing_ok=True)
self.lock_path.rmdir()
def index(self):
return {t['id']: t for t in self.definitions()['tasks']}
def missing(self, task, state):
return [d for d in task['depends_on'] if state['tasks'].get(d, {}).get('status') != 'done']
def task(self, task_id):
try:
return self.index()[task_id]
except KeyError:
raise ValueError('Unknown task: ' + task_id)
def safe_file(self, raw, folder, minimum=40):
if not isinstance(raw, str) or not raw:
raise ValueError("Evidence/handoff path is required")
path = Path(raw)
if not path.is_absolute():
path = self.root / path
resolved = path.resolve()
base = (self.root / folder).resolve()
if not resolved.is_relative_to(base) or resolved == base:
raise ValueError('Path must remain inside ' + folder)
relative = path.absolute().relative_to(self.root)
cursor = self.root
for part in relative.parts:
cursor = cursor / part
if cursor.is_symlink():
raise ValueError('Symlink paths are not allowed for evidence/handoff')
if not resolved.is_file() or resolved.stat().st_size < minimum:
raise ValueError('A real nonempty evidence/handoff file is required: ' + str(raw))
return resolved.relative_to(self.root).as_posix()
def ready(self, role=None):
if role is not None and role not in read_json(self.root / "roles/index.json")["roles"]:
raise ValueError("Unknown operator role: " + role)
data, state = self.plan_state()
out = []
for task in data['tasks']:
row = state['tasks'][task['id']]
if row['status'] == 'review' and (role is None or role == task['reviewer_role']):
out.append({'id': task['id'], 'action': 'review', 'role': task['reviewer_role'], 'title': task['title']})
elif row['status'] == 'todo' and not self.missing(task, state) and (role is None or task['role'] == role):
out.append({'id': task['id'], 'action': 'claim', 'role': task['role'], 'title': task['title']})
index = {t['id']: t for t in data['tasks']}
return sorted(out, key=lambda x: (0 if index[x['id']].get('kind') == 'blocker' else 1 if x['action'] == 'review' else 2 if state['tasks'][x['id']].get('resumed_after_blocker') else 3, x['id']))
def mutate(self, action, task_id, agent, role=None, reason=None, evidence=None, handoff=None, target=None):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}', agent or ''):
raise ValueError('Use a nonempty safe agent id (letters/digits/._-, max 80)')
with self.lock():
task = self.task(task_id)
state = self.state()
if agent in state.get('retired_agents', []):
raise ValueError('Agent ended after blocker; a fresh session is required')
if action in {'block', 'unblock'}:
raise ValueError('Use escalate with a remediation task; tracked blockers unblock automatically after review')
row = state['tasks'][task_id]
status = row['status']
owner = row.get('owner')
if action == 'claim':
if role != task['role']:
raise ValueError('Required operator role: ' + task['role'])
if status != 'todo' or self.missing(task, state):
raise ValueError('Task unavailable; status/dependencies: ' + status + ' ' + str(self.missing(task, state)))
if any(v.get('owner') == agent and v['status'] in ACTIVE for v in state['tasks'].values()):
raise ValueError('Agent already owns unfinished work. Use resume.')
row.update(status='in_progress', owner=agent, started_at=now(), reason=None)
elif action in {'submit', 'block', 'unblock', 'reassign'}:
if owner != agent:
raise ValueError('Only current owner can ' + action)
if action == 'submit':
if status != 'in_progress' or self.missing(task, state):
raise ValueError('Only dependency-ready in_progress work can be submitted')
row.update(status='review', evidence=self.safe_file(evidence, 'state/evidence'), handoff=self.safe_file(handoff, 'state/handoffs'), reason=None)
elif action == 'block':
if status not in {'in_progress', 'review'} or not reason or not reason.strip():
raise ValueError('Blocking needs active task and specific reason')
row.update(status='blocked', reason=reason)
elif action == 'unblock':
if status != 'blocked' or not reason or not reason.strip():
raise ValueError('Unblock needs blocked status and resolution reason')
row.update(status='in_progress', reason=reason)
else:
if row.get('blocked_by'):
raise ValueError('Tracked blocker waits for reviewed remediation, not reassignment')
if status not in {'in_progress', 'blocked'} or not reason or not reason.strip():
raise ValueError('Reassign requires in_progress/blocked and explicit reason')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,79}', target or '') or target == agent:
raise ValueError('New owner must be a distinct safe agent id')
if target in state.get('retired_agents', []):
raise ValueError('Retired reporter cannot be reassigned work')
if any(v.get('owner') == target and v['status'] in ACTIVE for v in state['tasks'].values()):
raise ValueError('New owner already has unfinished work')
row.update(owner=target, reason=reason)
elif action in {'approve', 'reject'}:
if status != 'review' or agent == owner or role != task['reviewer_role']:
raise ValueError('Review requires review state, distinct reviewer and role ' + task['reviewer_role'])
if action == 'approve':
if self.missing(task, state):
raise ValueError('Dependencies no longer complete')
row.update(status='done', reviewer=agent, review_evidence=self.safe_file(evidence, 'state/evidence'), completed_at=now(), reason=None)
task_extensions.unblock_resolved(self, state, task_id)
else:
if not reason or not reason.strip():
raise ValueError('Rejection needs actionable reason')
row.update(status='todo', owner=None, previous_owner=owner, reviewer=agent, reason=reason)
else:
raise ValueError('Unsupported transition')
state['revision'] += 1
row['updated_at'] = now()
event = {'revision': state['revision'], 'at': now(), 'action': action, 'task': task_id, 'agent': agent, 'role': role, 'from': status, 'to': row['status'], 'reason': reason}
if action in {'submit', 'approve'}:
event['evidence'] = row.get('review_evidence') if action == 'approve' else row.get('evidence')
if action == 'reassign':
event['new_owner'] = target
state['events'].append(event)
atomic_write(self.state_path, json.dumps(state, ensure_ascii=False, indent=2) + '\n')
return {'id': task_id, 'revision': state['revision'], **row}
def sync(self):
with self.lock():
definitions = self.index()
state = self.state()
removed = set(state['tasks']) - set(definitions)
if removed:
raise ValueError('Cannot silently remove task history: ' + str(sorted(removed)))
added = []
for task_id in definitions:
if task_id not in state['tasks']:
state['tasks'][task_id] = {'status': 'todo', 'owner': None}
added.append(task_id)
if added:
state['revision'] += 1
state['events'].append({'revision': state['revision'], 'at': now(), 'action': 'sync', 'added': added})
atomic_write(self.state_path, json.dumps(state, ensure_ascii=False, indent=2) + '\n')
return added
def validate(self, generated=True):
if self._locked:
return self._validate(generated)
with self.lock():
return self._validate(generated)
def _validate(self, generated=True):
data = self.definitions()
tasks = data['tasks']
ids = [t['id'] for t in tasks]
if len(ids) != len(set(ids)):
raise ValueError('Duplicate task IDs')
index = self.index()
roles = read_json(self.root / 'roles/index.json')['roles']
stages = {s['id'] for s in data['stages']}
if len(stages) != len(data["stages"]):
raise ValueError("Duplicate stage IDs")
requirements = read_json(self.root / 'planning/requirements.json')
req_index = {r['id']: r for r in requirements}
seen, walking = set(), set()
def visit(task_id):
if task_id in walking:
raise ValueError('Dependency cycle: ' + task_id)
if task_id in seen:
return
walking.add(task_id)
for dep in index[task_id]['depends_on']:
if dep not in index:
raise ValueError('Missing dependency: ' + dep)
visit(dep)
walking.remove(task_id)
seen.add(task_id)
coverage = set()
for task in tasks:
if not re.fullmatch(r'S\d{2}-\d{3}', task['id']) or task['stage'] not in stages or not task["id"].startswith(task["stage"] + "-"):
raise ValueError('Invalid task/stage ID')
if task['role'] not in roles or task['reviewer_role'] not in roles:
raise ValueError('Unknown role: ' + task['id'])
for key in ['title', 'outcome', 'scope', 'inputs', 'steps', 'acceptance', 'checks']:
if not task.get(key):
raise ValueError('Missing field ' + key + ' for ' + task['id'])
for path in task['scope']:
if Path(path).is_absolute() or '..' in Path(path).parts:
raise ValueError('Unsafe task scope')
for req in task['requirements']:
if req not in req_index or req_index[req]['applicability'] == 'out_of_scope':
raise ValueError('Unknown/out-of-scope requirement: ' + req)
coverage.add(req)
visit(task['id'])
if generated and not (self.root / f'planning/tasks/{task["id"]}.md').is_file():
raise ValueError('Missing task card ' + task['id'])
uncovered = {r['id'] for r in requirements if r['applicability'] != 'out_of_scope'} - coverage
if uncovered:
raise ValueError('Unallocated Web requirements: ' + str(sorted(uncovered)))
for role in roles:
if not (self.root / f'roles/{role}.md').is_file():
raise ValueError('Role instructions missing: ' + role)
manifest = read_json(self.root / 'specs/manifest.json')
for name, expected in manifest['sha256'].items():
if hashlib.sha256((self.root / 'specs' / name).read_bytes()).hexdigest() != expected:
raise ValueError('Original spec snapshot changed: ' + name)
source_ids = set()
for name in manifest['sha256']:
source_ids.update(re.findall(r'^\*\*([A-Z][A-Z0-9-]+)\.\*\*', (self.root / 'specs' / name).read_text(encoding='utf-8'), re.M))
if source_ids != set(req_index) or len(requirements) != len(req_index):
raise ValueError('Requirement index differs from original specs')
state = self.state()
if set(state['tasks']) != set(index):
raise ValueError('Definition/state IDs differ; run sync for additions')
owners = set()
for task_id, row in state['tasks'].items():
if row.get('status') not in STATUSES:
raise ValueError('Invalid status ' + task_id)
if row['status'] != 'todo' and not row.get('owner') and not (row['status'] == 'blocked' and row.get('external_wait')):
raise ValueError('Active/completed work has no owner')
if row['status'] in ACTIVE and row.get('owner'):
if row['owner'] in owners:
raise ValueError('Owner has multiple unfinished tasks')
owners.add(row['owner'])
if any(b not in index or b not in index[task_id]['depends_on'] for b in row.get('blocked_by', [])):
raise ValueError('Invalid tracked blocker dependency')
if row['status'] == 'in_progress' and row.get('owner') in state.get('retired_agents', []):
raise ValueError('Retired reporter cannot resume execution')
if row['status'] == 'blocked' and not row.get('reason'):
raise ValueError('Blocked task lacks reason')
if row['status'] in {'review', 'done'}:
self.safe_file(row.get('evidence'), 'state/evidence')
self.safe_file(row.get('handoff'), 'state/handoffs')
if row['status'] == 'done':
if not row.get('reviewer') or row['reviewer'] == row['owner'] or self.missing(index[task_id], state):
raise ValueError('Invalid done/review/dependency state')
self.safe_file(row.get('review_evidence'), 'state/evidence')
return {'tasks': len(tasks), 'stages': len(stages), 'requirements': len(requirements), 'allocated_web_requirements': len(coverage), 'revision': state['revision']}
@staticmethod
def section(text, number):
match = re.search(r'^## ' + re.escape(str(number)) + r'\. .*$', text, re.M)
if not match:
raise ValueError('Missing specification section ' + str(number))
end = re.search(r'^## ', text[match.end():], re.M)
return text[match.start():match.end() + end.start() if end else len(text)].strip()
def card(self, task):
out = [f'# {task["id"]}: {task["title"]}', '', '> Generated from planning/tasks.json; status is in state/tasks.json.', '', f'- Этап: {task["stage"]}', f'- Оператор: {task["role"]}', f'- Reviewer: {task["reviewer_role"]}', f'- Зависимости: {", ".join(task["depends_on"]) or "нет"}', f'- Требования: {", ".join(task["requirements"]) or "инженерная подготовка"}', '', '## Один результат', '', task['outcome'], '', '## Разрешённые пути', '']
if task.get('kind') == 'blocker':
out[2:2] = [f"- Remediation для: {', '.join(task.get('unblocks', []))}; root: {task.get('root_task')}; depth: {task.get('blocker_depth')}", '']
out += ['- `' + p + '`' for p in task['scope']]
out += ['', 'Не изменять другие modules, shared contracts или spec snapshots без изменения плана. Stem без расширения разрешает принятые stack-specific extensions и focused test рядом.', '', '## Прочитать', ''] + ['- ' + p for p in task['inputs']]
for heading, key in [('Шаги', 'steps'), ('Приёмка', 'acceptance'), ('Focused checks', 'checks')]:
out += ['', '## ' + heading, ''] + [f'{i}. {x}' for i, x in enumerate(task[key], 1)]
out += ['', '## Блокер и завершение запуска', '', 'Для всех ролей scope — только эта задача. При блокере создать issue JSON по state/ISSUE_TEMPLATE.json, вызвать escalate с текущим ID/role/agent, проверить status=blocked и resolution_task, затем немедленно завершить запуск. Не решать blocker и не выбирать следующую задачу самому. Новый агент назначается runner. Правки metadata через CLI — единственное исключение scope.', '', '## Передача', '', 'Сохранить evidence/handoff; submit → review и завершить запуск. Reviewer также выполняет только эту задачу, включая обязательное escalate при блокере.', '']
return '\n'.join(out)
def packet(self, task_id, agent):
task = self.task(task_id)
reqs = {r['id']: r for r in read_json(self.root / 'planning/requirements.json')}
out = ['# Context packet ' + task_id, '', 'Agent: ' + agent, '', self.card(task), '## Точные требования', '']
out += [f'**{r}.** {reqs[r]["text"]}' for r in task['requirements']]
for number in task.get('spec_sections', []):
out += ['', self.section((self.root / 'specs/tz_clear.md').read_text(encoding='utf-8'), number)]
for number in task.get('ui_sections', []):
out += ['', self.section((self.root / 'specs/uikit_clear.md').read_text(encoding='utf-8'), number)]
state = self.state()
current = state['tasks'][task_id]
out += ['', '## Текущее состояние и предыдущая передача', '', json.dumps(current, ensure_ascii=False, indent=2)]
if current.get('handoff'):
out += ['Previous handoff: ' + current['handoff'] + ' — прочитать перед продолжением.']
out += ['', '## Зависимости: результат и evidence', '']
for dep in task['depends_on']:
row = state['tasks'][dep]
out += [f'- {dep}: {self.task(dep)["outcome"]}; status={row["status"]}; evidence={row.get("evidence", "нет")}; review={row.get("review_evidence", "нет")}; handoff={row.get("handoff", "нет")}.']
result = '\n'.join(out) + '\n'
if len(result) > 48000:
raise ValueError('Context packet >48k chars; split task/read scope, do not truncate requirements')
return result
def render(self):
data, state = self.plan_state()
out = ['# Матрица задач', '', f'Generated; state revision **{state["revision"]}**. Canonical status: state/tasks.json.', '', '| ID | Этап | Оператор | Reviewer | Статус | Owner | Зависимости | Задача |', '| --- | --- | --- | --- | --- | --- | --- | --- |']
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(['id', 'stage', 'operator', 'reviewer', 'status', 'owner', 'depends_on', 'title'])
for task in data['tasks']:
row = state['tasks'][task['id']]
values = [task['id'], task['stage'], task['role'], task['reviewer_role'], row['status'], row.get('owner') or '', ', '.join(task['depends_on']), task['title']]
escaped = [str(v).replace('|', '\\|').replace('\n', ' ') for v in values]
escaped[0] = f'[{task["id"]}](tasks/{task["id"]}.md)'
out.append('| ' + ' | '.join(escaped) + ' |')
writer.writerow(values)
atomic_write(self.root / f'planning/tasks/{task["id"]}.md', self.card(task))
atomic_write(self.root / 'planning/TASK_MATRIX.md', '\n'.join(out) + '\n')
atomic_write(self.root / 'planning/task_matrix.csv', buffer.getvalue())
roadmap = ['# Этапы разработки', '', 'Последовательность gates предохраняет небольшого агента от догадок о ещё не принятых interfaces. Внутри этапа независимые атомарные задачи доступны параллельно при разных scopes/owners.', '']
for stage in data['stages']:
members = [t for t in data['tasks'] if t['stage'] == stage['id']]
exit_id = stage.get('exit_gate') or members[-1]['id']
lines = [f'# {stage["id"]}: {stage["title"]}', '', stage['goal'], '', f'Входной gate: {stage.get("entry_gate") or "нет"}. Выходной gate: {exit_id}.', '', '## Задачи', '']
lines += [f'- [{t["id"]}](../tasks/{t["id"]}.md): {t["title"]} — {t["role"]}; {state["tasks"][t["id"]]["status"]}.' for t in members]
lines += ['', '## Граница этапа', '', 'Выходной gate проверяет evidence/готовые проверки, не реализует функции за остальные задачи. Если отдельная карточка требует слишком большой контекст — split с сохранением requirements coverage.', '']
atomic_write(self.root / f'planning/stages/{stage["id"]}.md', '\n'.join(lines))
roadmap += [f'## [{stage["id"]}: {stage["title"]}](stages/{stage["id"]}.md)', '', stage['goal'], '', f'Задач: {len(members)}; entry: {stage.get("entry_gate") or "нет"}; exit: {exit_id}.', '']
atomic_write(self.root / 'planning/ROADMAP.md', '\n'.join(roadmap))
refs = {}
for task in data['tasks']:
for req in task['requirements']:
refs.setdefault(req, []).append(task['id'])
trace = ['# Traceability Web', '', 'Allocated означает наличие плана/приёмки, а не реализацию требования. Out-of-scope исключены только native-specific clauses; оригинальные specs сохранены.', '', '| Requirement | Применимость | Причина | Задачи |', '| --- | --- | --- | --- |']
for req in read_json(self.root / 'planning/requirements.json'):
trace.append('| ' + ' | '.join([req['id'], req['applicability'], req['reason'], ', '.join(refs.get(req['id'], []))]) + ' |')
atomic_write(self.root / 'planning/TRACEABILITY.md', '\n'.join(trace) + '\n')
return {'rendered': len(data['tasks']), 'revision': state['revision']}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['status', 'list', 'next', 'show', 'resume', 'claim', 'submit', 'approve', 'reject', 'block', 'unblock', 'reassign', 'packet', 'validate', 'render', 'sync', 'unlock', 'escalate', 'release-external'])
parser.add_argument('task', nargs='?')
parser.add_argument('--agent')
parser.add_argument('--role')
parser.add_argument('--reason')
parser.add_argument('--evidence')
parser.add_argument('--handoff')
parser.add_argument('--issue')
parser.add_argument('--to', dest='target')
parser.add_argument('--output')
parser.add_argument('--stage')
parser.add_argument('--status', choices=sorted(STATUSES))
parser.add_argument('--query')
parser.add_argument('--limit', type=int, default=20)
parser.add_argument('--json', action='store_true')
args = parser.parse_args(argv)
tracker = Tracker(Path(__file__).resolve().parents[1])
try:
if args.command == 'escalate':
issue_path = tracker.safe_file(args.issue, 'state/issues')
result = tracker.escalate(args.task, args.agent, args.role, read_json(tracker.root / issue_path), args.handoff)
tracker.render()
elif args.command == 'release-external':
result = task_extensions.release_external(tracker, args.task, args.agent, args.role, args.reason)
tracker.render()
elif args.command in {'claim', 'submit', 'approve', 'reject', 'block', 'unblock', 'reassign'}:
result = tracker.mutate(args.command, args.task, args.agent, args.role, args.reason, args.evidence, args.handoff, args.target)
elif args.command == 'validate':
result = tracker.validate()
elif args.command == 'render':
result = tracker.render()
elif args.command == 'sync':
result = tracker.sync()
elif args.command == 'status':
state = tracker.state()
result = {'revision': state['revision'], 'counts': {s: sum(v['status'] == s for v in state['tasks'].values()) for s in sorted(STATUSES)}, 'ready': len(tracker.ready()), 'blocked': {k: v['reason'] for k, v in state['tasks'].items() if v['status'] == 'blocked'}}
elif args.command == 'list':
if not 1 <= args.limit <= 100:
raise ValueError('limit must be 1..100')
if args.role is not None and args.role not in read_json(tracker.root / 'roles/index.json')['roles']:
raise ValueError('Unknown operator role: ' + args.role)
state = tracker.state()
result = []
for task in tracker.definitions()['tasks']:
row = state['tasks'][task['id']]
if args.role and task['role'] != args.role:
continue
if args.stage and task['stage'] != args.stage:
continue
if args.status and row['status'] != args.status:
continue
if args.query and args.query.casefold() not in (task['id'] + ' ' + task['title'] + ' ' + task['outcome']).casefold():
continue
result.append({'id': task['id'], 'stage': task['stage'], 'role': task['role'], 'title': task['title'], 'status': row['status'], 'owner': row.get('owner'), 'unmet_dependencies': tracker.missing(task, state)})
result = result[:args.limit]
elif args.command == 'next':
if not 1 <= args.limit <= 100:
raise ValueError('limit must be 1..100')
result = tracker.ready(args.role)[:args.limit]
elif args.command == 'resume':
if not args.agent:
raise ValueError('resume requires --agent')
result = [{'id': k, **v} for k, v in tracker.state()['tasks'].items() if v.get('owner') == args.agent and v['status'] in ACTIVE]
elif args.command == 'show':
task = tracker.task(args.task)
if args.json:
result = {'definition': task, 'state': tracker.state()['tasks'][args.task], 'unmet_dependencies': tracker.missing(task, tracker.state())}
else:
print(tracker.card(task))
print('Current state:', json.dumps(tracker.state()['tasks'][args.task], ensure_ascii=False))
return 0
elif args.command == 'packet':
if not args.agent:
raise ValueError('packet requires --agent')
packet = tracker.packet(args.task, args.agent)
if args.output:
output = (tracker.root / args.output).resolve()
if not output.is_relative_to(tracker.root / 'state') or not output.name.startswith('context-') or output.suffix != '.md':
raise ValueError('Output must be state/context-*.md')
atomic_write(output, packet)
result = {'output': str(output), 'characters': len(packet)}
else:
print(packet)
return 0
else:
if not args.reason or not args.reason.strip():
raise ValueError('unlock requires --reason after checking crashed owner process')
owner_path = tracker.lock_path / 'owner.json'
owner = read_json(owner_path) if owner_path.exists() else {}
pid = owner.get('pid')
if pid:
if os.name != "posix":
raise ValueError("Automatic stale-process check is POSIX-only; inspect OS process state and recover the stale lock manually")
try:
os.kill(pid, 0)
except ProcessLookupError:
pass
except PermissionError:
raise ValueError('Cannot prove lock owner process is dead')
else:
raise ValueError('Lock owner process is alive; refuse unlock')
owner_path.unlink(missing_ok=True)
tracker.lock_path.rmdir()
result = {'unlocked': owner, 'reason': args.reason}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (ValueError, KeyError, TypeError, OSError) as exc:
print('Error: ' + str(exc), file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())