feat(agentctl): governance visibility P0 — table/brief/archive + MCP reads

Add human-readable --format table, one-page brief, claim archive via docs
apply CAS, and four MCP readonly tools. Includes P1 console design doc
and engineering-tool test evidence.
This commit is contained in:
weichen
2026-09-24 13:25:57 +00:00
parent a2afa67d47
commit 0d1840da96
20 changed files with 1407 additions and 28 deletions
+313 -25
View File
@@ -27,6 +27,7 @@ sys.dont_write_bytecode = True
os.environ.setdefault('PYTHONDONTWRITEBYTECODE', '1')
import agentctl_w4 as w4 # noqa: E402
import agentctl_catalog as catalog # noqa: E402
import agentctl_visibility as visibility # noqa: E402
VERSION = 1
POLICY_REL = 'configs/agent-governance.json'
@@ -163,6 +164,18 @@ def emit(value, stream=sys.stdout):
print(json.dumps(value, ensure_ascii=False, sort_keys=True), file=stream, flush=True)
def emit_payload(payload, fmt='json', renderer=None, stream=sys.stdout):
"""Emit JSON (default) or a human table via an optional renderer callable."""
if (fmt or 'json') == 'table' and renderer is not None:
text = renderer(payload)
if not text.endswith('\n'):
text += '\n'
stream.write(text)
stream.flush()
return
emit(payload, stream)
def digest(value):
return hashlib.sha256(value.encode() if isinstance(value, str) else value).hexdigest()
@@ -1781,7 +1794,8 @@ def queue_list(repo, args):
states).fetchall()
else:
rows = db.execute('SELECT * FROM queue_items ORDER BY created ASC').fetchall()
emit({'status': 'ok', 'items': [queue_row(r) for r in rows]})
payload = {'status': 'ok', 'items': [queue_row(r) for r in rows]}
emit_payload(payload, getattr(args, 'format', 'json'), visibility.render_queue_table)
return 0
@@ -2211,7 +2225,8 @@ def cleanup_report(repo, args=None):
item['cleanup_owner'] = (active[0]['owner'] if active
else (rows[0]['owner'] if rows else None))
record_audit(repo, 'cleanup_report', payload)
emit(payload)
emit_payload(payload, getattr(args, 'format', 'json') if args is not None else 'json',
visibility.render_cleanup_table)
return 0
@@ -2457,7 +2472,280 @@ def claim_list(repo, args):
"SELECT * FROM claims WHERE state='held' ORDER BY updated DESC").fetchall()]
except sqlite3.Error:
rows = []
emit({'status': 'ok', 'claims': rows})
payload = {'status': 'ok', 'claims': rows}
emit_payload(payload, getattr(args, 'format', 'json'), visibility.render_claim_table)
return 0
def claim_archive(repo, args):
"""Migrate done CURRENT claim rows to archive via docs apply @document CAS."""
task = task_row(repo, args.task)
if repo.current != Path(task['worktree']) or repo.current == repo.main:
deny('WORKTREE_MISMATCH', 'claim archive must run inside the task worktree')
caps = json.loads(task['capabilities']) if isinstance(task['capabilities'], str) else task['capabilities']
if 'docs:admin' not in caps:
deny('CAPABILITY_REQUIRED', 'claim archive rewrites CURRENT via @document and needs docs:admin')
current_rel = '.doc/ops/CURRENT.md'
archive_rel = '.doc/archive/ops/2026-09-21-current-history.md'
current_path = safe_path(repo.current, current_rel)
content = current_path.read_text(encoding='utf-8')
rows = visibility.parse_current_claim_rows(content)
selected = visibility.select_archive_candidates(
rows,
retain_days=getattr(args, 'retain_days', 7),
include_undated=bool(getattr(args, 'include_undated', False)),
)
eligible = [r for r in selected if r.get('eligible')]
payload = {
'status': 'dry_run',
'retain_days': getattr(args, 'retain_days', 7),
'include_undated': bool(getattr(args, 'include_undated', False)),
'done_scanned': len(selected),
'eligible_count': len(eligible),
'candidates': eligible,
'skipped': [r for r in selected if not r.get('eligible')],
'archive_file': archive_rel,
}
if not getattr(args, 'apply', False):
emit(payload)
return 0
if not eligible:
emit({**payload, 'status': 'noop', 'message': 'no eligible done rows to archive'})
return 0
remove_ids = [r['task'] for r in eligible]
new_content = visibility.build_archived_current(content, remove_ids)
archive_date = time.strftime('%Y-%m-%d', time.localtime())
section = visibility.build_archive_section(eligible, archive_date)
request = {
'request_id': f"{args.task}-claim-archive-{archive_date.replace('-', '')}",
'changes': [{
'file': current_rel,
'record_id': '@document',
'expected_sha256': digest(content),
'replacement': new_content,
}],
}
req_path = Path(tempfile.mkstemp(prefix='agentctl-claim-archive-', suffix='.json')[1])
try:
req_path.write_text(json.dumps(request, ensure_ascii=False, indent=2), encoding='utf-8')
apply_args = argparse.Namespace(task=args.task, request=str(req_path))
rc = docs_apply(repo, apply_args)
if rc not in (0, None):
return rc
finally:
with contextlib.suppress(OSError):
req_path.unlink()
archive_path = safe_path(repo.current, archive_rel)
with Lease(repo, task, [f'path:{archive_rel}'], f'{args.task}-archive-append',
digest(section)) as lease:
if lease.replay:
emit({'status': 'replayed', 'run_id': lease.request_id})
return lease.row['exit_code'] or 0
existing = archive_path.read_text(encoding='utf-8') if archive_path.is_file() else ''
atomic_write(archive_path, existing.rstrip() + '\n' + section)
lease.finish()
emit({
'status': 'archived',
'archived_count': len(eligible),
'tasks': remove_ids,
'archive_file': archive_rel,
'current_sha256': digest(new_content),
})
return 0
def collect_status_payload(repo):
identity = repo.identity()
policy = policy_status(repo.main)
try:
with repo.db(True) as db:
ensure_schema(db)
except Denied:
pass
with repo.db() as db:
tasks = [dict(r) for r in db.execute('SELECT * FROM tasks')]
runs = [dict(r) for r in db.execute(
"SELECT * FROM runs WHERE state IN ('running','queued','recovery_required','busy','suspect','quarantine')")]
try:
queue = [dict(r) for r in db.execute(
"SELECT * FROM queue_items WHERE state IN ('pending','claimed') ORDER BY priority DESC, created")]
except sqlite3.Error:
queue = []
for row in runs:
row['supervisor_alive'] = alive_status(row['pid'], row['start'], row['boot'])
return {
'status': 'ok',
'lock_root': str(repo.root),
'main': str(repo.main),
'version': VERSION,
'epoch': identity['epoch'],
'tasks': tasks,
'runs': runs,
'queue': queue,
'policy': policy,
}
def collect_cleanup_payload(repo):
import agent_worktree as aw # noqa: E402
payload = aw.build_cleanup_report(repo.main, repo.main)
with repo.db() as db:
try:
ensure_schema(db)
except Exception:
pass
for item in payload.get('worktrees', []):
path = item.get('path')
if not path:
continue
rows = [dict(r) for r in db.execute(
'SELECT id,owner,state FROM tasks WHERE worktree=?', (path,)).fetchall()]
item['tasks'] = rows
active = [r for r in rows if r.get('state') == 'active']
item['cleanup_owner'] = (active[0]['owner'] if active
else (rows[0]['owner'] if rows else None))
return payload
def collect_docs_drift_payload(repo):
"""Same logic as docs_drift but returns payload without emitting."""
matrix_path = repo.main / '.doc/status/FEATURE-MATRIX.md'
if not matrix_path.is_file():
return {'status': 'error', 'error': 'FEATURE-MATRIX.md missing'}
tip = git(repo.main, 'show', 'refs/heads/dev:.doc/status/FEATURE-MATRIX.md', check=False)
content = tip.stdout if tip.returncode == 0 else matrix_path.read_text()
current = matrix_states(content)
previous = {}
with repo.db(True) as db:
ensure_schema(db)
for row in db.execute('SELECT record_id, status FROM matrix_state_snapshot'):
previous[row['record_id']] = row['status']
regressions = []
for record_id, prev in previous.items():
now = current.get(record_id)
if prev == 'done' and now is not None and now != 'done':
regressions.append({
'record_id': record_id, 'previous': prev, 'current': now,
'severity': 'hard', 'message': 'MATRIX status regressed from done',
})
elif prev == 'done' and record_id not in current:
regressions.append({
'record_id': record_id, 'previous': prev, 'current': None,
'severity': 'hard', 'message': 'MATRIX done row disappeared',
})
now_ts = time.time()
db.execute('DELETE FROM matrix_state_snapshot')
for record_id, status in current.items():
db.execute(
'INSERT INTO matrix_state_snapshot(record_id, status, updated) VALUES(?,?,?)',
(record_id, status, now_ts))
open_rows = sorted(
[{'record_id': rid, 'status': st} for rid, st in current.items()
if st not in ('done', 'removed')],
key=lambda item: item['record_id'])
return {
'status': 'ok',
'file': '.doc/status/FEATURE-MATRIX.md',
'record_count': len(current),
'previous_count': len(previous),
'regressions': regressions,
'open_capabilities': open_rows,
'baseline': 'first_snapshot' if not previous else 'compared',
}
def collect_daemon_payload(repo):
try:
identity = repo.identity()
except Denied as exc:
return {'status': 'denied', 'code': exc.code, 'message': exc.message}
lock = manager_lock_status(repo)
heartbeat = read_daemon_heartbeat(repo)
heartbeat_age = None
if heartbeat and isinstance(heartbeat.get('updated'), (int, float)):
heartbeat_age = max(0.0, time.time() - float(heartbeat['updated']))
pending = claimed = 0
try:
with repo.db() as db:
try:
pending = db.execute(
"SELECT count(*) FROM queue_items WHERE state='pending'").fetchone()[0]
claimed = db.execute(
"SELECT count(*) FROM queue_items WHERE state='claimed'").fetchone()[0]
except sqlite3.Error:
pending = claimed = 0
except Denied:
pass
return {
'status': 'ok',
'lock_root': str(repo.root),
'epoch': identity.get('epoch'),
'manager_lock': lock,
'heartbeat': heartbeat,
'heartbeat_age_seconds': heartbeat_age,
'queue': {'pending': pending, 'claimed': claimed},
}
def collect_catalog_drift_payload(repo):
matrix_path = repo.main / '.doc/status/FEATURE-MATRIX.md'
try:
content = git(repo.main, 'show', 'refs/heads/dev:.doc/status/FEATURE-MATRIX.md')
except Exception:
content = matrix_path.read_text(encoding='utf-8') if matrix_path.is_file() else ''
states = matrix_states(content) if content else {}
with repo.db(True) as db:
return catalog.catalog_drift(db, matrix_states=states)
def cmd_brief(repo, args):
"""One-page Markdown briefing aggregating status/drift/cleanup/catalog/daemon."""
status_payload = collect_status_payload(repo)
try:
docs = collect_docs_drift_payload(repo)
except Exception as exc: # noqa: BLE001
docs = {'status': 'error', 'error': str(exc)}
try:
cleanup = collect_cleanup_payload(repo)
except Exception as exc: # noqa: BLE001
cleanup = {'status': 'error', 'error': str(exc), 'worktrees': []}
try:
cat = collect_catalog_drift_payload(repo)
except Exception as exc: # noqa: BLE001
cat = {'status': 'error', 'error': str(exc)}
try:
daemon = collect_daemon_payload(repo)
except Exception as exc: # noqa: BLE001
daemon = {'status': 'error', 'error': str(exc)}
text = visibility.build_brief(
status=status_payload,
docs_drift=docs,
cleanup=cleanup,
catalog_drift=cat,
daemon=daemon,
)
if getattr(args, 'format', 'markdown') == 'json':
emit({
'status': 'ok',
'brief': text,
'sources': {
'status': {
'active_tasks': len([
t for t in (status_payload.get('tasks') or [])
if t.get('state') not in ('done', 'cancelled')
]),
'runs': len(status_payload.get('runs') or []),
'queue': len(status_payload.get('queue') or []),
},
'docs_drift': docs,
'cleanup': {'worktrees': len((cleanup or {}).get('worktrees') or [])},
'catalog_drift': cat,
'daemon': daemon,
},
})
else:
sys.stdout.write(text)
sys.stdout.flush()
return 0
@@ -2678,7 +2966,12 @@ def daemon_status(repo, args):
def parser():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--repo',default='.')
sub=p.add_subparsers(dest='action',required=True)
for name in ('init','status','doctor'):sub.add_parser(name)
for name in ('init','status','doctor'):
sp=sub.add_parser(name)
if name in ('status','doctor'):
sp.add_argument('--format',choices=['json','table'],default='json')
brief=sub.add_parser('brief', help='One-page governance briefing (Markdown)')
brief.add_argument('--format',choices=['markdown','json'],default='markdown')
task=sub.add_parser('task').add_subparsers(dest='task_action',required=True)
reg=task.add_parser('register');reg.add_argument('--id',required=True);reg.add_argument('--owner',required=True);reg.add_argument('--scope',action='append',required=True);reg.add_argument('--capability',action='append',default=[])
reg.add_argument('--requirement',help='Catalog R-ID to bind at register')
@@ -2705,12 +2998,13 @@ def parser():
q=sub.add_parser('queue').add_subparsers(dest='queue_action',required=True)
qe=q.add_parser('enqueue');qe.add_argument('--task',required=True);qe.add_argument('--expected-dev',required=True)
qe.add_argument('--head');qe.add_argument('--priority',type=int,default=0);qe.add_argument('--request-id')
ql=q.add_parser('list');ql.add_argument('--all',action='store_true')
ql=q.add_parser('list');ql.add_argument('--all',action='store_true');ql.add_argument('--format',choices=['json','table'],default='json')
qc=q.add_parser('cancel');qc.add_argument('--task',required=True);qc.add_argument('--id',required=True);qc.add_argument('--reason',required=True)
qs=q.add_parser('schedule');qs.add_argument('--drain',action='store_true')
cu=sub.add_parser('cleanup').add_subparsers(dest='cleanup_action',required=True)
cs=cu.add_parser('status');cs.add_argument('--path');cs.add_argument('--branch')
cu.add_parser('report', help='Batch cleanup readiness for .worktrees (never deletes)')
crep=cu.add_parser('report', help='Batch cleanup readiness for .worktrees (never deletes)')
crep.add_argument('--format',choices=['json','table'],default='json')
rec=sub.add_parser('recover');rec.add_argument('--run',required=True);rec.add_argument('--reason',required=True)
op=sub.add_parser('operation');op.add_argument('run')
for action in ('freeze','unfreeze'):
@@ -2735,6 +3029,12 @@ def parser():
crl=cl.add_parser('release');crl.add_argument('--task',required=True);crl.add_argument('--resource',required=True);crl.add_argument('--reason',default='')
cst=cl.add_parser('status');cst.add_argument('--resource',required=True);cst.add_argument('--json',action='store_true')
clist=cl.add_parser('list');clist.add_argument('--all',action='store_true')
clist.add_argument('--format',choices=['json','table'],default='json')
carc=cl.add_parser('archive', help='Archive done CURRENT claim rows via docs apply')
carc.add_argument('--task',required=True)
carc.add_argument('--retain-days',type=int,default=7)
carc.add_argument('--include-undated',action='store_true')
carc.add_argument('--apply',action='store_true')
cc=cl.add_parser('cancel');cc.add_argument('--task',required=True);cc.add_argument('--id',required=True);cc.add_argument('--reason',required=True)
qtn=sub.add_parser('quarantine').add_subparsers(dest='quarantine_action',required=True)
qtn.add_parser('sweep')
@@ -2801,6 +3101,8 @@ def main():
pass
elif args.action == 'claim' and getattr(args, 'claim_action', None) in ('status', 'list'):
require_policy(repo) # status/list need schema constants but are non-mutating
elif args.action == 'claim' and getattr(args, 'claim_action', None) == 'archive' and not getattr(args, 'apply', False):
require_policy(repo) # dry-run is non-mutating
elif args.action == 'quarantine' and getattr(args, 'quarantine_action', None) == 'list':
require_policy(repo) # list needs TTL/schema constants
elif args.action == 'daemon' and getattr(args, 'daemon_action', None) == 'status':
@@ -2817,25 +3119,10 @@ def main():
require_policy(repo)
if args.action=='init':initialize(repo)
elif args.action in ('status','doctor'):
identity=repo.identity()
policy=policy_status(repo.main)
try:
with repo.db(True) as db:
ensure_schema(db)
except Denied:
pass
with repo.db() as db:
tasks=[dict(r) for r in db.execute('SELECT * FROM tasks')]
runs=[dict(r) for r in db.execute(
"SELECT * FROM runs WHERE state IN ('running','queued','recovery_required','busy','suspect','quarantine')")]
try:
queue=[dict(r) for r in db.execute(
"SELECT * FROM queue_items WHERE state IN ('pending','claimed') ORDER BY priority DESC, created")]
except sqlite3.Error:
queue=[]
for row in runs:row['supervisor_alive']=alive_status(row['pid'],row['start'],row['boot'])
emit({'status':'ok','lock_root':str(repo.root),'main':str(repo.main),'version':VERSION,'epoch':identity['epoch'],
'tasks':tasks,'runs':runs,'queue':queue,'policy':policy})
payload = collect_status_payload(repo)
emit_payload(payload, getattr(args, 'format', 'json'), visibility.render_status_table)
elif args.action=='brief':
return cmd_brief(repo, args)
elif args.action=='task':
if args.task_action=='register':
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}',args.id):deny('INVALID_TASK','Invalid task id')
@@ -2941,6 +3228,7 @@ def main():
if args.claim_action=='release':return claim_release(repo,args)
if args.claim_action=='status':return claim_status(repo,args)
if args.claim_action=='list':return claim_list(repo,args)
if args.claim_action=='archive':return claim_archive(repo,args)
if args.claim_action=='cancel':return claim_cancel(repo,args)
elif args.action=='quarantine':
if args.quarantine_action=='sweep':return quarantine_sweep(repo)
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""Pure rendering / brief / CURRENT claim-archive helpers for agentctl.
No I/O against the live repo except via callers. Safe to unit-test in isolation.
"""
from __future__ import annotations
import datetime as _dt
import re
from typing import Any, Iterable, Optional
TASK_DATE_RE = re.compile(r'(?<!\d)(20\d{2})-(\d{2})-(\d{2})(?!\d)')
CLAIM_HEADER = '| owner | task | branch | worktree | mutex | status |'
CLAIM_SECTION = '## 并行认领'
def format_table(headers: Iterable[str], rows: Iterable[Iterable[Any]]) -> str:
"""Render a plain monospace table. Empty rows → header-only block."""
cols = [str(h) for h in headers]
body = [[_cell(v) for v in row] for row in rows]
widths = [len(c) for c in cols]
for row in body:
for i, cell in enumerate(row):
if i < len(widths):
widths[i] = max(widths[i], len(cell))
else:
widths.append(len(cell))
while len(row) < len(widths):
row.append('')
def line(cells):
return ' '.join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
out = [line(cols), line(['-' * w for w in widths])]
out.extend(line(row) for row in body)
return '\n'.join(out)
def _cell(value: Any) -> str:
if value is None:
return '-'
text = str(value)
return text.replace('\n', ' ').strip() or '-'
def _duration(created: Any, now: Optional[float] = None) -> str:
try:
ts = float(created)
except (TypeError, ValueError):
return '-'
age = (now if now is not None else _dt.datetime.now().timestamp()) - ts
if age < 0:
age = 0
if age < 60:
return f'{int(age)}s'
if age < 3600:
return f'{int(age // 60)}m'
if age < 86400:
return f'{int(age // 3600)}h'
return f'{int(age // 86400)}d'
def render_status_table(payload: dict, *, now: Optional[float] = None) -> str:
"""Human table for `agentctl status` payload."""
lines = [
f"agentctl status main={payload.get('main', '-')} "
f"epoch={_short(payload.get('epoch'))} version={payload.get('version', '-')}",
'',
]
tasks = payload.get('tasks') or []
active = [t for t in tasks if t.get('state') not in ('done', 'cancelled')]
terminal = [t for t in tasks if t.get('state') in ('done', 'cancelled')]
lines.append(f'## tasks active={len(active)} closed={len(terminal)} total={len(tasks)}')
task_rows = [
[
t.get('id'),
t.get('owner'),
t.get('state'),
_short_path(t.get('worktree')),
_short(t.get('branch')),
]
for t in active
]
lines.append(format_table(['task', 'owner', 'state', 'worktree', 'branch'], task_rows) if task_rows
else '(no active tasks)')
lines.append('')
runs = payload.get('runs') or []
lines.append(f'## runs live={len(runs)}')
run_rows = [
[
r.get('id'),
r.get('task'),
r.get('state'),
r.get('supervisor_alive'),
_short(r.get('kind') or r.get('action')),
]
for r in runs
]
lines.append(format_table(['run', 'task', 'state', 'alive', 'kind'], run_rows) if run_rows
else '(no live runs)')
lines.append('')
queue = payload.get('queue') or []
lines.append(f'## queue depth={len(queue)}')
q_rows = [
[
q.get('id'),
q.get('task'),
q.get('state'),
q.get('priority'),
_short(q.get('head_sha')),
_duration(q.get('created'), now),
]
for q in queue
]
lines.append(format_table(['id', 'task', 'state', 'pri', 'head', 'age'], q_rows) if q_rows
else '(queue empty)')
policy = payload.get('policy') or {}
lines.append('')
lines.append(
f"## policy loaded={policy.get('loaded', policy.get('status', '-'))} "
f"revision={policy.get('policy_revision') or policy.get('revision') or '-'}"
)
return '\n'.join(lines) + '\n'
def render_queue_table(payload: dict, *, now: Optional[float] = None) -> str:
items = payload.get('items') or []
rows = [
[
i.get('id'),
i.get('task'),
i.get('state'),
i.get('priority'),
_short(i.get('head_sha')),
_short(i.get('expected_dev')),
_duration(i.get('created'), now),
]
for i in items
]
header = f"queue list count={len(items)}\n"
body = format_table(['id', 'task', 'state', 'pri', 'head', 'expected_dev', 'age'], rows) if rows \
else '(queue empty)'
return header + body + '\n'
def render_claim_table(payload: dict, *, now: Optional[float] = None) -> str:
claims = payload.get('claims') or []
rows = [
[
c.get('id') or c.get('resource'),
c.get('task'),
c.get('resource'),
c.get('state'),
_duration(c.get('updated') or c.get('created'), now),
]
for c in claims
]
header = f"claim list count={len(claims)}\n"
body = format_table(['id', 'task', 'resource', 'state', 'age'], rows) if rows else '(no claims)'
return header + body + '\n'
def render_cleanup_table(payload: dict) -> str:
trees = payload.get('worktrees') or []
ready = [t for t in trees if t.get('cleanup_allowed') or t.get('status') == 'allowed']
blocked = [t for t in trees if t not in ready]
rows = []
for t in trees:
status = 'allowed' if (t.get('cleanup_allowed') or t.get('status') == 'allowed') else 'blocked'
reason = t.get('blocked_reason') or t.get('reason') or ''
if not reason and isinstance(t.get('gates'), list):
fails = [g.get('name') for g in t['gates'] if g.get('status') != 'pass']
reason = ','.join(fails[:3])
rows.append([
_short_path(t.get('path') or t.get('worktree')),
status,
t.get('cleanup_owner') or '-',
reason or '-',
])
header = (
f"cleanup report total={len(trees)} allowed={len(ready)} "
f"blocked/pending={len(blocked)}\n"
)
body = format_table(['path', 'status', 'owner', 'reason'], rows) if rows else '(no worktrees)'
return header + body + '\n'
def build_brief(
*,
status: Optional[dict] = None,
docs_drift: Optional[dict] = None,
cleanup: Optional[dict] = None,
catalog_drift: Optional[dict] = None,
daemon: Optional[dict] = None,
now: Optional[float] = None,
) -> str:
"""Aggregate read-only sources into a one-page Markdown briefing."""
status = status or {}
docs_drift = docs_drift or {}
cleanup = cleanup or {}
catalog_drift = catalog_drift or {}
daemon = daemon or {}
tasks = status.get('tasks') or []
active = [t for t in tasks if t.get('state') not in ('done', 'cancelled')]
blocked = [t for t in active if t.get('state') == 'blocked']
runs = status.get('runs') or []
queue = status.get('queue') or []
trees = cleanup.get('worktrees') or []
pending_cleanup = [
t for t in trees
if not (t.get('cleanup_allowed') or t.get('status') == 'allowed')
]
regressions = docs_drift.get('regressions') or catalog_drift.get('regressions') or []
hard = [r for r in regressions if r.get('severity') == 'hard' or 'done' in str(r.get('message', ''))]
stamp = _dt.datetime.fromtimestamp(now or _dt.datetime.now().timestamp()).strftime('%Y-%m-%d %H:%M')
lines = [
f'# agentctl brief ({stamp})',
'',
f"- daemon: **{daemon.get('status', 'unknown')}** "
f"manager.lock.held={((daemon.get('manager_lock') or {}).get('held'))} "
f"queue pending={((daemon.get('queue') or {}).get('pending', '-'))}",
f"- tasks: active={len(active)} blocked={len(blocked)} "
f"live runs={len(runs)} integrate queue={len(queue)}",
f"- cleanup pending: {len(pending_cleanup)} / {len(trees)} worktrees",
f"- MATRIX drift hard alerts: {len(hard)}",
'',
'## Active tasks',
]
if active:
for t in active[:20]:
lines.append(
f"- `{t.get('id')}` owner={t.get('owner')} state={t.get('state')} "
f"wt={_short_path(t.get('worktree'))}"
)
if len(active) > 20:
lines.append(f'- … and {len(active) - 20} more')
else:
lines.append('- (none)')
lines.append('')
lines.append('## Blockers / alerts')
if blocked:
for t in blocked:
lines.append(f"- blocked task `{t.get('id')}`")
if hard:
for r in hard[:10]:
lines.append(
f"- MATRIX regression `{r.get('record_id')}`: "
f"{r.get('previous')}→{r.get('current')}"
)
if pending_cleanup:
lines.append(f"- cleanup backlog: {len(pending_cleanup)} worktrees not removable")
if not blocked and not hard and not pending_cleanup:
lines.append('- (none)')
lines.append('')
lines.append('## Integrate queue')
if queue:
for q in queue[:10]:
lines.append(
f"- `{q.get('task')}` state={q.get('state')} pri={q.get('priority')} "
f"head={_short(q.get('head_sha'))}"
)
else:
lines.append('- (empty)')
return '\n'.join(lines) + '\n'
def parse_current_claim_rows(content: str) -> list[dict]:
"""Extract parallel-claim table rows from CURRENT.md body."""
section = _claim_section_body(content)
if section is None:
return []
rows = []
for line in section.splitlines():
stripped = line.strip()
if not stripped.startswith('|'):
continue
cells = [c.strip() for c in stripped.strip('|').split('|')]
if not cells or len(cells) < 6:
continue
if cells[0].casefold() in ('owner', '---') or set(cells[0]) <= {'-', ':'}:
continue
if all(re.fullmatch(r':?-+:?', c.replace(' ', '') or '-') for c in cells):
continue
rows.append({
'owner': cells[0],
'task': cells[1],
'branch': cells[2],
'worktree': cells[3],
'mutex': cells[4],
'status': cells[5],
'line': stripped,
})
return rows
def parse_task_date(task_id: str) -> Optional[_dt.date]:
match = TASK_DATE_RE.search(task_id or '')
if not match:
return None
try:
return _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
except ValueError:
return None
def select_archive_candidates(
rows: list[dict],
*,
retain_days: int = 7,
today: Optional[_dt.date] = None,
include_undated: bool = False,
) -> list[dict]:
"""Pick status=done rows older than retain_days (by date embedded in task id)."""
today = today or _dt.date.today()
cutoff = today - _dt.timedelta(days=max(0, int(retain_days)))
selected = []
for row in rows:
if (row.get('status') or '').strip() != 'done':
continue
parsed = parse_task_date(row.get('task') or '')
entry = dict(row)
entry['parsed_date'] = parsed.isoformat() if parsed else None
if parsed is None:
entry['eligible'] = bool(include_undated)
entry['reason'] = 'undated' if include_undated else 'undated_skipped'
selected.append(entry)
continue
if parsed <= cutoff:
entry['eligible'] = True
entry['reason'] = f'older_than_{retain_days}d'
else:
entry['eligible'] = False
entry['reason'] = 'within_retain_window'
selected.append(entry)
return selected
def build_archived_current(content: str, remove_task_ids: Iterable[str]) -> str:
"""Return CURRENT.md with listed claim rows removed from the parallel table."""
remove = set(remove_task_ids)
start, end = _claim_section_span(content)
if start is None:
raise ValueError('CURRENT.md missing ## 并行认领 section')
lines = content.splitlines(keepends=True)
kept = []
for line in lines[start:end]:
stripped = line.strip()
if stripped.startswith('|'):
cells = [c.strip() for c in stripped.strip('|').split('|')]
if len(cells) >= 2 and cells[1] in remove:
continue
kept.append(line)
return ''.join(lines[:start] + kept + lines[end:])
def build_archive_section(rows: list[dict], archive_date: str) -> str:
"""Markdown fragment to append to the CURRENT history archive."""
lines = [
'',
f'## 并行认领归档 {archive_date}',
'',
f'由 `agentctl claim archive` 从 CURRENT 迁出(status=done)。共 {len(rows)} 行。',
'',
CLAIM_HEADER,
'|---|---|---|---|---|---|',
]
for row in rows:
lines.append(
f"| {row.get('owner', '')} | {row.get('task', '')} | {row.get('branch', '')} | "
f"{row.get('worktree', '')} | {row.get('mutex', '')} | {row.get('status', '')} |"
)
lines.append('')
return '\n'.join(lines)
def _claim_section_body(content: str) -> Optional[str]:
start, end = _claim_section_span(content)
if start is None:
return None
return ''.join(content.splitlines(keepends=True)[start:end])
def _claim_section_span(content: str) -> tuple[Optional[int], Optional[int]]:
lines = content.splitlines(keepends=True)
start = None
for i, line in enumerate(lines):
if line.startswith(CLAIM_SECTION):
start = i
break
if start is None:
return None, None
end = len(lines)
for j in range(start + 1, len(lines)):
if lines[j].startswith('## '):
end = j
break
return start, end
def _short(value: Any, n: int = 12) -> str:
if value is None:
return '-'
text = str(value)
return text if len(text) <= n else text[:n]
def _short_path(value: Any) -> str:
if not value:
return '-'
text = str(value).replace('\\', '/')
if '/.worktrees/' in text:
return '.worktrees/' + text.split('/.worktrees/', 1)[1]
return text if len(text) <= 48 else '…' + text[-47:]
+64
View File
@@ -222,6 +222,46 @@ const TOOLS = [
description: 'Catalog table counts and gate/vector config summary',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'queue_list',
description: 'List integrate queue items (pending/claimed by default)',
inputSchema: {
type: 'object',
properties: {
all: { type: 'boolean', description: 'include terminal queue items' },
format: { type: 'string', enum: ['json', 'table'] },
},
additionalProperties: false,
},
},
{
name: 'claim_status',
description: 'List held claims (or all with all=true)',
inputSchema: {
type: 'object',
properties: {
all: { type: 'boolean' },
format: { type: 'string', enum: ['json', 'table'] },
},
additionalProperties: false,
},
},
{
name: 'cleanup_report',
description: 'Batch cleanup readiness for .worktrees (never deletes)',
inputSchema: {
type: 'object',
properties: {
format: { type: 'string', enum: ['json', 'table'] },
},
additionalProperties: false,
},
},
{
name: 'catalog_drift',
description: 'Refresh catalog features from MATRIX; report done→non-done regressions',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
];
function callTool(name, args = {}) {
@@ -332,6 +372,30 @@ function callTool(name, args = {}) {
const r = runAgentctl(['catalog', 'status']);
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
}
case 'queue_list': {
const argv = ['queue', 'list'];
if (args.all) argv.push('--all');
if (args.format) argv.push('--format', args.format);
const r = runAgentctl(argv);
return r.ok ? textResult(r.stdout.trim() || '{"items":[]}') : errResult(r.stderr || r.stdout || r.error);
}
case 'claim_status': {
const argv = ['claim', 'list'];
if (args.all) argv.push('--all');
if (args.format) argv.push('--format', args.format);
const r = runAgentctl(argv);
return r.ok ? textResult(r.stdout.trim() || '{"claims":[]}') : errResult(r.stderr || r.stdout || r.error);
}
case 'cleanup_report': {
const argv = ['cleanup', 'report'];
if (args.format) argv.push('--format', args.format);
const r = runAgentctl(argv, { timeout: 180000 });
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
}
case 'catalog_drift': {
const r = runAgentctl(['catalog', 'drift']);
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
}
default:
return errResult(`unknown tool: ${name}`);
}
+1 -1
View File
@@ -24,7 +24,7 @@ class WorktreeTest(unittest.TestCase):
self.root = Path(self.tmp.name) / "repo"
self.root.mkdir()
(self.root / "scripts").mkdir()
for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py"):
for name in ("agent-worktree.sh", "agent_worktree.py", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py", "agentctl_visibility.py"):
shutil.copy2(SOURCE / name, self.root / "scripts" / name)
(self.root / "configs").mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE.parent / "configs" / "agent-governance.json",
+2
View File
@@ -38,6 +38,7 @@ class AgentctlTests(unittest.TestCase):
shutil.copy2(SOURCE, self.root / "scripts" / "agentctl.py")
shutil.copy2(SOURCE.parent / "agentctl_w4.py", self.root / "scripts" / "agentctl_w4.py")
shutil.copy2(SOURCE.parent / "agentctl_catalog.py", self.root / "scripts" / "agentctl_catalog.py")
shutil.copy2(SOURCE.parent / "agentctl_visibility.py", self.root / "scripts" / "agentctl_visibility.py")
install_governance_policy(self.root)
(self.root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
for directory in ("src/a", "src/b", ".doc/ops", ".doc/status"):
@@ -1553,6 +1554,7 @@ class ProcessPlatformTests(unittest.TestCase):
shutil.copy2(SOURCE, root / "scripts" / "agentctl.py")
shutil.copy2(SOURCE.parent / "agentctl_w4.py", root / "scripts" / "agentctl_w4.py")
shutil.copy2(SOURCE.parent / "agentctl_catalog.py", root / "scripts" / "agentctl_catalog.py")
shutil.copy2(SOURCE.parent / "agentctl_visibility.py", root / "scripts" / "agentctl_visibility.py")
install_governance_policy(root)
(root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
(root / ".doc/ops").mkdir(parents=True)
+3
View File
@@ -33,6 +33,9 @@ class CatalogTests(unittest.TestCase):
shutil.copy2(SOURCE, self.root / "scripts" / "agentctl.py")
shutil.copy2(SOURCE.parent / "agentctl_w4.py", self.root / "scripts" / "agentctl_w4.py")
shutil.copy2(CATALOG_SOURCE, self.root / "scripts" / "agentctl_catalog.py")
vis = SOURCE.parent / "agentctl_visibility.py"
if vis.exists():
shutil.copy2(vis, self.root / "scripts" / "agentctl_visibility.py")
install_governance_policy(self.root)
(self.root / ".gitignore").write_text(".locks/\n.worktrees/\n", encoding="utf-8")
for directory in ("src/a", "src/b", ".doc/ops", ".doc/status"):
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Unit tests for agentctl visibility helpers (table/brief/claim-archive)."""
import datetime as dt
import pathlib
import sys
import unittest
SCRIPTS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
sys.dont_write_bytecode = True
import agentctl_visibility as vis # noqa: E402
SAMPLE_CURRENT = """# 当前焦点
## 焦点
1. something
## 并行认领
| owner | task | branch | worktree | mutex | status |
|---|---|---|---|---|---|
| Cursor | keep-active-2026-09-24 | feature/keep | .worktrees/keep | m1 | in_progress |
| Cursor | recent-done-2026-09-23 | feature/recent | .worktrees/recent | m2 | done |
| Codex | old-done-2026-09-10 | feature/old | .worktrees/old | m3 | done |
| Cursor | undated-done | feature/undated | .worktrees/u | m4 | done |
## 集成槽位
| holder | phase |
|---|---|
| - | idle |
"""
class FormatTableTests(unittest.TestCase):
def test_format_table_aligns_columns(self):
text = vis.format_table(['a', 'bb'], [['1', '22'], ['333', '4']])
lines = text.splitlines()
self.assertEqual(len(lines), 4)
self.assertIn('a', lines[0])
self.assertTrue(lines[1].startswith('-'))
def test_render_status_table_active_only(self):
payload = {
'main': '/repo',
'epoch': 'abc-epoch',
'version': 1,
'tasks': [
{'id': 't1', 'owner': 'A', 'state': 'active', 'worktree': '/repo/.worktrees/t1', 'branch': 'feature/t1'},
{'id': 't2', 'owner': 'B', 'state': 'done', 'worktree': '/repo/.worktrees/t2', 'branch': 'feature/t2'},
],
'runs': [],
'queue': [{'id': 'q1', 'task': 't1', 'state': 'pending', 'priority': 1, 'head_sha': 'deadbeef', 'created': 0}],
'policy': {'loaded': 'ok', 'policy_revision': 'r1'},
}
text = vis.render_status_table(payload, now=100.0)
self.assertIn('active=1', text)
self.assertIn('t1', text)
self.assertNotIn('t2', text.split('## runs')[0]) # closed not in active table
self.assertIn('queue depth=1', text)
class BriefTests(unittest.TestCase):
def test_build_brief_markdown(self):
text = vis.build_brief(
status={
'tasks': [{'id': 't1', 'owner': 'A', 'state': 'active', 'worktree': '/x/.worktrees/t1'}],
'runs': [],
'queue': [],
},
docs_drift={'regressions': []},
cleanup={'worktrees': [{'path': 'a', 'cleanup_allowed': False}]},
catalog_drift={'regressions': []},
daemon={'status': 'ok', 'manager_lock': {'held': True}, 'queue': {'pending': 0}},
now=1_700_000_000,
)
self.assertTrue(text.startswith('# agentctl brief'))
self.assertIn('daemon: **ok**', text)
self.assertIn('`t1`', text)
self.assertIn('cleanup backlog: 1', text)
class ClaimArchiveTests(unittest.TestCase):
def test_parse_current_claim_rows(self):
rows = vis.parse_current_claim_rows(SAMPLE_CURRENT)
self.assertEqual(len(rows), 4)
self.assertEqual(rows[0]['task'], 'keep-active-2026-09-24')
self.assertEqual(rows[2]['status'], 'done')
def test_parse_task_date(self):
self.assertEqual(vis.parse_task_date('foo-2026-09-10'), dt.date(2026, 9, 10))
self.assertEqual(vis.parse_task_date('2026-09-10-bar'), dt.date(2026, 9, 10))
self.assertIsNone(vis.parse_task_date('no-date-here'))
def test_select_archive_candidates_respects_retain_window(self):
rows = vis.parse_current_claim_rows(SAMPLE_CURRENT)
selected = vis.select_archive_candidates(
rows, retain_days=7, today=dt.date(2026, 9, 24), include_undated=False)
eligible = [r for r in selected if r['eligible']]
self.assertEqual([r['task'] for r in eligible], ['old-done-2026-09-10'])
skipped_reasons = {r['task']: r['reason'] for r in selected if not r['eligible']}
self.assertEqual(skipped_reasons['recent-done-2026-09-23'], 'within_retain_window')
self.assertEqual(skipped_reasons['undated-done'], 'undated_skipped')
def test_select_include_undated(self):
rows = vis.parse_current_claim_rows(SAMPLE_CURRENT)
selected = vis.select_archive_candidates(
rows, retain_days=0, today=dt.date(2026, 9, 24), include_undated=True)
eligible_ids = {r['task'] for r in selected if r['eligible']}
self.assertIn('undated-done', eligible_ids)
self.assertIn('old-done-2026-09-10', eligible_ids)
self.assertIn('recent-done-2026-09-23', eligible_ids)
self.assertNotIn('keep-active-2026-09-24', eligible_ids)
def test_build_archived_current_removes_rows(self):
out = vis.build_archived_current(SAMPLE_CURRENT, ['old-done-2026-09-10', 'undated-done'])
self.assertNotIn('old-done-2026-09-10', out)
self.assertNotIn('undated-done', out)
self.assertIn('keep-active-2026-09-24', out)
self.assertIn('## 集成槽位', out)
self.assertIn('## 焦点', out)
def test_build_archive_section(self):
rows = [{'owner': 'A', 'task': 't', 'branch': 'b', 'worktree': 'w', 'mutex': 'm', 'status': 'done'}]
section = vis.build_archive_section(rows, '2026-09-24')
self.assertIn('## 并行认领归档 2026-09-24', section)
self.assertIn('| A | t | b | w | m | done |', section)
class QueueClaimCleanupRenderTests(unittest.TestCase):
def test_render_queue_and_claims(self):
q = vis.render_queue_table({'items': [
{'id': '1', 'task': 't', 'state': 'pending', 'priority': 0,
'head_sha': 'abc', 'expected_dev': 'def', 'created': 0},
]}, now=10)
self.assertIn('queue list count=1', q)
c = vis.render_claim_table({'claims': [
{'id': 'c1', 'task': 't', 'resource': 'claim:x', 'state': 'held', 'updated': 0},
]}, now=10)
self.assertIn('claim list count=1', c)
def test_render_cleanup(self):
text = vis.render_cleanup_table({'worktrees': [
{'path': '/repo/.worktrees/a', 'cleanup_allowed': True, 'cleanup_owner': 'X'},
{'path': '/repo/.worktrees/b', 'status': 'blocked', 'gates': [
{'name': 'dev_ancestor', 'status': 'fail'},
]},
]})
self.assertIn('allowed=1', text)
self.assertIn('blocked/pending=1', text)
self.assertIn('dev_ancestor', text)
if __name__ == '__main__':
unittest.main()
+2 -1
View File
@@ -21,7 +21,8 @@ class CodegraphGovernanceTest(unittest.TestCase):
self.root.mkdir()
(self.root / 'scripts').mkdir()
for name in ('agent-worktree.sh', 'agent_worktree.py', 'agentctl.py',
'agentctl', 'agentctl_w4.py', 'agentctl_catalog.py'):
'agentctl', 'agentctl_w4.py', 'agentctl_catalog.py',
'agentctl_visibility.py'):
shutil.copy2(SOURCE / name, self.root / 'scripts' / name)
(self.root / 'configs').mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE.parent / 'configs' / 'agent-governance.json',
+1 -1
View File
@@ -19,7 +19,7 @@ class OpsGuardsTest(unittest.TestCase):
self.root = Path(self.tmp.name) / "repo"
self.root.mkdir()
(self.root / "scripts").mkdir()
for name in ("env-service.sh", "candidate-images-build.sh", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py"):
for name in ("env-service.sh", "candidate-images-build.sh", "agentctl.py", "agentctl", "agentctl_w4.py", "agentctl_catalog.py", "agentctl_visibility.py"):
shutil.copy2(SOURCE / name, self.root / "scripts" / name)
(self.root / ".gitignore").write_text(".worktrees/\n.locks/\nconfigs/.env.*\n!configs/.env.*.example\ndata/\n")
(self.root / ".dockerignore").write_text(".doc\n.locks\n.worktrees\n")
+3
View File
@@ -149,6 +149,9 @@ class PiSubagentsTests(unittest.TestCase):
catalog_src = ROOT / "scripts" / "agentctl_catalog.py"
if catalog_src.exists():
shutil.copy2(catalog_src, self.root / "scripts" / "agentctl_catalog.py")
vis_src = ROOT / "scripts" / "agentctl_visibility.py"
if vis_src.exists():
shutil.copy2(vis_src, self.root / "scripts" / "agentctl_visibility.py")
if SOURCE_EXEC.exists():
shutil.copy2(SOURCE_EXEC, self.root / "scripts" / "pi_subagents.py")
if SOURCE_MCP.exists():