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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|