#!/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())