This commit is contained in:
commit
54bfc0f34f
357 files changed
+41075
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user