forgex-cli 0.1.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.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,103 @@
1
+ """`forgex plan` — run the Architect agent standalone (spec sections 7, 34)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.markup import escape
10
+ from rich.table import Table
11
+
12
+ from forgex.agents.architect import build_planner, plan_project
13
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
14
+ from forgex.config import load_config
15
+ from forgex.orchestration.stores import open_stores
16
+ from forgex.research.citations import format_note
17
+ from forgex.schemas.error import ProjectError
18
+ from forgex.schemas.plan import specs_to_tasks
19
+ from forgex.utils.paths import find_project_root
20
+
21
+ STAGE_LABELS = {
22
+ "analyzing": "Analyzing request & repository",
23
+ "researching": "Researching dependencies",
24
+ "planning": "Producing implementation plan",
25
+ }
26
+
27
+
28
+ def plan_command(
29
+ ctx: typer.Context,
30
+ request: Annotated[str, typer.Argument(help="What should be built?")],
31
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show the plan without persisting anything.")] = False,
32
+ json_out: JsonFlag = False,
33
+ plain: PlainFlag = False,
34
+ ) -> None:
35
+ """Run the Architect agent: analysis, research, implementation plan."""
36
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
37
+ root = find_project_root()
38
+ if root is None:
39
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
40
+
41
+ config = load_config(root)
42
+ step = {"n": 0}
43
+
44
+ def progress(stage: str) -> None:
45
+ step["n"] += 1
46
+ ui.text(f"[{step['n']}/3] {STAGE_LABELS.get(stage, stage)}...")
47
+
48
+ with open_stores(root) as stores:
49
+ planner = build_planner(config=config, workspace=root, stores=stores)
50
+ try:
51
+ result = asyncio.run(
52
+ plan_project(
53
+ planner,
54
+ stores=stores,
55
+ request=request,
56
+ dry_run=dry_run,
57
+ progress=progress,
58
+ )
59
+ )
60
+ finally:
61
+ aclose = getattr(planner, "aclose", None)
62
+ if aclose is not None:
63
+ asyncio.run(aclose())
64
+
65
+ tasks = specs_to_tasks(result.plan.tasks)
66
+ ui.data({
67
+ "request": request,
68
+ "plan": result.plan.model_dump(mode="json"),
69
+ "research": [note.model_dump(mode="json") for note in result.research],
70
+ "tasks_created": len(tasks),
71
+ "persisted": not dry_run,
72
+ })
73
+
74
+ suffix = " (dry run — nothing persisted)" if dry_run else ""
75
+ ui.ok(f"Plan created — {len(tasks)} tasks, {len(result.plan.libraries)} libraries{suffix}")
76
+
77
+ if ui.mode is Output.RICH:
78
+ table = Table(show_header=True, header_style="bold", expand=False)
79
+ table.add_column("ID")
80
+ table.add_column("Priority")
81
+ table.add_column("Deps")
82
+ table.add_column("Description", overflow="fold")
83
+ for task in tasks:
84
+ table.add_row(
85
+ task.id,
86
+ task.priority.value,
87
+ ", ".join(task.depends_on) or "—",
88
+ escape(task.description),
89
+ )
90
+ ui.console.print(table)
91
+ if result.plan.libraries:
92
+ ui.text(f"Libraries: {', '.join(escape(lib) for lib in result.plan.libraries)}")
93
+ if result.research:
94
+ ui.text("")
95
+ ui.text("Research:")
96
+ for note in result.research:
97
+ ui.text(f" {escape(format_note(note))}")
98
+ elif ui.mode is Output.PLAIN:
99
+ for task in tasks:
100
+ deps = ",".join(task.depends_on) or "-"
101
+ ui.text(f"{task.id} [{task.priority.value}] {task.description} (deps: {deps})")
102
+ for note in result.research:
103
+ ui.text(format_note(note))
@@ -0,0 +1,76 @@
1
+ """`forgex review` — run the Expert Coder over recent changes (spec section 10)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.markup import escape
10
+
11
+ from forgex.agents.expert_coder import review_work
12
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, make_approval, ui_from
13
+ from forgex.config import load_config
14
+ from forgex.orchestration.stores import open_stores
15
+ from forgex.schemas.error import ProjectError
16
+ from forgex.utils.paths import find_project_root
17
+
18
+
19
+ def review_command(
20
+ ctx: typer.Context,
21
+ task_id: Annotated[str | None, typer.Argument(help="Review this task's changes (default: current uncommitted changes).")] = None,
22
+ base: Annotated[str | None, typer.Option("--base", help="Explicit base commit for the review diff.")] = None,
23
+ json_out: JsonFlag = False,
24
+ plain: PlainFlag = False,
25
+ ) -> None:
26
+ """Expert review of recent changes: fix, simplify, harden — minimally."""
27
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
28
+ root = find_project_root()
29
+ if root is None:
30
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
31
+
32
+ config = load_config(root)
33
+ with open_stores(root) as stores:
34
+ result = asyncio.run(
35
+ review_work(
36
+ stores=stores,
37
+ config=config,
38
+ task_id=task_id,
39
+ base_commit=base,
40
+ approval=make_approval(ui),
41
+ )
42
+ )
43
+
44
+ if result is None:
45
+ ui.data({"reviewed": False})
46
+ ui.ok("Nothing to review — no changes detected.")
47
+ return
48
+
49
+ ui.data({
50
+ "reviewed": True,
51
+ "status": result.status,
52
+ "summary": result.summary,
53
+ "improvements": result.improvements,
54
+ "issues_found": result.issues_found,
55
+ "unfixed_issues": result.unfixed_issues,
56
+ "changed_files": result.changed_files,
57
+ })
58
+
59
+ if result.status != "completed":
60
+ ui.error(f"Review failed: {result.summary}")
61
+ raise typer.Exit(code=1)
62
+
63
+ if result.changed_files:
64
+ ui.ok(f"Review complete — {len(result.improvements)} improvement(s), {len(result.changed_files)} file(s) changed")
65
+ else:
66
+ ui.ok(f"Review complete — no changes needed ({result.summary})")
67
+
68
+ if ui.mode in (Output.RICH, Output.PLAIN):
69
+ for item in result.improvements:
70
+ ui.text(f" • {escape(item)}")
71
+ for path in result.changed_files:
72
+ ui.text(f" changed: {escape(path)}")
73
+ if result.unfixed_issues:
74
+ ui.text(" Unfixed issues:")
75
+ for issue in result.unfixed_issues:
76
+ ui.text(f" - {escape(issue)}")
@@ -0,0 +1,65 @@
1
+ """`forgex rollback` — restore workspace to a checkpoint (spec sections 21, 33)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.prompt import Confirm
10
+
11
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
12
+ from forgex.orchestration.checkpoints import get_checkpoint, latest_checkpoint, rollback_to
13
+ from forgex.orchestration.stores import open_stores
14
+ from forgex.schemas.error import ProjectError
15
+ from forgex.tools.git import git_diff_stat
16
+ from forgex.utils.paths import find_project_root
17
+
18
+
19
+ def rollback_command(
20
+ ctx: typer.Context,
21
+ checkpoint_id: Annotated[str | None, typer.Argument(help="Checkpoint id (default: latest)")] = None,
22
+ last: Annotated[bool, typer.Option("--last", help="Rollback to latest checkpoint.")] = False,
23
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False,
24
+ json_out: JsonFlag = False,
25
+ plain: PlainFlag = False,
26
+ ) -> None:
27
+ """Roll the workspace back to a checkpoint (reversible)."""
28
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
29
+ root = find_project_root()
30
+ if root is None:
31
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
32
+
33
+ with open_stores(root) as stores:
34
+ if checkpoint_id and not last:
35
+ record = get_checkpoint(checkpoint_id, stores=stores)
36
+ if record is None:
37
+ raise ProjectError(f"Unknown checkpoint: {checkpoint_id}")
38
+ else:
39
+ record = latest_checkpoint(stores=stores)
40
+ if record is None:
41
+ raise ProjectError("No checkpoints recorded yet — run `forgex checkpoint`.")
42
+
43
+ ui.text(f"Rolling back to {record.id} — {record.label} (commit {record.git_commit[:8] if record.git_commit else '?'})")
44
+ preview = git_diff_stat(root, record.git_commit) if record.git_commit else ""
45
+ if preview and preview != "(no diff preview available)":
46
+ ui.text(f"Changes to be reverted:\n{preview}")
47
+
48
+ if not yes:
49
+ if not sys.stdin.isatty() or ui.mode is not Output.RICH:
50
+ ui.warn("Refusing to rollback non-interactively without --yes.")
51
+ raise typer.Exit(code=1)
52
+ if not Confirm.ask("Proceed with rollback?", default=False):
53
+ ui.warn("Aborted — nothing changed.")
54
+ raise typer.Exit(code=1)
55
+
56
+ safety = rollback_to(record, stores=stores)
57
+
58
+ ui.data({
59
+ "rolled_back_to": record.id,
60
+ "commit": record.git_commit,
61
+ "safety_checkpoint": safety.id if safety else None,
62
+ })
63
+ ui.ok(f"Rolled back to {record.id} ({record.label})")
64
+ if safety:
65
+ ui.text(f"Safety checkpoint: {safety.id} — undo this rollback with `forgex rollback {safety.id}`")
@@ -0,0 +1,75 @@
1
+ """`forgex run` — run the project (spec section 22)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, make_approval, ui_from
13
+ from forgex.config import load_config
14
+ from forgex.orchestration.stores import open_stores
15
+ from forgex.schemas.agent import AgentRole
16
+ from forgex.schemas.error import ProjectError
17
+ from forgex.tools import create_context
18
+ from forgex.utils.paths import find_project_root
19
+
20
+
21
+ def detect_run_command(root: Path) -> list[str] | None:
22
+ pkg = root / "package.json"
23
+ if pkg.is_file():
24
+ try:
25
+ scripts = json.loads(pkg.read_text(encoding="utf-8")).get("scripts") or {}
26
+ except (OSError, ValueError):
27
+ return None
28
+ if scripts.get("start"):
29
+ return ["npm", "start"]
30
+ return None
31
+
32
+
33
+ def run_command(
34
+ ctx: typer.Context,
35
+ command: Annotated[list[str] | None, typer.Argument(help="Command to run (default: detected npm start).")] = None,
36
+ timeout: Annotated[int | None, typer.Option("--timeout", help="Seconds before run is killed.")] = None,
37
+ json_out: JsonFlag = False,
38
+ plain: PlainFlag = False,
39
+ ) -> None:
40
+ """Run the project's start command through the policy-gated executor."""
41
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
42
+ root = find_project_root()
43
+ if root is None:
44
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
45
+
46
+ cmd = list(command) if command else detect_run_command(root)
47
+ if not cmd:
48
+ raise ProjectError("No run command detected — pass one: forgex run -- python main.py")
49
+
50
+ config = load_config(root)
51
+ with open_stores(root) as stores:
52
+ tool_ctx = create_context(
53
+ workspace=root,
54
+ config=config,
55
+ events=stores.recorder,
56
+ approval=make_approval(ui),
57
+ agent=AgentRole.BUILDER,
58
+ )
59
+ result = asyncio.run(tool_ctx.executor.run(cmd, cwd=".", timeout=timeout))
60
+
61
+ ui.data({
62
+ "command": result.argv,
63
+ "exit_code": result.exit_code,
64
+ "timed_out": result.timed_out,
65
+ "duration_ms": round(result.duration_ms, 1),
66
+ })
67
+
68
+ if ui.mode is not Output.JSON and ui.mode is not Output.QUIET:
69
+ ui.text(f"$ {' '.join(result.argv)}")
70
+ if result.stdout:
71
+ ui.text(result.stdout.rstrip())
72
+ if result.stderr:
73
+ ui.text(result.stderr.rstrip())
74
+
75
+ raise typer.Exit(code=0 if result.exit_code == 0 and not result.timed_out else 1)
@@ -0,0 +1,141 @@
1
+ """`forgex status` — project detection, git state, ProjectState summary and run history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.panel import Panel
7
+
8
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
9
+ from forgex.config import load_config
10
+ from forgex.orchestration.state import StateStore, TaskStore
11
+ from forgex.orchestration.stores import open_stores
12
+ from forgex.project import detect_project
13
+ from forgex.schemas.error import ProjectError
14
+ from forgex.schemas.task import TaskStatus
15
+ from forgex.tools.git import current_branch, is_dirty
16
+ from forgex.utils.paths import find_project_root
17
+
18
+
19
+ def status_command(
20
+ ctx: typer.Context,
21
+ json_out: JsonFlag = False,
22
+ plain: PlainFlag = False,
23
+ ) -> None:
24
+ """Show project status."""
25
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
26
+ root = find_project_root()
27
+ if root is None:
28
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
29
+
30
+ config = load_config(root)
31
+ detection = detect_project(root)
32
+ branch = current_branch(root)
33
+ dirty = is_dirty(root) if branch else None
34
+
35
+ state = StateStore(root).load()
36
+ tasks = TaskStore(root).load()
37
+
38
+ task_counts = {s.value: 0 for s in TaskStatus}
39
+ for t in tasks:
40
+ task_counts[t.status.value] += 1
41
+
42
+ runs_payload: list[dict] = []
43
+ usage_totals = None
44
+ with open_stores(root, create_db=False) as stores:
45
+ if stores.runs is not None:
46
+ pid = state.project_id if state else None
47
+ runs_payload = [r.model_dump(mode="json") for r in stores.runs.list_recent(pid, limit=5)]
48
+ if stores.usage is not None:
49
+ raw_totals = stores.usage.totals()
50
+ usage_totals = {k: (int(v) if v is not None else None) for k, v in raw_totals.items()}
51
+
52
+ events_path = root / ".forgex" / "events.jsonl"
53
+ n_events = (
54
+ sum(1 for ln in events_path.read_text(encoding="utf-8").splitlines() if ln.strip())
55
+ if events_path.is_file()
56
+ else 0
57
+ )
58
+ n_checkpoints = len(list((root / ".forgex" / "checkpoints").glob("*")))
59
+ git_state = f"{branch} ({'dirty' if dirty else 'clean'})" if branch else "not a git repository"
60
+
61
+ state_summary = None
62
+ if state:
63
+ state_summary = {
64
+ "project_id": state.project_id,
65
+ "status": state.status.value,
66
+ "iteration": state.iteration,
67
+ "current_agent": state.current_agent.value if state.current_agent else None,
68
+ "current_task": state.current_task,
69
+ "user_request": state.user_request,
70
+ "git_commit": state.git_commit,
71
+ "changed_files": len(state.changed_files),
72
+ "errors": len(state.errors),
73
+ "last_supervisor_status": state.last_supervisor_status,
74
+ "test_results": (
75
+ {
76
+ "passed": state.test_results.passed,
77
+ "failed": state.test_results.failed,
78
+ "exit_code": state.test_results.exit_code,
79
+ }
80
+ if state.test_results
81
+ else None
82
+ ),
83
+ }
84
+
85
+ payload = {
86
+ "project": root.name,
87
+ "root": str(root),
88
+ "detection": detection.as_dict(),
89
+ "git": {"branch": branch, "dirty": dirty},
90
+ "state": state_summary,
91
+ "tasks": {"total": len(tasks), "by_status": task_counts},
92
+ "runs": runs_payload,
93
+ "model_usage": usage_totals,
94
+ "checkpoints": n_checkpoints,
95
+ "events": n_events,
96
+ "workflow": {"max_iterations": config.workflow.max_iterations},
97
+ "permissions": {"mode": config.permissions.mode},
98
+ }
99
+ ui.data(payload)
100
+
101
+ lines = [
102
+ f"Detected: {detection.summary()}",
103
+ f"Git: {git_state}",
104
+ f"Status: {state.status.value if state else 'no state file'}",
105
+ ]
106
+ if state:
107
+ agent = state.current_agent.value if state.current_agent else "—"
108
+ lines.append(f"Iteration: {state.iteration}")
109
+ lines.append(f"Current: agent={agent} task={state.current_task or '—'}")
110
+ lines.append(
111
+ f"Tasks: {task_counts['completed']}/{len(tasks)} completed"
112
+ + (f", {task_counts['failed']} failed" if task_counts["failed"] else "")
113
+ )
114
+ if state:
115
+ tr = state.test_results
116
+ if tr is not None:
117
+ p = tr.passed if tr.passed is not None else "?"
118
+ f = tr.failed if tr.failed is not None else "?"
119
+ lines.append(f"Tests: {p} passed, {f} failed (exit {tr.exit_code})")
120
+ else:
121
+ lines.append("Tests: not run")
122
+ lines.append(f"Supervisor: {state.last_supervisor_status or 'not run'}")
123
+ lines.extend([
124
+ f"Checkpoints: {n_checkpoints}",
125
+ f"Events: {n_events}",
126
+ f"Workflow: max_iterations={config.workflow.max_iterations}",
127
+ f"Permissions: {config.permissions.mode}",
128
+ ])
129
+
130
+ if usage_totals and usage_totals.get("calls"):
131
+ tokens_in, tokens_out = usage_totals.get("input_tokens"), usage_totals.get("output_tokens")
132
+ lines.append(
133
+ f"Model usage: {usage_totals['calls']} call(s)"
134
+ + (f", {tokens_in} in / {tokens_out} out tokens" if tokens_in is not None else " (tokens unreported)")
135
+ )
136
+
137
+ if ui.mode is Output.RICH:
138
+ ui.console.print(Panel("\n".join(lines), title=f"FORGEX — {root.name}", expand=False))
139
+ elif ui.mode is Output.PLAIN:
140
+ for line in lines:
141
+ ui.console.print(line)
@@ -0,0 +1,61 @@
1
+ """`forgex supervise` — full supervision: verification + LLM audit + quality gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.markup import escape
10
+
11
+ from forgex.agents.supervisor import supervise
12
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, make_approval, ui_from
13
+ from forgex.config import load_config
14
+ from forgex.orchestration.stores import open_stores
15
+ from forgex.schemas.error import ProjectError
16
+ from forgex.utils.paths import find_project_root
17
+
18
+
19
+ def supervise_command(
20
+ ctx: typer.Context,
21
+ no_build: Annotated[bool, typer.Option("--no-build", help="Skip the build step.")] = False,
22
+ no_lint: Annotated[bool, typer.Option("--no-lint", help="Skip the lint step.")] = False,
23
+ json_out: JsonFlag = False,
24
+ plain: PlainFlag = False,
25
+ ) -> None:
26
+ """Inspect → diagnose → instruct repair."""
27
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
28
+ root = find_project_root()
29
+ if root is None:
30
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
31
+
32
+ config = load_config(root)
33
+ with open_stores(root) as stores:
34
+ report = asyncio.run(
35
+ supervise(
36
+ stores=stores,
37
+ config=config,
38
+ approval=make_approval(ui),
39
+ include_build=not no_build,
40
+ include_lint=not no_lint,
41
+ )
42
+ )
43
+
44
+ ui.data(report.model_dump(mode="json"))
45
+ color = {"pass": "green", "fail": "red", "needs_review": "yellow"}.get(report.status, "white")
46
+ if ui.mode is Output.RICH:
47
+ ui.console.print(f"[bold {color}]VERDICT: {report.status.upper()}[/bold {color}]")
48
+ else:
49
+ ui.text(f"VERDICT: {report.status.upper()}")
50
+
51
+ ui.text(report.summary)
52
+ for item in report.errors:
53
+ ui.text(f" error: {escape(item)}")
54
+ for item in report.warnings:
55
+ ui.text(f" warning: {escape(item)}")
56
+ if report.required_changes:
57
+ ui.text(" Required changes:")
58
+ for change in report.required_changes:
59
+ ui.text(f" - {escape(change)}")
60
+
61
+ raise typer.Exit(code=0 if report.status == "pass" else 1)
@@ -0,0 +1,136 @@
1
+ """`forgex task` — inspect and execute plan tasks (Builder surface)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.markup import escape
10
+ from rich.table import Table
11
+
12
+ from forgex.agents.builder import build_builder, execute_task
13
+ from forgex.cli.ui import JsonFlag, Output, PlainFlag, make_approval, ui_from
14
+ from forgex.config import load_config
15
+ from forgex.orchestration.state import TaskStore
16
+ from forgex.orchestration.stores import open_stores
17
+ from forgex.schemas.error import ProjectError
18
+ from forgex.schemas.task import ready_tasks
19
+ from forgex.utils.paths import find_project_root
20
+
21
+ app = typer.Typer(no_args_is_help=True, help="Inspect and execute plan tasks.")
22
+
23
+
24
+ @app.command("list")
25
+ def list_command(
26
+ ctx: typer.Context,
27
+ json_out: JsonFlag = False,
28
+ plain: PlainFlag = False,
29
+ ) -> None:
30
+ """List tasks with status and readiness."""
31
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
32
+ root = find_project_root()
33
+ if root is None:
34
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
35
+
36
+ tasks = TaskStore(root).load()
37
+ ready_ids = {t.id for t in ready_tasks(tasks)}
38
+ payload = [
39
+ {
40
+ "id": t.id,
41
+ "status": t.status.value,
42
+ "priority": t.priority.value,
43
+ "depends_on": t.depends_on,
44
+ "attempts": t.attempts,
45
+ "ready": t.id in ready_ids,
46
+ "description": t.description,
47
+ }
48
+ for t in tasks
49
+ ]
50
+ ui.data({"tasks": payload})
51
+
52
+ if ui.mode is Output.RICH:
53
+ table = Table(show_header=True, header_style="bold")
54
+ table.add_column("ID")
55
+ table.add_column("Status")
56
+ table.add_column("Priority")
57
+ table.add_column("Deps")
58
+ table.add_column("Ready")
59
+ table.add_column("Description", overflow="fold")
60
+ for item in payload:
61
+ ready_str = "[green]✓[/green]" if item["ready"] else "[yellow]—[/yellow]"
62
+ deps = item["depends_on"]
63
+ deps_str = ", ".join(deps) if isinstance(deps, list) else str(deps)
64
+ table.add_row(
65
+ str(item["id"]),
66
+ str(item["status"]),
67
+ str(item["priority"]),
68
+ deps_str or "—",
69
+ ready_str,
70
+ escape(str(item["description"])),
71
+ )
72
+ ui.console.print(table)
73
+ elif ui.mode is Output.PLAIN:
74
+ for item in payload:
75
+ ui.console.print(f"{item['id']} [{item['status']}] ready={item['ready']}: {item['description']}")
76
+
77
+
78
+ @app.command("run")
79
+ def run_command(
80
+ ctx: typer.Context,
81
+ task_id: Annotated[str, typer.Argument(help="Task ID to execute, e.g. TASK-001")],
82
+ json_out: JsonFlag = False,
83
+ plain: PlainFlag = False,
84
+ ) -> None:
85
+ """Execute one task with the Builder agent."""
86
+ ui = ui_from(ctx, json_out=json_out, plain=plain)
87
+ root = find_project_root()
88
+ if root is None:
89
+ raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
90
+
91
+ config = load_config(root)
92
+ with open_stores(root) as stores:
93
+ tasks = stores.tasks.load()
94
+ task = next((t for t in tasks if t.id == task_id), None)
95
+ if task is None:
96
+ raise ProjectError(f"Task '{task_id}' not found in tasks.json")
97
+
98
+ state = stores.state.load()
99
+ if not state or not state.architecture_plan:
100
+ raise ProjectError("No architecture plan found in project state. Run `forgex plan` first.")
101
+
102
+ builder = build_builder(
103
+ config=config,
104
+ workspace=root,
105
+ stores=stores,
106
+ approval=make_approval(ui),
107
+ run_id=state.current_run_id,
108
+ )
109
+ try:
110
+ result = asyncio.run(
111
+ execute_task(
112
+ task,
113
+ builder=builder,
114
+ stores=stores,
115
+ config=config,
116
+ plan=state.architecture_plan,
117
+ research=state.research,
118
+ )
119
+ )
120
+ finally:
121
+ asyncio.run(builder.aclose())
122
+
123
+ ui.data({
124
+ "task_id": task_id,
125
+ "status": result.status,
126
+ "summary": result.summary,
127
+ "changed_files": result.changed_files,
128
+ "issues": result.issues,
129
+ })
130
+
131
+ if result.status == "completed":
132
+ ui.ok(f"Task {task_id} completed: {result.summary}")
133
+ else:
134
+ ui.error(f"Task {task_id} failed: {result.summary}")
135
+
136
+ raise typer.Exit(code=0 if result.status == "completed" else 1)