agentflow-proof 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentflow/cli.py ADDED
@@ -0,0 +1,2108 @@
1
+ """Command line interface for Agentflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from . import __version__
13
+ from .aggregate import analyze, parse_sources, write_canonical
14
+ from .artifacts import (
15
+ SCHEMA_VERSION,
16
+ append_jsonl,
17
+ create_initial_artifacts,
18
+ read_json,
19
+ read_jsonl,
20
+ utc_now,
21
+ write_json,
22
+ )
23
+ from .contracts import (
24
+ AMENDMENTS_SCHEMA_VERSION,
25
+ CONTEXT_RECEIPTS_SCHEMA_VERSION,
26
+ EVIDENCE_KINDS,
27
+ EVIDENCE_SCHEMA_VERSION,
28
+ FAILURES_SCHEMA_VERSION,
29
+ WORKFLOW_REVIEW_DEPTHS,
30
+ strict_mode,
31
+ )
32
+ from .execution import (
33
+ AMENDMENT_REASON_CODES,
34
+ amend_step,
35
+ block_step,
36
+ claim_step,
37
+ complete_step,
38
+ doctor,
39
+ fail_step,
40
+ init_execution_artifacts,
41
+ mark_step_verified,
42
+ next_step,
43
+ reclaim_step,
44
+ renew_lease,
45
+ require_lifecycle_owner,
46
+ )
47
+ from .execution_coverage import verify_run, verify_step
48
+ from .events import filter_events_since, project_events, valid_since
49
+ from .capabilities import append_capability_receipt, build_capability_receipt
50
+ from .handoff import export_handoff, lint_handoff_text
51
+ from . import porcelain
52
+ from .packs import (
53
+ PackError,
54
+ find_profile,
55
+ inspect_summary,
56
+ load_pack,
57
+ profile_to_contract,
58
+ render_inspect_summary,
59
+ template_to_plan,
60
+ )
61
+ from .draft_plan import (
62
+ DRAFT_PLAN_SCHEMA_VERSION,
63
+ DraftPlanError,
64
+ compile_draft_plan,
65
+ selection_reason,
66
+ )
67
+ from .proof import (
68
+ InvalidWorkingState,
69
+ build_proof,
70
+ render_markdown,
71
+ verify_proof,
72
+ write_proof_metadata,
73
+ )
74
+ from .viewer import collect_view_model, render_html
75
+ from .stuck import Thresholds, detect_stuck
76
+ from .recommend import (
77
+ RecommendError,
78
+ recommend as recommend_workflow,
79
+ render_text as render_recommendation,
80
+ validate_brief,
81
+ )
82
+ from .receipts import record_command, record_file_change, replay_gates, run_command
83
+ from .review import parse_finding_ref, record_review_run, review_evidence_entries
84
+ from .review_runner import MANIFEST_FILENAME, exit_code_for, produce_manifest
85
+ from .runtime import build_runtime_status
86
+ from .validation import (
87
+ audit_drift,
88
+ validate_design_decision_traceability,
89
+ validate_plan,
90
+ validate_requirement_traceability,
91
+ )
92
+ from .workflow_contract import (
93
+ WORKFLOW_CONTRACT_PATH,
94
+ validate_workflow_contract,
95
+ write_workflow_contract,
96
+ )
97
+
98
+
99
+ def print_lines(lines: List[str]) -> None:
100
+ for line in lines:
101
+ print(line)
102
+
103
+
104
+ def _positive_int(value: str) -> int:
105
+ try:
106
+ parsed = int(value)
107
+ except ValueError as exc:
108
+ raise argparse.ArgumentTypeError("must be a positive integer") from exc
109
+ if parsed < 1:
110
+ raise argparse.ArgumentTypeError("must be a positive integer")
111
+ return parsed
112
+
113
+
114
+ def command_init(args: argparse.Namespace) -> int:
115
+ root = Path(args.root).resolve()
116
+ pack_path = getattr(args, "pack", None)
117
+ profile_id = getattr(args, "profile", None)
118
+ if profile_id and not pack_path:
119
+ print("error: --profile requires --pack", file=sys.stderr)
120
+ return 2
121
+ if pack_path:
122
+ return _command_init_with_pack(root, pack_path, profile_id, args)
123
+ created, skipped = create_initial_artifacts(root, force=args.force)
124
+ for path in created:
125
+ print(f"created {path}")
126
+ for path in skipped:
127
+ print(f"skipped {path}")
128
+ return 0
129
+
130
+
131
+ def _command_init_with_pack(
132
+ root: Path, pack_path: str, profile_id: Optional[str], args: argparse.Namespace
133
+ ) -> int:
134
+ if not profile_id:
135
+ print("error: --pack requires --profile", file=sys.stderr)
136
+ return 2
137
+ try:
138
+ pack = load_pack(Path(pack_path))
139
+ profile = profile_to_contract(
140
+ pack.manifest,
141
+ profile_id,
142
+ "init --pack",
143
+ args.reason
144
+ or f"Selected pack {pack.manifest['id']} profile {profile_id} via init --pack.",
145
+ )
146
+ template_id = find_profile(pack.manifest, profile_id)["plan_template"]
147
+ plan = template_to_plan(pack.manifest, template_id)
148
+ except PackError as exc:
149
+ print(f"error: {exc}", file=sys.stderr)
150
+ return 1
151
+
152
+ plan_path = root / ".agent" / "plan.lock.json"
153
+ contract_path = root / WORKFLOW_CONTRACT_PATH
154
+ if (plan_path.exists() or contract_path.exists()) and not args.force:
155
+ print(
156
+ "error: refusing to overwrite existing .agent/plan.lock.json or "
157
+ ".agent/workflow.contract.json (use --force)",
158
+ file=sys.stderr,
159
+ )
160
+ return 1
161
+
162
+ # Scaffold base artifacts without force: never wipe pre-existing ledgers
163
+ # when --force was passed only to override the plan/contract conflict above.
164
+ # The plan and contract below are written explicitly regardless.
165
+ created, _skipped = create_initial_artifacts(root, force=False)
166
+ for path in created:
167
+ print(f"created {path}")
168
+ write_json(plan_path, plan)
169
+ write_workflow_contract(root, profile)
170
+ print(f"seeded .agent/plan.lock.json from template {template_id}")
171
+ print(f"wrote {WORKFLOW_CONTRACT_PATH}")
172
+ return 0
173
+
174
+
175
+ def command_pack_inspect(args: argparse.Namespace) -> int:
176
+ try:
177
+ pack = load_pack(Path(args.path))
178
+ except PackError as exc:
179
+ if args.json:
180
+ print(
181
+ json.dumps(
182
+ {
183
+ "status": "invalid",
184
+ "errors": [_plan_diagnostic("validation_error", str(exc))],
185
+ },
186
+ indent=2,
187
+ sort_keys=True,
188
+ )
189
+ )
190
+ else:
191
+ print(f"error: {exc}", file=sys.stderr)
192
+ return 1
193
+ summary = inspect_summary(pack)
194
+ if args.json:
195
+ print(json.dumps(summary, indent=2, sort_keys=True))
196
+ else:
197
+ print_lines(render_inspect_summary(summary))
198
+ return 0
199
+
200
+
201
+ def command_init_execution(args: argparse.Namespace) -> int:
202
+ root = Path(args.root).resolve()
203
+ created, skipped = init_execution_artifacts(root, force=args.force)
204
+ for path in created:
205
+ print(f"created {path}")
206
+ for path in skipped:
207
+ print(f"skipped {path}")
208
+ return 0
209
+
210
+
211
+ def command_doctor(args: argparse.Namespace) -> int:
212
+ root = Path(args.root).resolve()
213
+ result = doctor(root)
214
+ if args.json:
215
+ print(json.dumps(result, indent=2, sort_keys=True))
216
+ else:
217
+ print(f"doctor {result['status']}")
218
+ for finding in result["findings"]:
219
+ print(f"{finding['severity']}: {finding['message']}")
220
+ return 1 if result["status"] == "failed" else 0
221
+
222
+
223
+ def command_intake(args: argparse.Namespace) -> int:
224
+ root = Path(args.root).resolve()
225
+ source = Path(args.from_file)
226
+ text = source.read_text(encoding="utf-8")
227
+ objective = next((line.strip() for line in text.splitlines() if line.strip()), "")
228
+ intake = {
229
+ # Intake is a transient draft artifact and intentionally tracks the plan schema.
230
+ "schema_version": SCHEMA_VERSION,
231
+ "objective": objective,
232
+ "non_goals": [],
233
+ "success_criteria": [],
234
+ "risk_level": "low",
235
+ "requires_user_approval": False,
236
+ "initial_unknowns": [],
237
+ "source": str(source),
238
+ "created_at": utc_now(),
239
+ }
240
+ output = root / ".agent/intake.json"
241
+ write_json(output, intake)
242
+ print(f"created {output.relative_to(root)}")
243
+ return 0
244
+
245
+
246
+ def _load_plan(path: Path) -> Dict[str, Any]:
247
+ if not path.exists():
248
+ raise FileNotFoundError(f"plan file not found: {path}")
249
+ return read_json(path)
250
+
251
+
252
+ def _plan_diagnostic(code: str, message: str) -> Dict[str, str]:
253
+ return {"code": code, "message": message}
254
+
255
+
256
+ def _print_plan_result(
257
+ status: str,
258
+ *,
259
+ path: Optional[Path] = None,
260
+ errors: Optional[List[Dict[str, str]]] = None,
261
+ ) -> None:
262
+ payload: Dict[str, Any] = {
263
+ "status": status,
264
+ "errors": errors or [],
265
+ }
266
+ if path is not None:
267
+ payload["path"] = str(path)
268
+ print(json.dumps(payload, indent=2, sort_keys=True))
269
+
270
+
271
+ def _json_decode_message(source: str, exc: json.JSONDecodeError) -> str:
272
+ return f"{source}: invalid JSON at line {exc.lineno} column {exc.colno}: {exc.msg}"
273
+
274
+
275
+ def _coerce_plan_object(data: Any, source: str) -> Dict[str, Any]:
276
+ if not isinstance(data, dict):
277
+ raise ValueError(f"{source}: top-level value must be a JSON object")
278
+ return data
279
+
280
+
281
+ def _load_plan_from_path(path: Path) -> Dict[str, Any]:
282
+ if not path.exists():
283
+ raise FileNotFoundError(f"plan file not found: {path}")
284
+ try:
285
+ return _coerce_plan_object(read_json(path), str(path))
286
+ except json.JSONDecodeError as exc:
287
+ raise ValueError(_json_decode_message(str(path), exc)) from exc
288
+
289
+
290
+ def _load_plan_from_stdin() -> Dict[str, Any]:
291
+ try:
292
+ data = json.loads(sys.stdin.read())
293
+ except json.JSONDecodeError as exc:
294
+ raise ValueError(_json_decode_message("stdin", exc)) from exc
295
+ return _coerce_plan_object(data, "stdin")
296
+
297
+
298
+ def _load_root_plan(root: Path) -> Dict[str, Any]:
299
+ return read_json(root / ".agent/plan.lock.json")
300
+
301
+
302
+ def command_validate_plan(args: argparse.Namespace) -> int:
303
+ plan_path = Path(args.plan).resolve()
304
+ try:
305
+ plan = _load_plan(plan_path)
306
+ except (FileNotFoundError, ValueError) as exc:
307
+ # One presentation for every unreadable plan (missing, malformed JSON,
308
+ # or schema_version policy rejection): stderr + exit 1.
309
+ print(f"invalid plan: {exc}", file=sys.stderr)
310
+ return 1
311
+
312
+ errors = validate_plan(plan)
313
+ if errors:
314
+ print("plan invalid")
315
+ for error in errors:
316
+ print(f"- {error}")
317
+ return 1
318
+
319
+ print("plan valid")
320
+ return 0
321
+
322
+
323
+ def command_lock_plan(args: argparse.Namespace) -> int:
324
+ if args.stdin and args.from_json:
325
+ message = "--stdin and --from-json are mutually exclusive"
326
+ if args.json:
327
+ _print_plan_result(
328
+ "invalid",
329
+ errors=[_plan_diagnostic("invalid_arguments", message)],
330
+ )
331
+ else:
332
+ print(message, file=sys.stderr)
333
+ return 2
334
+
335
+ output_path = Path(args.plan).resolve()
336
+ try:
337
+ if args.stdin:
338
+ plan = _load_plan_from_stdin()
339
+ elif args.from_json:
340
+ plan = _load_plan_from_path(Path(args.from_json).resolve())
341
+ else:
342
+ plan = _load_plan_from_path(output_path)
343
+ except FileNotFoundError as exc:
344
+ if args.json:
345
+ _print_plan_result(
346
+ "invalid",
347
+ errors=[_plan_diagnostic("input_not_found", str(exc))],
348
+ )
349
+ else:
350
+ print(f"invalid plan: {exc}", file=sys.stderr)
351
+ return 1
352
+ except ValueError as exc:
353
+ code = (
354
+ "invalid_json"
355
+ if isinstance(exc.__cause__, json.JSONDecodeError)
356
+ else "invalid_plan_json"
357
+ )
358
+ if args.json:
359
+ _print_plan_result("invalid", errors=[_plan_diagnostic(code, str(exc))])
360
+ else:
361
+ print(f"invalid plan: {exc}", file=sys.stderr)
362
+ return 1
363
+
364
+ errors = validate_plan(plan)
365
+ if errors:
366
+ if args.json:
367
+ _print_plan_result(
368
+ "invalid",
369
+ errors=[_plan_diagnostic("validation_error", error) for error in errors],
370
+ )
371
+ else:
372
+ print("plan invalid")
373
+ for error in errors:
374
+ print(f"- {error}")
375
+ return 1
376
+
377
+ plan["locked"] = True
378
+ plan["locked_at"] = utc_now()
379
+ write_json(output_path, plan)
380
+ if args.json:
381
+ _print_plan_result("locked", path=output_path)
382
+ else:
383
+ print(f"locked {output_path}")
384
+ return 0
385
+
386
+
387
+ def _load_json_object(path: Path, kind: str) -> Dict[str, Any]:
388
+ if not path.exists():
389
+ raise FileNotFoundError(f"{kind} file not found: {path}")
390
+ try:
391
+ data = read_json(path)
392
+ except json.JSONDecodeError as exc:
393
+ raise ValueError(_json_decode_message(str(path), exc)) from exc
394
+ if not isinstance(data, dict):
395
+ raise ValueError(f"{path}: top-level value must be a JSON object")
396
+ return data
397
+
398
+
399
+ def _print_workflow_contract_errors(errors: List[str]) -> None:
400
+ print("workflow contract invalid")
401
+ for error in errors:
402
+ print(f"- {error}")
403
+
404
+
405
+ def command_workflow_contract(args: argparse.Namespace) -> int:
406
+ root = Path(args.root).resolve()
407
+ if args.from_json:
408
+ if args.path != WORKFLOW_CONTRACT_PATH:
409
+ print(
410
+ f"PATH cannot be used with --from-json; writes go to {WORKFLOW_CONTRACT_PATH}",
411
+ file=sys.stderr,
412
+ )
413
+ return 2
414
+ source = Path(args.from_json)
415
+ if not source.is_absolute():
416
+ source = root / source
417
+ try:
418
+ contract = _load_json_object(source, "workflow contract")
419
+ except (FileNotFoundError, ValueError) as exc:
420
+ print(f"workflow contract invalid: {exc}", file=sys.stderr)
421
+ return 1
422
+ errors = validate_workflow_contract(contract)
423
+ if errors:
424
+ _print_workflow_contract_errors(errors)
425
+ return 1
426
+ path = write_workflow_contract(root, contract)
427
+ print(f"wrote {path.relative_to(root)}")
428
+ return 0
429
+
430
+ target = Path(args.path or WORKFLOW_CONTRACT_PATH)
431
+ if not target.is_absolute():
432
+ target = root / target
433
+ try:
434
+ contract = _load_json_object(target, "workflow contract")
435
+ except (FileNotFoundError, ValueError) as exc:
436
+ print(f"workflow contract invalid: {exc}", file=sys.stderr)
437
+ return 1
438
+ errors = validate_workflow_contract(contract)
439
+ if errors:
440
+ _print_workflow_contract_errors(errors)
441
+ return 1
442
+ print("workflow contract valid")
443
+ return 0
444
+
445
+
446
+ def command_amend_plan(args: argparse.Namespace) -> int:
447
+ root = Path(args.root).resolve()
448
+ amendment = {
449
+ "schema_version": AMENDMENTS_SCHEMA_VERSION,
450
+ "id": args.id,
451
+ "reason": args.reason,
452
+ "new_evidence": args.evidence or [],
453
+ "changed_scope": args.changed_scope or [],
454
+ "changed_steps": args.changed_steps or [],
455
+ "changed_risk": args.changed_risk,
456
+ "requires_approval": args.requires_approval,
457
+ "created_at": utc_now(),
458
+ }
459
+ append_jsonl(root / ".agent/amendments.jsonl", amendment)
460
+ print(f"recorded amendment {args.id}")
461
+ return 0
462
+
463
+
464
+ def command_record_evidence(args: argparse.Namespace) -> int:
465
+ root = Path(args.root).resolve()
466
+ evidence = {
467
+ "schema_version": EVIDENCE_SCHEMA_VERSION,
468
+ "id": args.id,
469
+ "claim": args.claim,
470
+ "source": args.source,
471
+ "confidence": args.confidence,
472
+ "last_verified": utc_now(),
473
+ }
474
+ if args.kind:
475
+ evidence["kind"] = args.kind
476
+ if args.supports:
477
+ evidence["supports"] = args.supports
478
+ append_jsonl(root / ".agent/evidence.jsonl", evidence)
479
+ print(f"recorded evidence {args.id}")
480
+ return 0
481
+
482
+
483
+ def command_record_review(args: argparse.Namespace) -> int:
484
+ root = Path(args.root).resolve()
485
+ manifest_path = Path(args.manifest)
486
+ if not manifest_path.is_absolute():
487
+ manifest_path = root / manifest_path
488
+ try:
489
+ record = record_review_run(root, manifest_path)
490
+ except ValueError as exc:
491
+ print(f"invalid review manifest: {exc}", file=sys.stderr)
492
+ return 1
493
+ if args.emit_evidence:
494
+ for entry in review_evidence_entries(record):
495
+ append_jsonl(root / ".agent/evidence.jsonl", entry)
496
+ if args.json:
497
+ print(json.dumps(record, indent=2, sort_keys=True))
498
+ else:
499
+ print(f"recorded review run {record['review_run_id']}")
500
+ return 0
501
+
502
+
503
+ def command_record_capability(args: argparse.Namespace) -> int:
504
+ root = Path(args.root).resolve()
505
+ try:
506
+ row = build_capability_receipt(
507
+ args.id,
508
+ args.capability,
509
+ "used",
510
+ args.reason,
511
+ provider=args.provider,
512
+ evidence=args.evidence,
513
+ )
514
+ except ValueError as exc:
515
+ print(f"invalid capability receipt: {exc}", file=sys.stderr)
516
+ return 1
517
+ append_capability_receipt(root, row)
518
+ print(f"recorded capability {row['id']}")
519
+ return 0
520
+
521
+
522
+ def command_waive_capability(args: argparse.Namespace) -> int:
523
+ root = Path(args.root).resolve()
524
+ try:
525
+ row = build_capability_receipt(
526
+ args.id,
527
+ args.capability,
528
+ "waived",
529
+ args.reason,
530
+ provider=None,
531
+ evidence=args.evidence,
532
+ )
533
+ except ValueError as exc:
534
+ print(f"invalid capability receipt: {exc}", file=sys.stderr)
535
+ return 1
536
+ append_capability_receipt(root, row)
537
+ print(f"waived capability {row['id']}")
538
+ return 0
539
+
540
+
541
+ def command_review_manifest(args: argparse.Namespace) -> int:
542
+ root = Path(args.root).resolve()
543
+ config_path = Path(args.config)
544
+ if not config_path.is_absolute():
545
+ config_path = root / config_path
546
+ try:
547
+ manifest = produce_manifest(
548
+ root=root,
549
+ state_dir=args.state_dir,
550
+ branch=args.branch,
551
+ findings_json=args.findings_json,
552
+ config_path=config_path,
553
+ depth_profile=args.depth_profile,
554
+ )
555
+ except ValueError as exc:
556
+ print(f"review-manifest failed: {exc}", file=sys.stderr)
557
+ return 1
558
+ if args.write:
559
+ out_dir = (root / args.state_dir).resolve()
560
+ write_json(out_dir / MANIFEST_FILENAME, manifest)
561
+ if args.json:
562
+ print(json.dumps(manifest, indent=2, sort_keys=True))
563
+ else:
564
+ print(
565
+ f"review-manifest {manifest['review_run_id']} "
566
+ f"gate_status={manifest['gate_status']} "
567
+ f"active_blocking={len(manifest['active_blocking'])}"
568
+ )
569
+ return exit_code_for(manifest, args.fail_on_block, args.strict_exit)
570
+
571
+
572
+ def command_record_context(args: argparse.Namespace) -> int:
573
+ root = Path(args.root).resolve()
574
+ if args.bytes is not None and args.bytes < 0:
575
+ print("context bytes must be non-negative", file=sys.stderr)
576
+ return 1
577
+ receipt = {
578
+ "schema_version": CONTEXT_RECEIPTS_SCHEMA_VERSION,
579
+ "id": args.id,
580
+ "source": args.source,
581
+ "reason": args.reason,
582
+ "used_for": args.used_for or [],
583
+ "created_at": utc_now(),
584
+ }
585
+ if args.bytes is not None:
586
+ receipt["bytes"] = args.bytes
587
+ append_jsonl(root / ".agent/context-receipts.jsonl", receipt)
588
+ print(f"recorded context receipt {args.id}")
589
+ return 0
590
+
591
+
592
+ def command_record_failure(args: argparse.Namespace) -> int:
593
+ root = Path(args.root).resolve()
594
+ failure = {
595
+ "schema_version": FAILURES_SCHEMA_VERSION,
596
+ "command": args.command,
597
+ "failure_type": args.failure_type,
598
+ "relevant_lines": args.relevant_lines or [],
599
+ "suspected_cause": args.suspected_cause,
600
+ "next_action": args.next_action,
601
+ "created_at": utc_now(),
602
+ }
603
+ append_jsonl(root / ".agent/failures.jsonl", failure)
604
+ print(f"recorded failure signature for {args.command}")
605
+ return 0
606
+
607
+
608
+ def command_audit_drift(args: argparse.Namespace) -> int:
609
+ root = Path(args.root).resolve()
610
+ plan_path = Path(args.plan)
611
+ if not plan_path.is_absolute():
612
+ plan_path = root / plan_path
613
+
614
+ try:
615
+ plan = _load_plan(plan_path)
616
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
617
+ print(f"invalid plan: {exc}", file=sys.stderr)
618
+ return 1
619
+
620
+ report = audit_drift(root, plan)
621
+ output = root / ".agent/drift-report.json"
622
+ write_json(output, report)
623
+ print(f"drift audit {report['status']}")
624
+ if report["out_of_scope_files"]:
625
+ print("out of scope:")
626
+ print_lines([f"- {path}" for path in report["out_of_scope_files"]])
627
+ if report["blocked_files_changed"]:
628
+ print("blocked files changed:")
629
+ print_lines([f"- {path}" for path in report["blocked_files_changed"]])
630
+ if report.get("unmapped_hunks"):
631
+ print("unmapped hunks:")
632
+ hunk_lines = []
633
+ for entry in report["unmapped_hunks"]:
634
+ if isinstance(entry, dict):
635
+ hunk_lines.append(
636
+ f"- {entry['path']} @@ -{entry['old_start']},{entry['old_count']} "
637
+ f"+{entry['new_start']},{entry['new_count']} @@ "
638
+ f"{entry['hash'][:12]} ({entry['reason']})"
639
+ )
640
+ else:
641
+ hunk_lines.append(f"- {entry}")
642
+ print_lines(hunk_lines)
643
+ for note in report["notes"]:
644
+ print(f"note: {note}")
645
+ return 1 if report["status"] == "fail" else 0
646
+
647
+
648
+ def command_next_step(args: argparse.Namespace) -> int:
649
+ root = Path(args.root).resolve()
650
+ plan = _load_root_plan(root)
651
+ step = next_step(root, plan)
652
+ if args.json:
653
+ print(json.dumps(step, indent=2, sort_keys=True))
654
+ elif step:
655
+ print(step["id"])
656
+ else:
657
+ print("no eligible step")
658
+ return 0
659
+
660
+
661
+ def command_claim_step(args: argparse.Namespace) -> int:
662
+ root = Path(args.root).resolve()
663
+ plan = _load_root_plan(root)
664
+ try:
665
+ event = claim_step(root, plan, args.step, args.agent, args.lease_minutes)
666
+ except ValueError as exc:
667
+ print(str(exc), file=sys.stderr)
668
+ return 2
669
+ if args.json:
670
+ print(json.dumps(event, indent=2, sort_keys=True))
671
+ else:
672
+ print(f"claimed {event['step_id']} as {event['attempt_id']}")
673
+ return 0
674
+
675
+
676
+ def command_amend_step(args: argparse.Namespace) -> int:
677
+ root = Path(args.root).resolve()
678
+ plan = _load_root_plan(root)
679
+ finding_refs = []
680
+ for raw in args.finding or []:
681
+ try:
682
+ finding_refs.append(parse_finding_ref(raw))
683
+ except ValueError as exc:
684
+ print(str(exc), file=sys.stderr)
685
+ return 2
686
+ try:
687
+ event = amend_step(
688
+ root,
689
+ plan,
690
+ args.step,
691
+ args.agent,
692
+ args.reason,
693
+ args.reason_code,
694
+ finding_refs=finding_refs or None,
695
+ lease_minutes=args.lease_minutes,
696
+ )
697
+ except ValueError as exc:
698
+ print(str(exc), file=sys.stderr)
699
+ return 2
700
+ if args.json:
701
+ print(json.dumps(event, indent=2, sort_keys=True))
702
+ else:
703
+ print(
704
+ f"amended {event['step_id']} as {event['attempt_id']} "
705
+ f"(amends {event['amends_attempt']})"
706
+ )
707
+ return 0
708
+
709
+
710
+ def command_complete_step(args: argparse.Namespace) -> int:
711
+ root = Path(args.root).resolve()
712
+ try:
713
+ event = complete_step(root, args.step, args.attempt, agent_id=args.agent)
714
+ except ValueError as exc:
715
+ print(str(exc), file=sys.stderr)
716
+ return 2
717
+ except RuntimeError as exc:
718
+ print(str(exc), file=sys.stderr)
719
+ return 1
720
+ if args.json:
721
+ print(json.dumps(event, indent=2, sort_keys=True))
722
+ else:
723
+ print(f"completed {event['step_id']} {event['attempt_id']}")
724
+ return 0
725
+
726
+
727
+ def command_block_step(args: argparse.Namespace) -> int:
728
+ root = Path(args.root).resolve()
729
+ try:
730
+ event = block_step(root, args.step, args.attempt, args.reason, agent_id=args.agent)
731
+ except ValueError as exc:
732
+ print(str(exc), file=sys.stderr)
733
+ return 2
734
+ if args.json:
735
+ print(json.dumps(event, indent=2, sort_keys=True))
736
+ else:
737
+ print(f"blocked {event['step_id']} {event['attempt_id']}")
738
+ return 0
739
+
740
+
741
+ def command_fail_step(args: argparse.Namespace) -> int:
742
+ root = Path(args.root).resolve()
743
+ try:
744
+ event = fail_step(root, args.step, args.attempt, args.reason, agent_id=args.agent)
745
+ except ValueError as exc:
746
+ print(str(exc), file=sys.stderr)
747
+ return 2
748
+ if args.json:
749
+ print(json.dumps(event, indent=2, sort_keys=True))
750
+ else:
751
+ print(f"failed {event['step_id']} {event['attempt_id']}")
752
+ return 0
753
+
754
+
755
+ def command_reclaim_step(args: argparse.Namespace) -> int:
756
+ root = Path(args.root).resolve()
757
+ plan = _load_root_plan(root)
758
+ try:
759
+ event = reclaim_step(
760
+ root, plan, args.step, args.agent, args.reason, args.lease_minutes
761
+ )
762
+ except ValueError as exc:
763
+ print(str(exc), file=sys.stderr)
764
+ return 2
765
+ if args.json:
766
+ print(json.dumps(event, indent=2, sort_keys=True))
767
+ else:
768
+ print(f"reclaimed {event['step_id']} as {event['attempt_id']}")
769
+ return 0
770
+
771
+
772
+ def command_renew_lease(args: argparse.Namespace) -> int:
773
+ root = Path(args.root).resolve()
774
+ try:
775
+ event = renew_lease(root, args.step, args.attempt, args.agent, args.minutes)
776
+ except ValueError as exc:
777
+ print(str(exc), file=sys.stderr)
778
+ return 2
779
+ if args.json:
780
+ print(json.dumps(event, indent=2, sort_keys=True))
781
+ else:
782
+ print(f"renewed {event['step_id']} {event['attempt_id']} "
783
+ f"until {event['lease_expires_at']}")
784
+ return 0
785
+
786
+
787
+ def _normalize_remainder(command: List[str]) -> List[str]:
788
+ if command and command[0] == "--":
789
+ return command[1:]
790
+ return command
791
+
792
+
793
+ def command_run(args: argparse.Namespace) -> int:
794
+ root = Path(args.root).resolve()
795
+ plan = _load_root_plan(root)
796
+ command = _normalize_remainder(args.command)
797
+ if not command:
798
+ print("run requires a command after --", file=sys.stderr)
799
+ return 2
800
+ confirmed = bool(getattr(args, "confirm_risk", False)) or (
801
+ os.environ.get("AGENTFLOW_CONFIRM_RISK") == "1"
802
+ )
803
+ source = "cli" if getattr(args, "confirm_risk", False) else ("env" if confirmed else None)
804
+ try:
805
+ receipt = run_command(
806
+ root, plan, args.step, args.attempt, command,
807
+ gate=args.gate, confirmed=confirmed, confirmation_source=source,
808
+ agent_id=args.agent,
809
+ )
810
+ except ValueError as exc:
811
+ print(str(exc), file=sys.stderr)
812
+ return 2
813
+ if receipt.get("decision") == "blocked":
814
+ categories = sorted({f["category"] for f in receipt["risk"]["findings"]})
815
+ risk_policy = receipt.get("risk_policy")
816
+ message = (
817
+ "blocked high-risk command ("
818
+ + ", ".join(categories)
819
+ + f"); risk_policy={risk_policy} refused execution."
820
+ )
821
+ if risk_policy == "require-confirmation":
822
+ message += " Re-run with --confirm-risk to override."
823
+ print(message, file=sys.stderr)
824
+ if args.json:
825
+ print(json.dumps(receipt, indent=2, sort_keys=True))
826
+ return 2
827
+ if receipt.get("decision") == "timeout":
828
+ timeout_seconds = receipt.get("timeout_seconds")
829
+ print(f"command timed out after {timeout_seconds} seconds", file=sys.stderr)
830
+ if args.json:
831
+ print(json.dumps(receipt, indent=2, sort_keys=True))
832
+ return 124
833
+ if args.json:
834
+ print(json.dumps(receipt, indent=2, sort_keys=True))
835
+ else:
836
+ print(f"recorded {receipt['id']} exit {receipt['exit_code']}")
837
+ return int(receipt["exit_code"])
838
+
839
+
840
+ def command_record_command(args: argparse.Namespace) -> int:
841
+ root = Path(args.root).resolve()
842
+ plan = _load_root_plan(root)
843
+ command = _normalize_remainder(args.command)
844
+ if not command:
845
+ print("record-command requires a command after --", file=sys.stderr)
846
+ return 2
847
+ try:
848
+ receipt = record_command(
849
+ root,
850
+ args.step,
851
+ args.attempt,
852
+ command,
853
+ args.exit_code,
854
+ gate=args.gate,
855
+ plan=plan,
856
+ agent_id=args.agent,
857
+ )
858
+ except ValueError as exc:
859
+ print(str(exc), file=sys.stderr)
860
+ return 2
861
+ if args.json:
862
+ print(json.dumps(receipt, indent=2, sort_keys=True))
863
+ else:
864
+ print(f"recorded {receipt['id']} attested exit {receipt['exit_code']}")
865
+ return 0
866
+
867
+
868
+ def command_record_file_change(args: argparse.Namespace) -> int:
869
+ root = Path(args.root).resolve()
870
+ plan = _load_root_plan(root)
871
+ receipts = []
872
+ try:
873
+ for path in args.path:
874
+ receipts.append(
875
+ record_file_change(root, plan, args.step, args.attempt, path, agent_id=args.agent)
876
+ )
877
+ except ValueError as exc:
878
+ print(str(exc), file=sys.stderr)
879
+ return 1
880
+ if args.json:
881
+ print(json.dumps(receipts, indent=2, sort_keys=True))
882
+ else:
883
+ for receipt in receipts:
884
+ print(f"recorded {receipt['id']} {receipt['path']}")
885
+ return 0
886
+
887
+
888
+ def command_verify_step(args: argparse.Namespace) -> int:
889
+ root = Path(args.root).resolve()
890
+ plan = _load_root_plan(root)
891
+ try:
892
+ resolved_attempt = require_lifecycle_owner(
893
+ root, args.step, args.attempt, args.agent, action="verify"
894
+ )
895
+ result = verify_step(
896
+ root,
897
+ plan,
898
+ args.step,
899
+ resolved_attempt,
900
+ strict_mode(args.strict),
901
+ args.replay,
902
+ )
903
+ if result["status"] != "failed":
904
+ mark_step_verified(
905
+ root,
906
+ args.step,
907
+ resolved_attempt,
908
+ [*result["errors"], *result["warnings"]],
909
+ agent_id=args.agent,
910
+ )
911
+ except ValueError as exc:
912
+ print(str(exc), file=sys.stderr)
913
+ return 2
914
+ if args.json:
915
+ print(json.dumps(result, indent=2, sort_keys=True))
916
+ else:
917
+ print(f"verify-step {result['status']}")
918
+ for finding in [*result["errors"], *result["warnings"]]:
919
+ print(f"{finding['severity']}: {finding['message']}")
920
+ return 1 if result["status"] == "failed" else 0
921
+
922
+
923
+ def command_verify_run(args: argparse.Namespace) -> int:
924
+ root = Path(args.root).resolve()
925
+ plan_path = Path(args.plan)
926
+ if not plan_path.is_absolute():
927
+ plan_path = root / plan_path
928
+ try:
929
+ plan = _load_plan(plan_path)
930
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
931
+ print(f"invalid plan: {exc}", file=sys.stderr)
932
+ return 1
933
+ result = verify_run(root, plan, strict_mode(args.strict), record=args.record)
934
+ if args.json:
935
+ print(json.dumps(result, indent=2, sort_keys=True))
936
+ else:
937
+ print(f"verify-run {result['status']}")
938
+ for finding in [*result["errors"], *result["warnings"]]:
939
+ print(f"{finding['severity']}: {finding['message']}")
940
+ return 1 if result["status"] == "failed" else 0
941
+
942
+
943
+ def command_aggregate_ledgers(args: argparse.Namespace) -> int:
944
+ inputs = args.input or []
945
+ source_ids = args.source_id or []
946
+ if not inputs:
947
+ print("aggregate-ledgers: at least one --input with --source-id is required", file=sys.stderr)
948
+ return 2
949
+ sources, errors = parse_sources(inputs, source_ids, args.label)
950
+ if errors:
951
+ for err in errors:
952
+ print(f"aggregate-ledgers: {err}", file=sys.stderr)
953
+ return 2
954
+ output_root = Path(args.output).resolve()
955
+ if not output_root.is_dir():
956
+ print(
957
+ f"aggregate-ledgers: --output {output_root} is not a directory "
958
+ "(it must already contain the merged source tree)",
959
+ file=sys.stderr,
960
+ )
961
+ return 2
962
+ if args.dry_run:
963
+ report = analyze(sources, output_root, base_ref=args.base)
964
+ if args.json:
965
+ print(json.dumps(report, indent=2, sort_keys=True))
966
+ else:
967
+ print(
968
+ f"aggregate-ledgers dry-run: {report['status']} "
969
+ f"({len(sources)} sources, {len(report['collisions'])} collisions)"
970
+ )
971
+ for col in report["collisions"]:
972
+ detail = {k: v for k, v in col.items() if k != "kind"}
973
+ print(f"collision: {col['kind']} {json.dumps(detail, sort_keys=True)}")
974
+ return 1 if report["status"] == "collision" else 0
975
+
976
+ result = write_canonical(sources, output_root, base_ref=args.base)
977
+ if args.json:
978
+ print(json.dumps(result, indent=2, sort_keys=True))
979
+ elif result["status"] == "collision":
980
+ print(
981
+ f"aggregate-ledgers: {result['status']} "
982
+ f"({len(sources)} sources, {len(result['collisions'])} collisions); nothing written"
983
+ )
984
+ for col in result["collisions"]:
985
+ detail = {k: v for k, v in col.items() if k != "kind"}
986
+ print(f"collision: {col['kind']} {json.dumps(detail, sort_keys=True)}")
987
+ else:
988
+ print(f"aggregate-ledgers: wrote canonical .agent/ into {output_root} ({len(sources)} sources)")
989
+ return 1 if result["status"] == "collision" else 0
990
+
991
+
992
+ def command_detect_stuck(args: argparse.Namespace) -> int:
993
+ root = Path(args.root).resolve()
994
+ plan_path = Path(args.plan)
995
+ if not plan_path.is_absolute():
996
+ plan_path = root / plan_path
997
+ try:
998
+ plan = _load_plan(plan_path)
999
+ except (FileNotFoundError, json.JSONDecodeError):
1000
+ plan = None
1001
+ thresholds = Thresholds(
1002
+ min_command_failures=args.min_command_failures,
1003
+ min_verify_failures=args.min_verify_failures,
1004
+ min_cycle_repeats=args.min_cycle_repeats,
1005
+ )
1006
+ report = detect_stuck(root, plan, thresholds)
1007
+ if args.json:
1008
+ print(json.dumps(report, indent=2, sort_keys=True))
1009
+ else:
1010
+ print(f"detect-stuck {report['status']}")
1011
+ for finding in report["findings"]:
1012
+ location = f"{finding['step_id']}/{finding['attempt_id']}"
1013
+ print(f"warning [{location}]: {finding['message']}")
1014
+ print(f" -> {finding['suggested_action']}")
1015
+ return 1 if (args.strict and report["findings"]) else 0
1016
+
1017
+
1018
+ def command_export_handoff(args: argparse.Namespace) -> int:
1019
+ root = Path(args.root).resolve()
1020
+ plan = _load_root_plan(root)
1021
+ try:
1022
+ rendered = export_handoff(plan, args.step, args.format)
1023
+ except ValueError as exc:
1024
+ print(str(exc), file=sys.stderr)
1025
+ return 1
1026
+ if args.output:
1027
+ output = Path(args.output)
1028
+ if not output.is_absolute():
1029
+ output = root / output
1030
+ else:
1031
+ suffix = "json" if args.format == "json" else "md"
1032
+ output = root / ".agent/handoffs" / f"{args.step}.{suffix}"
1033
+ output.parent.mkdir(parents=True, exist_ok=True)
1034
+ if args.format == "json":
1035
+ output.write_text(json.dumps(rendered, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1036
+ else:
1037
+ output.write_text(str(rendered), encoding="utf-8")
1038
+ print(f"created {output.relative_to(root)}")
1039
+ return 0
1040
+
1041
+
1042
+ def command_lint_handoff(args: argparse.Namespace) -> int:
1043
+ root = Path(args.root).resolve()
1044
+ path = Path(args.input)
1045
+ if not path.is_absolute():
1046
+ path = root / path
1047
+ findings = lint_handoff_text(path.read_text(encoding="utf-8"))
1048
+ result = {"findings": findings}
1049
+ if args.json:
1050
+ print(json.dumps(result, indent=2, sort_keys=True))
1051
+ else:
1052
+ for finding in findings:
1053
+ print(f"{finding['severity']}: {finding['message']}")
1054
+ if not findings:
1055
+ print("handoff valid")
1056
+ return 1 if findings else 0
1057
+
1058
+
1059
+ def command_replay_gates(args: argparse.Namespace) -> int:
1060
+ root = Path(args.root).resolve()
1061
+ plan = _load_root_plan(root)
1062
+ result = replay_gates(root, plan, step_id=args.step, record=args.record)
1063
+ if args.json:
1064
+ print(json.dumps(result, indent=2, sort_keys=True))
1065
+ else:
1066
+ print(f"replay-gates {result['status']}")
1067
+ for finding in [*result["errors"], *result["warnings"]]:
1068
+ print(f"{finding['severity']}: {finding['message']}")
1069
+ return 1 if result["status"] == "failed" else 0
1070
+
1071
+
1072
+ def command_runtime_status(args: argparse.Namespace) -> int:
1073
+ root = Path(args.root).resolve()
1074
+ config_path = Path(args.config)
1075
+ if not config_path.is_absolute():
1076
+ config_path = root / config_path
1077
+ if not config_path.exists():
1078
+ message = f"runtime config missing: {config_path.relative_to(root)}"
1079
+ if args.json:
1080
+ print(
1081
+ json.dumps(
1082
+ {
1083
+ "status": "missing",
1084
+ "findings": [{"severity": "warning", "message": message}],
1085
+ },
1086
+ indent=2,
1087
+ sort_keys=True,
1088
+ )
1089
+ )
1090
+ else:
1091
+ print(message)
1092
+ return 1 if strict_mode(args.strict) else 0
1093
+
1094
+ snapshot = build_runtime_status(
1095
+ config_path,
1096
+ record_id=f"R{len(read_jsonl(root / '.agent/runtime-snapshots.jsonl')) + 1}",
1097
+ allow_probe=args.probe,
1098
+ )
1099
+ if args.record:
1100
+ append_jsonl(root / ".agent/runtime-snapshots.jsonl", snapshot)
1101
+ if args.json:
1102
+ print(json.dumps(snapshot, indent=2, sort_keys=True))
1103
+ else:
1104
+ for runtime in snapshot["runtimes"]:
1105
+ print(f"{runtime['id']} {runtime['status']}")
1106
+ for server in snapshot.get("mcp_servers", []):
1107
+ print(f"mcp {server['id']} {server['status']}")
1108
+ for finding in snapshot.get("findings", []):
1109
+ print(f"{finding['severity']}: {finding['message']}")
1110
+ has_error = any(item.get("severity") == "error" for item in snapshot.get("findings", []))
1111
+ has_warning = any(item.get("severity") == "warning" for item in snapshot.get("findings", []))
1112
+ return 1 if has_error or (strict_mode(args.strict) and has_warning) else 0
1113
+
1114
+
1115
+ def _markdown_list(items: List[Any], empty: str = "None.") -> str:
1116
+ if not items:
1117
+ return empty
1118
+ return "\n".join(f"- {item}" for item in items)
1119
+
1120
+
1121
+ def command_build_proof(args: argparse.Namespace) -> int:
1122
+ root = Path(args.root).resolve()
1123
+ plan_path = Path(args.plan)
1124
+ if not plan_path.is_absolute():
1125
+ plan_path = root / plan_path
1126
+ output = Path(args.output)
1127
+ if not output.is_absolute():
1128
+ output = root / output
1129
+
1130
+ try:
1131
+ plan = _load_plan(plan_path)
1132
+ except (FileNotFoundError, json.JSONDecodeError) as exc:
1133
+ print(f"invalid plan: {exc}", file=sys.stderr)
1134
+ return 1
1135
+ traceability_errors = validate_requirement_traceability(plan)
1136
+ if traceability_errors:
1137
+ print("invalid requirement traceability", file=sys.stderr)
1138
+ for error in traceability_errors:
1139
+ print(f"- {error}", file=sys.stderr)
1140
+ return 1
1141
+ design_errors = validate_design_decision_traceability(plan)
1142
+ if design_errors:
1143
+ print("invalid design decision traceability", file=sys.stderr)
1144
+ for error in design_errors:
1145
+ print(f"- {error}", file=sys.stderr)
1146
+ return 1
1147
+
1148
+ try:
1149
+ proof = build_proof(root, plan_path, strict_mode(args.strict))
1150
+ except InvalidWorkingState as exc:
1151
+ # #28: nothing is written, so a rejected build leaves any existing proof
1152
+ # in place rather than replacing it with one built from invalid input.
1153
+ print(f"invalid working state: {exc.subject}:", file=sys.stderr)
1154
+ for error in exc.errors:
1155
+ print(f"- {error}", file=sys.stderr)
1156
+ return 1
1157
+ except ValueError as exc:
1158
+ print(f"invalid ledger: {exc}", file=sys.stderr)
1159
+ return 1
1160
+ write_proof_metadata(root, proof)
1161
+ drift_path = root / ".agent/drift-report.json"
1162
+ drift = read_json(drift_path) if drift_path.exists() else {"status": "missing", "notes": []}
1163
+ try:
1164
+ evidence = read_jsonl(root / ".agent/evidence.jsonl")
1165
+ except ValueError as exc:
1166
+ print(f"invalid ledger: {exc}", file=sys.stderr)
1167
+ return 1
1168
+ output.parent.mkdir(parents=True, exist_ok=True)
1169
+ output.write_text(render_markdown(plan, proof, evidence, drift), encoding="utf-8")
1170
+ print(f"created {output.relative_to(root)}")
1171
+ print("created .agent/proof-pack.json")
1172
+
1173
+ has_failed = any(check.get("status") == "failed" for check in proof["checks"])
1174
+ has_warning = any(check.get("status") == "warning" for check in proof["checks"])
1175
+ return 1 if has_failed or (strict_mode(args.strict) and has_warning) else 0
1176
+
1177
+
1178
+ def command_verify_proof(args: argparse.Namespace) -> int:
1179
+ root = Path(args.root).resolve()
1180
+ proof_path = Path(args.proof)
1181
+ if not proof_path.is_absolute():
1182
+ proof_path = root / proof_path
1183
+ if not proof_path.exists():
1184
+ print(f"proof metadata missing: {proof_path.relative_to(root)}")
1185
+ return 1
1186
+ findings = verify_proof(root, proof_path, replay=args.replay, strict=strict_mode(args.strict))
1187
+ for finding in findings:
1188
+ print(f"{finding['severity']}: {finding['message']}")
1189
+ if any(finding.get("severity") == "error" for finding in findings):
1190
+ return 1
1191
+ print("proof verified")
1192
+ return 0
1193
+
1194
+
1195
+ def command_view_proof(args: argparse.Namespace) -> int:
1196
+ if not args.html:
1197
+ print("only --html output is supported; pass --html", file=sys.stderr)
1198
+ return 2
1199
+ root = Path(args.root).resolve()
1200
+ proof_path = Path(args.proof)
1201
+ if not proof_path.is_absolute():
1202
+ proof_path = root / proof_path
1203
+ output = Path(args.output)
1204
+ if not output.is_absolute():
1205
+ output = root / output
1206
+ try:
1207
+ model = collect_view_model(root, proof_path, output)
1208
+ except ValueError as exc:
1209
+ print(str(exc), file=sys.stderr)
1210
+ return 1
1211
+ output.parent.mkdir(parents=True, exist_ok=True)
1212
+ output.write_text(render_html(model), encoding="utf-8")
1213
+ print(f"created {output.relative_to(root) if output.is_relative_to(root) else output}")
1214
+ return 0
1215
+
1216
+
1217
+ def _event_detail(event: Dict[str, Any]) -> str:
1218
+ data = event["data"]
1219
+ event_type = event["type"]
1220
+ if event_type == "command.recorded":
1221
+ command = data.get("command")
1222
+ rendered = " ".join(command) if isinstance(command, list) else ""
1223
+ return f"exit={data.get('exit_code')} {rendered}".strip()
1224
+ if event_type == "file.changed":
1225
+ return f"{data.get('change_kind')} {data.get('path')}".strip()
1226
+ if event_type == "verification.run":
1227
+ return f"{data.get('scope')} {data.get('status')}".strip()
1228
+ return str(data.get("reason") or data.get("agent_id") or "")
1229
+
1230
+
1231
+ def command_events(args: argparse.Namespace) -> int:
1232
+ root = Path(args.root).resolve()
1233
+ events = project_events(root)
1234
+ if args.since is not None:
1235
+ if not valid_since(args.since):
1236
+ print(f"invalid --since timestamp: {args.since}", file=sys.stderr)
1237
+ return 2
1238
+ events = filter_events_since(events, args.since)
1239
+ if args.jsonl:
1240
+ for event in events:
1241
+ print(json.dumps(event, sort_keys=True))
1242
+ elif args.json:
1243
+ print(json.dumps(events, indent=2, sort_keys=True))
1244
+ else:
1245
+ for event in events:
1246
+ step = event.get("step_id") or "-"
1247
+ attempt = event.get("attempt_id") or "-"
1248
+ line = f"{event['timestamp']} {event['type']} {step}/{attempt} {_event_detail(event)}"
1249
+ print(line.rstrip())
1250
+ return 0
1251
+
1252
+
1253
+ def command_next_action(args: argparse.Namespace) -> int:
1254
+ root = Path(args.root).resolve()
1255
+ action = porcelain.next_action(
1256
+ root,
1257
+ strict_mode(args.strict),
1258
+ agent_id=args.agent,
1259
+ )
1260
+ if args.json:
1261
+ print(json.dumps(action.to_dict(), indent=2, sort_keys=True))
1262
+ else:
1263
+ print(f"next-action {action.state}: {action.reason}")
1264
+ if action.command:
1265
+ print(action.command)
1266
+ for line in action.diagnostics:
1267
+ print(f" {line}")
1268
+ return 0
1269
+
1270
+
1271
+ def command_finish_step(args: argparse.Namespace) -> int:
1272
+ root = Path(args.root).resolve()
1273
+ plan = _load_root_plan(root)
1274
+ try:
1275
+ result = porcelain.finish_step(
1276
+ root, plan, args.step, args.attempt,
1277
+ strict_mode(args.strict), args.replay,
1278
+ agent_id=args.agent,
1279
+ )
1280
+ except ValueError as exc:
1281
+ print(str(exc), file=sys.stderr)
1282
+ return 2
1283
+ if args.json:
1284
+ print(json.dumps(result, indent=2, sort_keys=True))
1285
+ else:
1286
+ print(f"finish-step {result['verification_status']} "
1287
+ f"verified={result['verified']} completed={result['completed']}")
1288
+ for line in result["diagnostics"]:
1289
+ print(f" {line}")
1290
+ return 0 if result["completed"] else 1
1291
+
1292
+
1293
+ def command_finish_run(args: argparse.Namespace) -> int:
1294
+ root = Path(args.root).resolve()
1295
+ plan_path = Path(args.plan)
1296
+ if not plan_path.is_absolute():
1297
+ plan_path = root / plan_path
1298
+ # finish_run captures each delegated gate's output internally, so stdout
1299
+ # stays clean for --json and the failing gate's detail is surfaced in
1300
+ # result["diagnostics"] rather than swallowed.
1301
+ result = porcelain.finish_run(root, plan_path, strict_mode(args.strict))
1302
+ if args.json:
1303
+ print(json.dumps(result, indent=2, sort_keys=True))
1304
+ else:
1305
+ for gate in result["gates"]:
1306
+ print(f"{gate['name']} {gate['status']}")
1307
+ if not result["ok"]:
1308
+ print(f"stopped at {result['stopped_at']}")
1309
+ for line in result["diagnostics"]:
1310
+ print(f" {line}")
1311
+ return 0 if result["ok"] else 1
1312
+
1313
+
1314
+ def command_status(args: argparse.Namespace) -> int:
1315
+ root = Path(args.root).resolve()
1316
+ plan_path = root / ".agent/plan.lock.json"
1317
+ drift_path = root / ".agent/drift-report.json"
1318
+ evidence_path = root / ".agent/evidence.jsonl"
1319
+ failures_path = root / ".agent/failures.jsonl"
1320
+ runtime_config_path = root / ".agent/runtime.config.json"
1321
+ runtime_snapshots_path = root / ".agent/runtime-snapshots.jsonl"
1322
+ execution_contract_path = root / ".agent/execution.contract.json"
1323
+ step_runs_path = root / ".agent/step-runs.jsonl"
1324
+ command_receipts_path = root / ".agent/command-receipts.jsonl"
1325
+ file_receipts_path = root / ".agent/file-receipts.jsonl"
1326
+
1327
+ print(f"agentflow {__version__}")
1328
+ print(f"root {root}")
1329
+ if not plan_path.exists():
1330
+ print("status uninitialized")
1331
+ print("missing .agent/plan.lock.json")
1332
+ return 1
1333
+
1334
+ try:
1335
+ plan = read_json(plan_path)
1336
+ except ValueError as exc:
1337
+ # Covers malformed JSON (JSONDecodeError is a ValueError) and
1338
+ # schema_version policy rejections: status must report, never traceback.
1339
+ print(f"status invalid: {exc}")
1340
+ return 1
1341
+
1342
+ problems = False
1343
+
1344
+ def count(path: Path) -> str:
1345
+ nonlocal problems
1346
+ if not path.exists():
1347
+ return "0"
1348
+ try:
1349
+ return str(len(read_jsonl(path)))
1350
+ except ValueError as exc:
1351
+ problems = True
1352
+ return f"unreadable: {exc}"
1353
+
1354
+ print(f"objective {plan.get('objective') or 'TBD'}")
1355
+ print(f"locked {bool(plan.get('locked'))}")
1356
+ print(f"steps {len(plan.get('steps', []))}")
1357
+ print(f"evidence {count(evidence_path)}")
1358
+ print(f"failures {count(failures_path)}")
1359
+ if drift_path.exists():
1360
+ try:
1361
+ drift = read_json(drift_path)
1362
+ print(f"drift {drift.get('status', 'unknown')}")
1363
+ except ValueError as exc:
1364
+ problems = True
1365
+ print(f"drift unreadable: {exc}")
1366
+ else:
1367
+ print("drift missing")
1368
+ print(f"runtime config {'present' if runtime_config_path.exists() else 'missing'}")
1369
+ print(f"runtime snapshots {count(runtime_snapshots_path)}")
1370
+ print(f"execution contract {'present' if execution_contract_path.exists() else 'missing'}")
1371
+ print(f"step runs {count(step_runs_path)}")
1372
+ print(f"command receipts {count(command_receipts_path)}")
1373
+ print(f"file receipts {count(file_receipts_path)}")
1374
+ return 1 if problems else 0
1375
+
1376
+
1377
+ def _load_brief_from_path(path: Path) -> Any:
1378
+ # Return the parsed JSON as-is; validate_brief is the single source of truth
1379
+ # for the "must be an object" rule, so a non-dict body surfaces as a
1380
+ # validation_error rather than being mislabeled a JSON-parse failure.
1381
+ if not path.exists():
1382
+ raise FileNotFoundError(f"brief file not found: {path}")
1383
+ try:
1384
+ return read_json(path)
1385
+ except json.JSONDecodeError as exc:
1386
+ raise ValueError(_json_decode_message(str(path), exc)) from exc
1387
+
1388
+
1389
+ def _load_brief_from_stdin() -> Any:
1390
+ try:
1391
+ return json.loads(sys.stdin.read())
1392
+ except json.JSONDecodeError as exc:
1393
+ raise ValueError(_json_decode_message("stdin", exc)) from exc
1394
+
1395
+
1396
+ def _recommend_fail(code: str, message: str, *, json_mode: bool, exit_code: int) -> int:
1397
+ if json_mode:
1398
+ _print_plan_result("invalid", errors=[_plan_diagnostic(code, message)])
1399
+ else:
1400
+ print(message if exit_code == 2 else f"invalid brief: {message}", file=sys.stderr)
1401
+ return exit_code
1402
+
1403
+
1404
+ def command_recommend_workflow(args: argparse.Namespace) -> int:
1405
+ if args.brief and args.stdin:
1406
+ return _recommend_fail(
1407
+ "invalid_arguments", "--brief and --stdin are mutually exclusive",
1408
+ json_mode=args.json, exit_code=2,
1409
+ )
1410
+ if not args.brief and not args.stdin:
1411
+ return _recommend_fail(
1412
+ "invalid_arguments", "one of --brief or --stdin is required",
1413
+ json_mode=args.json, exit_code=2,
1414
+ )
1415
+
1416
+ try:
1417
+ brief = (
1418
+ _load_brief_from_stdin()
1419
+ if args.stdin
1420
+ else _load_brief_from_path(Path(args.brief).resolve())
1421
+ )
1422
+ except FileNotFoundError as exc:
1423
+ return _recommend_fail("input_not_found", str(exc), json_mode=args.json, exit_code=1)
1424
+ except ValueError as exc:
1425
+ code = "invalid_json" if isinstance(exc.__cause__, json.JSONDecodeError) else "invalid_brief_json"
1426
+ return _recommend_fail(code, str(exc), json_mode=args.json, exit_code=1)
1427
+
1428
+ errors = validate_brief(brief)
1429
+ if errors:
1430
+ if args.json:
1431
+ _print_plan_result(
1432
+ "invalid",
1433
+ errors=[_plan_diagnostic("validation_error", error) for error in errors],
1434
+ )
1435
+ else:
1436
+ print("brief invalid")
1437
+ for error in errors:
1438
+ print(f"- {error}")
1439
+ return 1
1440
+
1441
+ try:
1442
+ report = recommend_workflow(
1443
+ brief, selected_profile=args.selected_profile, reason=args.reason
1444
+ )
1445
+ except RecommendError as exc:
1446
+ return _recommend_fail(exc.code, str(exc), json_mode=args.json, exit_code=2)
1447
+
1448
+ if args.json:
1449
+ print(json.dumps(report, indent=2, sort_keys=True))
1450
+ else:
1451
+ print_lines(render_recommendation(report))
1452
+ return 0
1453
+
1454
+
1455
+ def _draft_fail(code: str, message: str, *, json_mode: bool, exit_code: int) -> int:
1456
+ if json_mode:
1457
+ _print_plan_result("invalid", errors=[_plan_diagnostic(code, message)])
1458
+ else:
1459
+ print(message, file=sys.stderr)
1460
+ return exit_code
1461
+
1462
+
1463
+ def _existing_plan_is_locked(path: Path) -> bool:
1464
+ # A malformed/unreadable existing plan reports "not locked" and falls through
1465
+ # to the normal overwrite guard rather than masking the real problem.
1466
+ try:
1467
+ plan = read_json(path)
1468
+ return isinstance(plan, dict) and plan.get("locked") is True
1469
+ except (ValueError, OSError):
1470
+ return False
1471
+
1472
+
1473
+ def _print_draft_warnings(warnings: List[Dict[str, str]]) -> None:
1474
+ # Non-fatal diagnostics (e.g. #89 greenfield candidate_file_missing) go to
1475
+ # stderr so machine consumers reading stdout JSON stay unaffected.
1476
+ for warning in warnings:
1477
+ print(f"warning [{warning['code']}]: {warning['message']}", file=sys.stderr)
1478
+
1479
+
1480
+ def _render_draft_summary(payload: Dict[str, Any]) -> List[str]:
1481
+ recommended = payload["recommended"]
1482
+ selected = payload["selected"]
1483
+ plan = payload["plan_candidate"]
1484
+ return [
1485
+ f"drafted plan candidate from {selected['pack']}/{selected['profile']}",
1486
+ f"recommended {recommended['pack']}/{recommended['profile']}; "
1487
+ f"selected {selected['pack']}/{selected['profile']} ({payload['selection_mode']})",
1488
+ f"steps: {len(plan['steps'])}",
1489
+ f"validation gates: {', '.join(plan['validation_gates'])}",
1490
+ "(not written; pass --write to materialize "
1491
+ ".agent/plan.lock.json and .agent/workflow.contract.json)",
1492
+ ]
1493
+
1494
+
1495
+ def command_draft_plan(args: argparse.Namespace) -> int:
1496
+ if args.brief and args.stdin:
1497
+ return _draft_fail(
1498
+ "invalid_arguments", "--brief and --stdin are mutually exclusive",
1499
+ json_mode=args.json, exit_code=2,
1500
+ )
1501
+ if not args.brief and not args.stdin:
1502
+ return _draft_fail(
1503
+ "invalid_arguments", "one of --brief or --stdin is required",
1504
+ json_mode=args.json, exit_code=2,
1505
+ )
1506
+
1507
+ root = Path(args.root).resolve()
1508
+
1509
+ try:
1510
+ brief = (
1511
+ _load_brief_from_stdin()
1512
+ if args.stdin
1513
+ else _load_brief_from_path(Path(args.brief).resolve())
1514
+ )
1515
+ except FileNotFoundError as exc:
1516
+ return _draft_fail("input_not_found", str(exc), json_mode=args.json, exit_code=1)
1517
+ except ValueError as exc:
1518
+ code = "invalid_json" if isinstance(exc.__cause__, json.JSONDecodeError) else "invalid_brief_json"
1519
+ return _draft_fail(code, str(exc), json_mode=args.json, exit_code=1)
1520
+
1521
+ try:
1522
+ pack = load_pack(Path(args.workflow))
1523
+ except PackError as exc:
1524
+ return _draft_fail("invalid_pack", str(exc), json_mode=args.json, exit_code=1)
1525
+
1526
+ try:
1527
+ result = compile_draft_plan(
1528
+ brief,
1529
+ pack.manifest,
1530
+ objective=args.objective or "",
1531
+ root=root,
1532
+ profile_id=args.profile,
1533
+ reason=args.reason,
1534
+ allow_missing_candidates=args.allow_missing_candidates,
1535
+ )
1536
+ except DraftPlanError as exc:
1537
+ return _draft_fail(exc.code, str(exc), json_mode=args.json, exit_code=1)
1538
+
1539
+ plan = result["plan"]
1540
+ profile = result["profile"]
1541
+ report = result["report"]
1542
+ mode = result["selection_mode"]
1543
+ warnings = result["warnings"]
1544
+ reason_text = selection_reason(report, profile, mode, args.reason)
1545
+ contract = profile_to_contract(pack.manifest, profile["id"], "draft-plan", reason_text)
1546
+ contract["validation_policy"]["required_gates"] = list(plan["validation_gates"])
1547
+ # Validate the contract before any write so a malformed custom pack fails
1548
+ # closed with a diagnostic instead of half-materializing .agent/.
1549
+ contract_errors = validate_workflow_contract(contract)
1550
+ if contract_errors:
1551
+ return _draft_fail(
1552
+ "contract_invalid", "; ".join(contract_errors), json_mode=args.json, exit_code=1
1553
+ )
1554
+ recommended = report["recommended"]
1555
+ selected = {"pack": pack.manifest["id"], "profile": profile["id"]}
1556
+
1557
+ if args.write:
1558
+ plan_path = root / ".agent/plan.lock.json"
1559
+ contract_path = root / WORKFLOW_CONTRACT_PATH
1560
+ # A locked plan is sacred: lock-plan is the only authority for it, so
1561
+ # --force overrides an unlocked draft but never a locked plan.
1562
+ if plan_path.exists() and _existing_plan_is_locked(plan_path):
1563
+ return _draft_fail(
1564
+ "locked_plan_exists",
1565
+ "refusing to overwrite a locked .agent/plan.lock.json; "
1566
+ "unlock or remove it first",
1567
+ json_mode=args.json, exit_code=1,
1568
+ )
1569
+ if (plan_path.exists() or contract_path.exists()) and not args.force:
1570
+ return _draft_fail(
1571
+ "artifact_exists",
1572
+ "refusing to overwrite existing .agent/plan.lock.json or "
1573
+ ".agent/workflow.contract.json (use --force)",
1574
+ json_mode=args.json, exit_code=1,
1575
+ )
1576
+ write_json(plan_path, plan)
1577
+ write_workflow_contract(root, contract)
1578
+ if args.json:
1579
+ print(json.dumps({
1580
+ "schema_version": DRAFT_PLAN_SCHEMA_VERSION,
1581
+ "status": "drafted",
1582
+ "path": str(plan_path),
1583
+ "contract_path": str(contract_path),
1584
+ "recommended": recommended,
1585
+ "selected": selected,
1586
+ "selection_mode": mode,
1587
+ "warnings": warnings,
1588
+ "errors": [],
1589
+ }, indent=2, sort_keys=True))
1590
+ else:
1591
+ print(f"drafted .agent/plan.lock.json from {selected['pack']}/{selected['profile']}")
1592
+ print(f"wrote {WORKFLOW_CONTRACT_PATH}")
1593
+ _print_draft_warnings(warnings)
1594
+ return 0
1595
+
1596
+ payload = {
1597
+ "schema_version": DRAFT_PLAN_SCHEMA_VERSION,
1598
+ "status": "draft",
1599
+ "plan_candidate": plan,
1600
+ "workflow_contract": contract,
1601
+ "recommended": recommended,
1602
+ "selected": selected,
1603
+ "selection_mode": mode,
1604
+ "warnings": warnings,
1605
+ }
1606
+ if args.json:
1607
+ print(json.dumps(payload, indent=2, sort_keys=True))
1608
+ else:
1609
+ print_lines(_render_draft_summary(payload))
1610
+ _print_draft_warnings(warnings)
1611
+ return 0
1612
+
1613
+
1614
+ def build_parser() -> argparse.ArgumentParser:
1615
+ parser = argparse.ArgumentParser(prog="agentflow")
1616
+ parser.add_argument("--version", action="version", version=f"agentflow {__version__}")
1617
+ subparsers = parser.add_subparsers(dest="command", required=True)
1618
+
1619
+ init = subparsers.add_parser("init", help="create .agent scaffold")
1620
+ init.add_argument("--root", default=".")
1621
+ init.add_argument("--force", action="store_true")
1622
+ init.add_argument("--pack", help="initialize from a workflow pack manifest path")
1623
+ init.add_argument("--profile", help="pack profile id (required with --pack)")
1624
+ init.add_argument(
1625
+ "--reason", help="selection reason recorded in the workflow contract"
1626
+ )
1627
+ init.set_defaults(func=command_init)
1628
+
1629
+ init_execution = subparsers.add_parser("init-execution", help="create execution contract and ledgers")
1630
+ init_execution.add_argument("--root", default=".")
1631
+ init_execution.add_argument("--force", action="store_true")
1632
+ init_execution.set_defaults(func=command_init_execution)
1633
+
1634
+ pack = subparsers.add_parser("pack", help="inspect workflow packs")
1635
+ pack_sub = pack.add_subparsers(dest="pack_command", required=True)
1636
+ pack_inspect = pack_sub.add_parser(
1637
+ "inspect", help="validate and summarize a workflow pack"
1638
+ )
1639
+ pack_inspect.add_argument("path")
1640
+ pack_inspect.add_argument(
1641
+ "--json", action="store_true", help="emit a JSON summary including manifest_sha256"
1642
+ )
1643
+ pack_inspect.set_defaults(func=command_pack_inspect)
1644
+
1645
+ doctor_parser = subparsers.add_parser("doctor", help="check shell runtime readiness")
1646
+ doctor_parser.add_argument("--root", default=".")
1647
+ doctor_parser.add_argument("--json", action="store_true")
1648
+ doctor_parser.set_defaults(func=command_doctor)
1649
+
1650
+ intake = subparsers.add_parser("intake", help="create task intake from a prompt file")
1651
+ intake.add_argument("--root", default=".")
1652
+ intake.add_argument("--from", dest="from_file", required=True)
1653
+ intake.set_defaults(func=command_intake)
1654
+
1655
+ validate = subparsers.add_parser("validate-plan", help="validate a plan lock")
1656
+ validate.add_argument("plan", nargs="?", default=".agent/plan.lock.json")
1657
+ validate.set_defaults(func=command_validate_plan)
1658
+
1659
+ lock = subparsers.add_parser("lock-plan", help="validate and lock a plan")
1660
+ lock.add_argument("plan", nargs="?", default=".agent/plan.lock.json")
1661
+ lock.add_argument("--stdin", action="store_true", help="read plan JSON from standard input")
1662
+ lock.add_argument("--from-json", dest="from_json", help="read plan JSON from this file")
1663
+ lock.add_argument("--json", action="store_true", help="emit machine-readable diagnostics")
1664
+ lock.set_defaults(func=command_lock_plan)
1665
+
1666
+ recommend_parser = subparsers.add_parser(
1667
+ "recommend-workflow", help="recommend a workflow posture from a task brief"
1668
+ )
1669
+ recommend_parser.add_argument("--brief", help="read the task brief JSON from this file")
1670
+ recommend_parser.add_argument(
1671
+ "--stdin", action="store_true", help="read the task brief JSON from standard input"
1672
+ )
1673
+ recommend_parser.add_argument(
1674
+ "--json", action="store_true", help="emit the recommendation as JSON"
1675
+ )
1676
+ recommend_parser.add_argument(
1677
+ "--selected-profile", dest="selected_profile",
1678
+ help="operator-selected profile id (override)",
1679
+ )
1680
+ recommend_parser.add_argument(
1681
+ "--reason",
1682
+ help="rationale required when --selected-profile differs from the recommendation",
1683
+ )
1684
+ recommend_parser.set_defaults(func=command_recommend_workflow)
1685
+
1686
+ draft = subparsers.add_parser(
1687
+ "draft-plan",
1688
+ help="compile a task brief and workflow pack into an unlocked draft plan",
1689
+ )
1690
+ draft.add_argument("--root", default=".")
1691
+ draft.add_argument("--brief", help="read the task brief JSON from this file")
1692
+ draft.add_argument(
1693
+ "--stdin", action="store_true", help="read the task brief JSON from standard input"
1694
+ )
1695
+ draft.add_argument(
1696
+ "--workflow", required=True,
1697
+ help="path to the workflow pack (a .agentflow-pack dir, its parent, or pack.json)",
1698
+ )
1699
+ draft.add_argument(
1700
+ "--objective", help="the plan objective: the human intent the brief does not carry",
1701
+ )
1702
+ draft.add_argument("--profile", help="explicitly select a pack profile (override)")
1703
+ draft.add_argument(
1704
+ "--reason", help="rationale required when --profile is weaker than the recommendation",
1705
+ )
1706
+ draft.add_argument(
1707
+ "--allow-missing-candidates", "--greenfield",
1708
+ dest="allow_missing_candidates", action="store_true",
1709
+ help="downgrade candidate_file_missing to a warning so a plan can be "
1710
+ "drafted for files that do not exist yet (greenfield planning)",
1711
+ )
1712
+ draft.add_argument(
1713
+ "--write", action="store_true",
1714
+ help="write .agent/plan.lock.json (unlocked) and .agent/workflow.contract.json",
1715
+ )
1716
+ draft.add_argument(
1717
+ "--force", action="store_true", help="overwrite existing plan/contract when writing",
1718
+ )
1719
+ draft.add_argument("--json", action="store_true", help="emit machine-readable JSON")
1720
+ draft.set_defaults(func=command_draft_plan)
1721
+
1722
+ workflow_contract = subparsers.add_parser(
1723
+ "workflow-contract",
1724
+ help="write or validate workflow contract artifact",
1725
+ )
1726
+ workflow_contract.add_argument("path", nargs="?", default=WORKFLOW_CONTRACT_PATH)
1727
+ workflow_contract.add_argument("--root", default=".")
1728
+ workflow_contract_mode = workflow_contract.add_mutually_exclusive_group()
1729
+ workflow_contract_mode.add_argument(
1730
+ "--from-json",
1731
+ dest="from_json",
1732
+ help=f"read a contract JSON object and write {WORKFLOW_CONTRACT_PATH}",
1733
+ )
1734
+ workflow_contract_mode.add_argument(
1735
+ "--validate",
1736
+ action="store_true",
1737
+ help="validate an existing workflow contract artifact (default)",
1738
+ )
1739
+ workflow_contract.set_defaults(func=command_workflow_contract)
1740
+
1741
+ amend = subparsers.add_parser("amend-plan", help="record a plan amendment")
1742
+ amend.add_argument("--root", default=".")
1743
+ amend.add_argument("--id", required=True)
1744
+ amend.add_argument("--reason", required=True)
1745
+ amend.add_argument("--evidence", action="append")
1746
+ amend.add_argument("--changed-scope", action="append")
1747
+ amend.add_argument("--changed-steps", action="append")
1748
+ amend.add_argument("--changed-risk")
1749
+ amend.add_argument("--requires-approval", action="store_true")
1750
+ amend.set_defaults(func=command_amend_plan)
1751
+
1752
+ evidence = subparsers.add_parser("record-evidence", help="append an evidence ledger entry")
1753
+ evidence.add_argument("--root", default=".")
1754
+ evidence.add_argument("--id", required=True)
1755
+ evidence.add_argument("--claim", required=True)
1756
+ evidence.add_argument("--source", required=True)
1757
+ evidence.add_argument("--confidence", choices=["low", "medium", "high"], default="medium")
1758
+ evidence.add_argument("--kind", choices=list(EVIDENCE_KINDS))
1759
+ evidence.add_argument("--supports", action="append")
1760
+ evidence.set_defaults(func=command_record_evidence)
1761
+
1762
+ review = subparsers.add_parser("record-review", help="record a review run from its manifest")
1763
+ review.add_argument("--root", default=".")
1764
+ review.add_argument("--manifest", dest="manifest", required=True)
1765
+ review.add_argument("--emit-evidence", action="store_true")
1766
+ review.add_argument("--json", action="store_true")
1767
+ review.set_defaults(func=command_record_review)
1768
+
1769
+ record_capability = subparsers.add_parser(
1770
+ "record-capability", help="record a used specialized capability"
1771
+ )
1772
+ record_capability.add_argument("--root", default=".")
1773
+ record_capability.add_argument("--id", required=True)
1774
+ record_capability.add_argument("--capability", required=True)
1775
+ record_capability.add_argument("--provider", required=True)
1776
+ record_capability.add_argument("--reason", required=True)
1777
+ record_capability.add_argument("--evidence", action="append")
1778
+ record_capability.set_defaults(func=command_record_capability)
1779
+
1780
+ waive_capability = subparsers.add_parser(
1781
+ "waive-capability", help="record a knowingly waived specialized capability"
1782
+ )
1783
+ waive_capability.add_argument("--root", default=".")
1784
+ waive_capability.add_argument("--id", required=True)
1785
+ waive_capability.add_argument("--capability", required=True)
1786
+ waive_capability.add_argument("--reason", required=True)
1787
+ waive_capability.add_argument("--evidence", action="append")
1788
+ waive_capability.set_defaults(func=command_waive_capability)
1789
+
1790
+ review_manifest = subparsers.add_parser(
1791
+ "review-manifest",
1792
+ help="produce review-manifest.json from a findings-final.json sidecar",
1793
+ )
1794
+ review_manifest.add_argument("--root", default=".")
1795
+ review_manifest.add_argument("--state-dir", dest="state_dir", required=True)
1796
+ review_manifest.add_argument("--branch", default=None)
1797
+ review_manifest.add_argument("--config", default="docs/ai/config.json")
1798
+ review_manifest.add_argument(
1799
+ "--findings-json",
1800
+ dest="findings_json",
1801
+ default=None,
1802
+ help="path to findings sidecar; relative to --state-dir unless absolute "
1803
+ "(default: findings-final.json)",
1804
+ )
1805
+ review_manifest.add_argument(
1806
+ "--depth-profile",
1807
+ dest="depth_profile",
1808
+ default="deep",
1809
+ choices=WORKFLOW_REVIEW_DEPTHS,
1810
+ help="review depth this recorded run satisfies; lighter depths require "
1811
+ "fewer artifacts (default: deep = full four-pass)",
1812
+ )
1813
+ review_manifest.add_argument("--write", action="store_true")
1814
+ review_manifest.add_argument("--json", action="store_true")
1815
+ review_manifest.add_argument("--fail-on-block", dest="fail_on_block", action="store_true")
1816
+ review_manifest.add_argument("--strict-exit", dest="strict_exit", action="store_true")
1817
+ review_manifest.set_defaults(func=command_review_manifest)
1818
+
1819
+ context = subparsers.add_parser("record-context", help="append a context receipt")
1820
+ context.add_argument("--root", default=".")
1821
+ context.add_argument("--id", required=True)
1822
+ context.add_argument("--source", required=True)
1823
+ context.add_argument("--reason", required=True)
1824
+ context.add_argument("--used-for", action="append", dest="used_for")
1825
+ context.add_argument("--bytes", type=int)
1826
+ context.set_defaults(func=command_record_context)
1827
+
1828
+ failure = subparsers.add_parser("record-failure", help="append a failure signature")
1829
+ failure.add_argument("--root", default=".")
1830
+ failure.add_argument("--command", required=True)
1831
+ failure.add_argument("--failure-type", required=True)
1832
+ failure.add_argument("--relevant-lines", action="append")
1833
+ failure.add_argument("--suspected-cause", required=True)
1834
+ failure.add_argument("--next-action", required=True)
1835
+ failure.set_defaults(func=command_record_failure)
1836
+
1837
+ audit = subparsers.add_parser("audit-drift", help="compare git changes to plan scope")
1838
+ audit.add_argument("--root", default=".")
1839
+ audit.add_argument("--plan", default=".agent/plan.lock.json")
1840
+ audit.set_defaults(func=command_audit_drift)
1841
+
1842
+ next_step_parser = subparsers.add_parser("next-step", help="show the next eligible plan step")
1843
+ next_step_parser.add_argument("--root", default=".")
1844
+ next_step_parser.add_argument("--json", action="store_true")
1845
+ next_step_parser.set_defaults(func=command_next_step)
1846
+
1847
+ claim = subparsers.add_parser("claim-step", help="claim a plan step")
1848
+ claim.add_argument("step")
1849
+ claim.add_argument("--root", default=".")
1850
+ claim.add_argument("--agent", required=True)
1851
+ claim.add_argument("--lease-minutes", type=int)
1852
+ claim.add_argument("--json", action="store_true")
1853
+ claim.set_defaults(func=command_claim_step)
1854
+
1855
+ amend = subparsers.add_parser(
1856
+ "amend-step", help="open an auditable amendment attempt on a completed step"
1857
+ )
1858
+ amend.add_argument("step")
1859
+ amend.add_argument("--root", default=".")
1860
+ amend.add_argument("--agent", required=True)
1861
+ amend.add_argument("--reason", required=True)
1862
+ amend.add_argument("--reason-code", choices=sorted(AMENDMENT_REASON_CODES))
1863
+ amend.add_argument("--finding", action="append", help="review-run-scoped finding ref RR-...#ID (repeatable)")
1864
+ amend.add_argument("--lease-minutes", type=int, help="lease TTL for the amendment attempt under enforce")
1865
+ amend.add_argument("--json", action="store_true")
1866
+ amend.set_defaults(func=command_amend_step)
1867
+
1868
+ complete = subparsers.add_parser("complete-step", help="complete a verified step")
1869
+ complete.add_argument("step")
1870
+ complete.add_argument("--root", default=".")
1871
+ complete.add_argument("--attempt")
1872
+ complete.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1873
+ complete.add_argument("--json", action="store_true")
1874
+ complete.set_defaults(func=command_complete_step)
1875
+
1876
+ block = subparsers.add_parser("block-step", help="mark an attempt blocked")
1877
+ block.add_argument("step")
1878
+ block.add_argument("--root", default=".")
1879
+ block.add_argument("--attempt")
1880
+ block.add_argument("--reason", required=True)
1881
+ block.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1882
+ block.add_argument("--json", action="store_true")
1883
+ block.set_defaults(func=command_block_step)
1884
+
1885
+ fail = subparsers.add_parser("fail-step", help="mark an attempt failed")
1886
+ fail.add_argument("step")
1887
+ fail.add_argument("--root", default=".")
1888
+ fail.add_argument("--attempt")
1889
+ fail.add_argument("--reason", required=True)
1890
+ fail.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"),
1891
+ help="record who forced the failure (break-glass; no owner check)")
1892
+ fail.add_argument("--json", action="store_true")
1893
+ fail.set_defaults(func=command_fail_step)
1894
+
1895
+ reclaim = subparsers.add_parser(
1896
+ "reclaim-step", help="abandon an expired attempt and open a fresh claim"
1897
+ )
1898
+ reclaim.add_argument("step")
1899
+ reclaim.add_argument("--root", default=".")
1900
+ reclaim.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"), required=False)
1901
+ reclaim.add_argument("--reason", required=True)
1902
+ reclaim.add_argument("--lease-minutes", type=int)
1903
+ reclaim.add_argument("--json", action="store_true")
1904
+ reclaim.set_defaults(func=command_reclaim_step)
1905
+
1906
+ renew = subparsers.add_parser(
1907
+ "renew-lease", help="extend an attempt's lease (owner self-recovery)"
1908
+ )
1909
+ renew.add_argument("step")
1910
+ renew.add_argument("--root", default=".")
1911
+ renew.add_argument("--attempt")
1912
+ renew.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1913
+ renew.add_argument("--minutes", type=int)
1914
+ renew.add_argument("--json", action="store_true")
1915
+ renew.set_defaults(func=command_renew_lease)
1916
+
1917
+ run = subparsers.add_parser("run", help="run a command and record an observed receipt")
1918
+ run.add_argument("--root", default=".")
1919
+ run.add_argument("--step", required=True)
1920
+ run.add_argument("--attempt")
1921
+ run.add_argument("--gate")
1922
+ run.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1923
+ run.add_argument("--confirm-risk", action="store_true")
1924
+ run.add_argument("--json", action="store_true")
1925
+ run.add_argument("command", nargs=argparse.REMAINDER)
1926
+ run.set_defaults(func=command_run)
1927
+
1928
+ record_cmd = subparsers.add_parser("record-command", help="record an externally run command")
1929
+ record_cmd.add_argument("--root", default=".")
1930
+ record_cmd.add_argument("--step", required=True)
1931
+ record_cmd.add_argument("--attempt")
1932
+ record_cmd.add_argument("--exit-code", type=int, required=True)
1933
+ record_cmd.add_argument("--gate")
1934
+ record_cmd.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1935
+ record_cmd.add_argument("--json", action="store_true")
1936
+ record_cmd.add_argument("command", nargs=argparse.REMAINDER)
1937
+ record_cmd.set_defaults(func=command_record_command)
1938
+
1939
+ record_file = subparsers.add_parser("record-file-change", help="record changed files for a step")
1940
+ record_file.add_argument("--root", default=".")
1941
+ record_file.add_argument("--step", required=True)
1942
+ record_file.add_argument("--attempt")
1943
+ record_file.add_argument("--path", action="append", required=True)
1944
+ record_file.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1945
+ record_file.add_argument("--json", action="store_true")
1946
+ record_file.set_defaults(func=command_record_file_change)
1947
+
1948
+ verify_step_parser = subparsers.add_parser("verify-step", help="verify one step attempt")
1949
+ verify_step_parser.add_argument("step")
1950
+ verify_step_parser.add_argument("--root", default=".")
1951
+ verify_step_parser.add_argument("--attempt")
1952
+ verify_step_parser.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
1953
+ verify_step_parser.add_argument("--strict", action="store_true")
1954
+ verify_step_parser.add_argument("--replay", action="store_true")
1955
+ verify_step_parser.add_argument("--json", action="store_true")
1956
+ verify_step_parser.set_defaults(func=command_verify_step)
1957
+
1958
+ verify_run_parser = subparsers.add_parser("verify-run", help="verify execution run coverage")
1959
+ verify_run_parser.add_argument("--root", default=".")
1960
+ verify_run_parser.add_argument("--plan", default=".agent/plan.lock.json")
1961
+ verify_run_parser.add_argument("--strict", action="store_true")
1962
+ verify_run_parser.add_argument(
1963
+ "--no-record",
1964
+ action="store_false",
1965
+ dest="record",
1966
+ help="verify without appending to .agent/verification-runs.jsonl",
1967
+ )
1968
+ verify_run_parser.add_argument("--json", action="store_true")
1969
+ verify_run_parser.set_defaults(record=True)
1970
+ verify_run_parser.set_defaults(func=command_verify_run)
1971
+
1972
+ aggregate_parser = subparsers.add_parser(
1973
+ "aggregate-ledgers",
1974
+ help="merge N worktree ledgers into one canonical .agent/ (pass --dry-run to preview collisions without writing)",
1975
+ )
1976
+ aggregate_parser.add_argument("--input", action="append")
1977
+ aggregate_parser.add_argument("--source-id", action="append", dest="source_id")
1978
+ aggregate_parser.add_argument("--label", action="append")
1979
+ aggregate_parser.add_argument("--output", default=".")
1980
+ aggregate_parser.add_argument("--base")
1981
+ aggregate_parser.add_argument("--dry-run", action="store_true", dest="dry_run")
1982
+ aggregate_parser.add_argument("--json", action="store_true")
1983
+ aggregate_parser.set_defaults(func=command_aggregate_ledgers)
1984
+
1985
+ detect_stuck_parser = subparsers.add_parser(
1986
+ "detect-stuck",
1987
+ help="detect stuck agent loops from receipts and verification history",
1988
+ )
1989
+ detect_stuck_parser.add_argument("--root", default=".")
1990
+ detect_stuck_parser.add_argument("--plan", default=".agent/plan.lock.json")
1991
+ detect_stuck_parser.add_argument("--strict", action="store_true")
1992
+ detect_stuck_parser.add_argument("--json", action="store_true")
1993
+ detect_stuck_parser.add_argument("--min-command-failures", type=_positive_int, default=3)
1994
+ detect_stuck_parser.add_argument("--min-verify-failures", type=_positive_int, default=2)
1995
+ detect_stuck_parser.add_argument("--min-cycle-repeats", type=_positive_int, default=3)
1996
+ detect_stuck_parser.set_defaults(func=command_detect_stuck)
1997
+
1998
+ export_handoff_parser = subparsers.add_parser("export-handoff", help="write a provider-neutral work packet")
1999
+ export_handoff_parser.add_argument("--root", default=".")
2000
+ export_handoff_parser.add_argument("--step", required=True)
2001
+ export_handoff_parser.add_argument("--format", choices=["json", "markdown"], default="json")
2002
+ export_handoff_parser.add_argument("--output")
2003
+ export_handoff_parser.set_defaults(func=command_export_handoff)
2004
+
2005
+ lint_handoff_parser = subparsers.add_parser("lint-handoff", help="lint a handoff for provider-specific language")
2006
+ lint_handoff_parser.add_argument("--root", default=".")
2007
+ lint_handoff_parser.add_argument("--input", required=True)
2008
+ lint_handoff_parser.add_argument("--json", action="store_true")
2009
+ lint_handoff_parser.set_defaults(func=command_lint_handoff)
2010
+
2011
+ replay = subparsers.add_parser("replay-gates", help="replay attested validation gates")
2012
+ replay.add_argument("--root", default=".")
2013
+ replay.add_argument("--step")
2014
+ replay.add_argument("--record", action="store_true")
2015
+ replay.add_argument("--json", action="store_true")
2016
+ replay.set_defaults(func=command_replay_gates)
2017
+
2018
+ runtime = subparsers.add_parser("runtime-status", help="summarize configured runtime readiness")
2019
+ runtime.add_argument("--root", default=".")
2020
+ runtime.add_argument("--config", default=".agent/runtime.config.json")
2021
+ runtime.add_argument("--record", action="store_true")
2022
+ runtime.add_argument("--json", action="store_true")
2023
+ runtime.add_argument("--probe", action="store_true")
2024
+ runtime.add_argument("--strict", action="store_true")
2025
+ runtime.set_defaults(func=command_runtime_status)
2026
+
2027
+ proof = subparsers.add_parser("build-proof", help="generate proof pack markdown")
2028
+ proof.add_argument("--root", default=".")
2029
+ proof.add_argument("--plan", default=".agent/plan.lock.json")
2030
+ proof.add_argument("--output", default=".agent/proof-pack.md")
2031
+ proof.add_argument("--strict", action="store_true")
2032
+ proof.set_defaults(func=command_build_proof)
2033
+
2034
+ verify = subparsers.add_parser("verify-proof", help="verify proof-pack source hashes")
2035
+ verify.add_argument("--root", default=".")
2036
+ verify.add_argument("--proof", default=".agent/proof-pack.json")
2037
+ verify.add_argument("--replay", action="store_true")
2038
+ verify.add_argument("--strict", action="store_true")
2039
+ verify.set_defaults(func=command_verify_proof)
2040
+
2041
+ view = subparsers.add_parser(
2042
+ "view-proof",
2043
+ help="render a static HTML proof report (review aid; verify-proof is authoritative)",
2044
+ )
2045
+ view.add_argument("--html", action="store_true")
2046
+ view.add_argument("--root", default=".")
2047
+ view.add_argument("--proof", default=".agent/proof-pack.json")
2048
+ view.add_argument("--output", default=".agent/proof-report.html")
2049
+ view.set_defaults(func=command_view_proof)
2050
+
2051
+ events_parser = subparsers.add_parser(
2052
+ "events", help="project a chronological event stream over .agent ledgers"
2053
+ )
2054
+ events_parser.add_argument("--root", default=".")
2055
+ events_parser.add_argument("--since")
2056
+ events_format = events_parser.add_mutually_exclusive_group()
2057
+ events_format.add_argument("--jsonl", action="store_true")
2058
+ events_format.add_argument("--json", action="store_true")
2059
+ events_parser.set_defaults(func=command_events)
2060
+
2061
+ next_action_parser = subparsers.add_parser(
2062
+ "next-action", help="report the next required Agentflow action")
2063
+ next_action_parser.add_argument("--root", default=".")
2064
+ next_action_parser.add_argument(
2065
+ "--agent",
2066
+ default=os.environ.get("AGENTFLOW_AGENT_ID"),
2067
+ )
2068
+ next_action_parser.add_argument("--json", action="store_true")
2069
+ next_action_parser.add_argument("--strict", action="store_true")
2070
+ next_action_parser.set_defaults(func=command_next_action)
2071
+
2072
+ finish_step_parser = subparsers.add_parser(
2073
+ "finish-step", help="verify then complete a step")
2074
+ finish_step_parser.add_argument("step")
2075
+ finish_step_parser.add_argument("--attempt", default=None)
2076
+ finish_step_parser.add_argument("--root", default=".")
2077
+ finish_step_parser.add_argument("--agent", default=os.environ.get("AGENTFLOW_AGENT_ID"))
2078
+ finish_step_parser.add_argument("--json", action="store_true")
2079
+ finish_step_parser.add_argument("--strict", action="store_true")
2080
+ finish_step_parser.add_argument("--replay", action="store_true")
2081
+ finish_step_parser.set_defaults(func=command_finish_step)
2082
+
2083
+ finish_run_parser = subparsers.add_parser(
2084
+ "finish-run", help="run audit/verify/proof gates in order")
2085
+ finish_run_parser.add_argument("--root", default=".")
2086
+ finish_run_parser.add_argument("--plan", default=".agent/plan.lock.json")
2087
+ finish_run_parser.add_argument("--json", action="store_true")
2088
+ finish_run_parser.add_argument("--strict", action="store_true")
2089
+ finish_run_parser.set_defaults(func=command_finish_run)
2090
+
2091
+ status = subparsers.add_parser("status", help="summarize current workflow state")
2092
+ status.add_argument("--root", default=".")
2093
+ status.set_defaults(func=command_status)
2094
+
2095
+ return parser
2096
+
2097
+
2098
+ def main(argv: Optional[List[str]] = None) -> int:
2099
+ parser = build_parser()
2100
+ args = parser.parse_args(argv)
2101
+ try:
2102
+ return args.func(args)
2103
+ except ValueError as exc:
2104
+ # Safety net for reader-gate errors (artifact schema_version policy
2105
+ # rejections raise ValueError): cross-version .agent state must surface
2106
+ # as a clean diagnostic, never a traceback.
2107
+ print(f"error: {exc}", file=sys.stderr)
2108
+ return 1