specsmith 0.2.1.dev40__tar.gz → 0.2.1.dev42__tar.gz
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.
- {specsmith-0.2.1.dev40/src/specsmith.egg-info → specsmith-0.2.1.dev42}/PKG-INFO +1 -1
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/pyproject.toml +1 -1
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/auditor.py +9 -6
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/cli.py +92 -4
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/credit_analyzer.py +2 -4
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/credits.py +8 -6
- specsmith-0.2.1.dev42/src/specsmith/executor.py +267 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/importer.py +23 -7
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/scaffolder.py +31 -5
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/session.py +1 -2
- specsmith-0.2.1.dev42/src/specsmith/templates/docs/mkdocs.yml.j2 +34 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/docs/readthedocs.yaml.j2 +22 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/go/go.mod.j2 +3 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/go/main.go.j2 +7 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/js/package.json.j2 +48 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/rust/Cargo.toml.j2 +19 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/rust/main.rs.j2 +15 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/scripts/exec.cmd.j2 +63 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/scripts/exec.sh.j2 +60 -0
- specsmith-0.2.1.dev42/src/specsmith/templates/workflows/release.yml.j2 +101 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/updater.py +5 -2
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/SOURCES.txt +9 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_auditor.py +1 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_cli.py +1 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_integrations.py +1 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_scaffolder.py +1 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_smoke.py +1 -2
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_vcs.py +1 -0
- specsmith-0.2.1.dev40/src/specsmith/templates/scripts/exec.cmd.j2 +0 -33
- specsmith-0.2.1.dev40/src/specsmith/templates/scripts/exec.sh.j2 +0 -38
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/LICENSE +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/README.md +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/setup.cfg +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/__init__.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/__main__.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/architect.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/compressor.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/config.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/differ.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/doctor.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/exporter.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/warp.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/ledger.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/plugins.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/releaser.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/requirements.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/contributing.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/license-MIT.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/security.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/pyproject.toml.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/tools.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/validator.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_compressor.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_importer.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_tools.py +0 -0
- {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_validator.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specsmith"
|
|
7
|
-
version = "0.2.1.
|
|
7
|
+
version = "0.2.1.dev42"
|
|
8
8
|
description = "Forge governed project scaffolds from the Agentic AI Development Workflow Specification."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -125,10 +125,14 @@ def check_governance_files(root: Path) -> list[AuditResult]:
|
|
|
125
125
|
found = path.exists()
|
|
126
126
|
# For architecture.md, also search subdirectories (e.g. docs/architecture/*.md)
|
|
127
127
|
if not found and "architecture" in f:
|
|
128
|
-
found =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
found = (
|
|
129
|
+
bool(
|
|
130
|
+
list((root / "docs").glob("**/architecture*"))
|
|
131
|
+
+ list((root / "docs").glob("**/ARCHITECTURE*"))
|
|
132
|
+
)
|
|
133
|
+
if (root / "docs").is_dir()
|
|
134
|
+
else False
|
|
135
|
+
)
|
|
132
136
|
results.append(
|
|
133
137
|
AuditResult(
|
|
134
138
|
name=f"recommended:{f}",
|
|
@@ -580,8 +584,7 @@ def run_auto_fix(root: Path, report: AuditReport) -> list[str]:
|
|
|
580
584
|
path = root / "docs" / "ARCHITECTURE.md"
|
|
581
585
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
582
586
|
path.write_text(
|
|
583
|
-
f"# Architecture — {root.name}\n\n"
|
|
584
|
-
"[Run `specsmith architect` to populate]\n",
|
|
587
|
+
f"# Architecture — {root.name}\n\n[Run `specsmith architect` to populate]\n",
|
|
585
588
|
encoding="utf-8",
|
|
586
589
|
)
|
|
587
590
|
fixed.append("Created stub docs/ARCHITECTURE.md")
|
|
@@ -703,9 +703,7 @@ def architect(project_dir: str, non_interactive: bool) -> None:
|
|
|
703
703
|
f" [yellow]Note:[/yellow] Existing docs at {', '.join(existing)} "
|
|
704
704
|
"are referenced but not merged. Review manually."
|
|
705
705
|
)
|
|
706
|
-
console.print(
|
|
707
|
-
" [dim]Run \"specsmith audit --project-dir .\" to verify governance health.[/dim]"
|
|
708
|
-
)
|
|
706
|
+
console.print(' [dim]Run "specsmith audit --project-dir ." to verify governance health.[/dim]')
|
|
709
707
|
|
|
710
708
|
|
|
711
709
|
# ---------------------------------------------------------------------------
|
|
@@ -1463,7 +1461,10 @@ def credits_analyze(project_dir: str) -> None:
|
|
|
1463
1461
|
"--watermarks", default=None, help="Comma-separated USD watermark alerts (e.g. 5,10,25,50)."
|
|
1464
1462
|
)
|
|
1465
1463
|
def credits_budget(
|
|
1466
|
-
project_dir: str,
|
|
1464
|
+
project_dir: str,
|
|
1465
|
+
cap: float | None,
|
|
1466
|
+
alert_pct: int | None,
|
|
1467
|
+
watermarks: str | None,
|
|
1467
1468
|
) -> None:
|
|
1468
1469
|
"""View or set credit budget and alert thresholds."""
|
|
1469
1470
|
from specsmith.credits import load_budget, save_budget
|
|
@@ -1543,5 +1544,92 @@ def serve(port: int) -> None:
|
|
|
1543
1544
|
)
|
|
1544
1545
|
|
|
1545
1546
|
|
|
1547
|
+
# ---------------------------------------------------------------------------
|
|
1548
|
+
# Process execution and abort
|
|
1549
|
+
# ---------------------------------------------------------------------------
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
@main.command(name="exec")
|
|
1553
|
+
@click.argument("command")
|
|
1554
|
+
@click.option("--timeout", default=120, help="Timeout in seconds (default: 120).")
|
|
1555
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1556
|
+
def exec_cmd(command: str, timeout: int, project_dir: str) -> None:
|
|
1557
|
+
"""Execute a command with PID tracking and timeout enforcement.
|
|
1558
|
+
|
|
1559
|
+
Tracks the process in .specsmith/pids/ so it can be listed (specsmith ps)
|
|
1560
|
+
or aborted (specsmith abort). Logs stdout/stderr to .specsmith/logs/.
|
|
1561
|
+
Works cross-platform: Windows, Linux, macOS.
|
|
1562
|
+
"""
|
|
1563
|
+
from specsmith.executor import run_tracked
|
|
1564
|
+
|
|
1565
|
+
root = Path(project_dir).resolve()
|
|
1566
|
+
console.print(f"[bold]exec[/bold] {command} (timeout={timeout}s)")
|
|
1567
|
+
|
|
1568
|
+
result = run_tracked(root, command, timeout=timeout)
|
|
1569
|
+
|
|
1570
|
+
if result.timed_out:
|
|
1571
|
+
console.print(f"[red]TIMEOUT[/red] after {timeout}s (PID {result.pid})")
|
|
1572
|
+
elif result.exit_code == 0:
|
|
1573
|
+
console.print(f"[green]OK[/green] ({result.duration:.1f}s) — exit code 0")
|
|
1574
|
+
else:
|
|
1575
|
+
console.print(f"[red]FAILED[/red] ({result.duration:.1f}s) — exit code {result.exit_code}")
|
|
1576
|
+
if result.stdout_file:
|
|
1577
|
+
console.print(f" stdout: {result.stdout_file}")
|
|
1578
|
+
if result.stderr_file:
|
|
1579
|
+
console.print(f" stderr: {result.stderr_file}")
|
|
1580
|
+
raise SystemExit(result.exit_code)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
@main.command(name="ps")
|
|
1584
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1585
|
+
def ps_cmd(project_dir: str) -> None:
|
|
1586
|
+
"""List tracked running processes."""
|
|
1587
|
+
from specsmith.executor import list_processes
|
|
1588
|
+
|
|
1589
|
+
root = Path(project_dir).resolve()
|
|
1590
|
+
procs = list_processes(root)
|
|
1591
|
+
if not procs:
|
|
1592
|
+
console.print("No tracked processes running.")
|
|
1593
|
+
return
|
|
1594
|
+
for p in procs:
|
|
1595
|
+
elapsed = p.elapsed
|
|
1596
|
+
remaining = max(0, p.timeout - elapsed)
|
|
1597
|
+
status = "[red]EXPIRED[/red]" if p.is_expired else f"{remaining:.0f}s left"
|
|
1598
|
+
console.print(f" PID {p.pid} {status} {p.command}")
|
|
1599
|
+
console.print(f"\n {len(procs)} process(es)")
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
@main.command(name="abort")
|
|
1603
|
+
@click.option("--pid", type=int, default=None, help="Abort a specific PID.")
|
|
1604
|
+
@click.option("--all", "abort_all_flag", is_flag=True, default=False, help="Abort all tracked.")
|
|
1605
|
+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
|
|
1606
|
+
def abort_cmd(pid: int | None, abort_all_flag: bool, project_dir: str) -> None:
|
|
1607
|
+
"""Abort tracked process(es). Sends SIGTERM then SIGKILL (POSIX) or taskkill (Windows)."""
|
|
1608
|
+
from specsmith.executor import abort_all, abort_process, list_processes
|
|
1609
|
+
|
|
1610
|
+
root = Path(project_dir).resolve()
|
|
1611
|
+
|
|
1612
|
+
if abort_all_flag:
|
|
1613
|
+
killed = abort_all(root)
|
|
1614
|
+
if killed:
|
|
1615
|
+
console.print(f"[green]Aborted {len(killed)} process(es): {killed}[/green]")
|
|
1616
|
+
else:
|
|
1617
|
+
console.print("No tracked processes to abort.")
|
|
1618
|
+
elif pid:
|
|
1619
|
+
if abort_process(root, pid):
|
|
1620
|
+
console.print(f"[green]Aborted PID {pid}[/green]")
|
|
1621
|
+
else:
|
|
1622
|
+
console.print(f"[red]Could not abort PID {pid}[/red]")
|
|
1623
|
+
else:
|
|
1624
|
+
procs = list_processes(root)
|
|
1625
|
+
if not procs:
|
|
1626
|
+
console.print("No tracked processes. Use --pid or --all.")
|
|
1627
|
+
return
|
|
1628
|
+
console.print("Tracked processes:")
|
|
1629
|
+
for p in procs:
|
|
1630
|
+
console.print(f" PID {p.pid} {p.command}")
|
|
1631
|
+
console.print("\nUse --pid <N> or --all to abort.")
|
|
1632
|
+
|
|
1633
|
+
|
|
1546
1634
|
if __name__ == "__main__":
|
|
1547
1635
|
main()
|
|
@@ -43,8 +43,7 @@ def analyze_spend(root: Path) -> AnalysisReport:
|
|
|
43
43
|
severity="info",
|
|
44
44
|
message="No credit data yet.",
|
|
45
45
|
recommendation=(
|
|
46
|
-
"Record usage with `specsmith credits record` "
|
|
47
|
-
"or integrate with your AI agent."
|
|
46
|
+
"Record usage with `specsmith credits record` or integrate with your AI agent."
|
|
48
47
|
),
|
|
49
48
|
)
|
|
50
49
|
)
|
|
@@ -112,8 +111,7 @@ def analyze_spend(root: Path) -> AnalysisReport:
|
|
|
112
111
|
category="governance",
|
|
113
112
|
severity="info",
|
|
114
113
|
message=(
|
|
115
|
-
f"Governance files total {total_gov_lines} lines "
|
|
116
|
-
f"across {len(gov_files)} files."
|
|
114
|
+
f"Governance files total {total_gov_lines} lines across {len(gov_files)} files."
|
|
117
115
|
),
|
|
118
116
|
recommendation=(
|
|
119
117
|
"Ensure agents lazy-load governance files. Only rules.md + workflow.md "
|
|
@@ -167,9 +167,9 @@ def record_usage(
|
|
|
167
167
|
provider=provider,
|
|
168
168
|
tokens_in=tokens_in,
|
|
169
169
|
tokens_out=tokens_out,
|
|
170
|
-
estimated_cost_usd=cost_usd
|
|
171
|
-
|
|
172
|
-
),
|
|
170
|
+
estimated_cost_usd=cost_usd
|
|
171
|
+
if cost_usd is not None
|
|
172
|
+
else estimate_cost(model, tokens_in, tokens_out),
|
|
173
173
|
task=task,
|
|
174
174
|
duration_seconds=duration_seconds,
|
|
175
175
|
)
|
|
@@ -180,7 +180,10 @@ def record_usage(
|
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
def get_summary(
|
|
183
|
-
root: Path,
|
|
183
|
+
root: Path,
|
|
184
|
+
*,
|
|
185
|
+
since: str = "",
|
|
186
|
+
month: str = "",
|
|
184
187
|
) -> CreditSummary:
|
|
185
188
|
"""Get aggregate credit summary with budget alerts."""
|
|
186
189
|
entries = _load_entries(root)
|
|
@@ -222,8 +225,7 @@ def get_summary(
|
|
|
222
225
|
pct = (month_cost / budget.monthly_cap_usd) * 100 if budget.monthly_cap_usd else 0
|
|
223
226
|
if pct >= 100:
|
|
224
227
|
summary.alerts.append(
|
|
225
|
-
f"BUDGET EXCEEDED: ${month_cost:.2f} / ${budget.monthly_cap_usd:.2f} "
|
|
226
|
-
f"({pct:.0f}%)"
|
|
228
|
+
f"BUDGET EXCEEDED: ${month_cost:.2f} / ${budget.monthly_cap_usd:.2f} ({pct:.0f}%)"
|
|
227
229
|
)
|
|
228
230
|
elif pct >= budget.alert_threshold_pct:
|
|
229
231
|
summary.alerts.append(
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Executor — cross-platform process execution with PID tracking and abort.
|
|
4
|
+
|
|
5
|
+
Provides governed command execution with:
|
|
6
|
+
- PID file tracking in .specsmith/pids/
|
|
7
|
+
- Configurable timeout enforcement
|
|
8
|
+
- Cross-platform abort (Windows taskkill / POSIX SIGTERM+SIGKILL)
|
|
9
|
+
- Process listing for agent visibility
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import asdict, dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TrackedProcess:
|
|
27
|
+
"""Metadata for a tracked process."""
|
|
28
|
+
|
|
29
|
+
pid: int
|
|
30
|
+
command: str
|
|
31
|
+
started: str # ISO timestamp
|
|
32
|
+
timeout: int # seconds
|
|
33
|
+
pid_file: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def started_dt(self) -> datetime:
|
|
37
|
+
return datetime.fromisoformat(self.started)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def elapsed(self) -> float:
|
|
41
|
+
return (datetime.now(tz=timezone.utc) - self.started_dt).total_seconds()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_expired(self) -> bool:
|
|
45
|
+
return self.elapsed > self.timeout
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ExecResult:
|
|
50
|
+
"""Result of a tracked execution."""
|
|
51
|
+
|
|
52
|
+
command: str
|
|
53
|
+
exit_code: int
|
|
54
|
+
pid: int
|
|
55
|
+
duration: float
|
|
56
|
+
timed_out: bool = False
|
|
57
|
+
aborted: bool = False
|
|
58
|
+
stdout_file: str = ""
|
|
59
|
+
stderr_file: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pids_dir(root: Path) -> Path:
|
|
63
|
+
d = root / ".specsmith" / "pids"
|
|
64
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _logs_dir(root: Path) -> Path:
|
|
69
|
+
d = root / ".specsmith" / "logs"
|
|
70
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return d
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_pid_file(root: Path, proc: TrackedProcess) -> Path:
|
|
75
|
+
"""Write PID tracking file. Returns path to PID file."""
|
|
76
|
+
pid_file = _pids_dir(root) / f"{proc.pid}.json"
|
|
77
|
+
proc.pid_file = str(pid_file)
|
|
78
|
+
pid_file.write_text(json.dumps(asdict(proc), indent=2), encoding="utf-8")
|
|
79
|
+
return pid_file
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _remove_pid_file(root: Path, pid: int) -> None:
|
|
83
|
+
"""Remove PID tracking file."""
|
|
84
|
+
pid_file = _pids_dir(root) / f"{pid}.json"
|
|
85
|
+
if pid_file.exists():
|
|
86
|
+
pid_file.unlink()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_process_alive(pid: int) -> bool:
|
|
90
|
+
"""Check if a process is still running (cross-platform)."""
|
|
91
|
+
try:
|
|
92
|
+
if sys.platform == "win32":
|
|
93
|
+
# Windows: use tasklist to check
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=5,
|
|
99
|
+
)
|
|
100
|
+
return str(pid) in result.stdout
|
|
101
|
+
else:
|
|
102
|
+
# POSIX: signal 0 checks existence without killing
|
|
103
|
+
os.kill(pid, 0)
|
|
104
|
+
return True
|
|
105
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _kill_process(pid: int, *, graceful_timeout: float = 5.0) -> bool:
|
|
110
|
+
"""Kill a process cross-platform. Returns True if killed.
|
|
111
|
+
|
|
112
|
+
Strategy:
|
|
113
|
+
- POSIX: SIGTERM → wait → SIGKILL
|
|
114
|
+
- Windows: taskkill → taskkill /F
|
|
115
|
+
"""
|
|
116
|
+
if not _is_process_alive(pid):
|
|
117
|
+
return True # Already dead
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
if sys.platform == "win32":
|
|
121
|
+
# Graceful first
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["taskkill", "/PID", str(pid)],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
timeout=graceful_timeout,
|
|
126
|
+
)
|
|
127
|
+
time.sleep(min(graceful_timeout, 2.0))
|
|
128
|
+
if not _is_process_alive(pid):
|
|
129
|
+
return True
|
|
130
|
+
# Force kill
|
|
131
|
+
subprocess.run(
|
|
132
|
+
["taskkill", "/F", "/PID", str(pid), "/T"],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
timeout=5,
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
# SIGTERM first
|
|
138
|
+
os.kill(pid, signal.SIGTERM)
|
|
139
|
+
deadline = time.monotonic() + graceful_timeout
|
|
140
|
+
while time.monotonic() < deadline:
|
|
141
|
+
if not _is_process_alive(pid):
|
|
142
|
+
return True
|
|
143
|
+
time.sleep(0.2)
|
|
144
|
+
# SIGKILL
|
|
145
|
+
os.kill(pid, signal.SIGKILL)
|
|
146
|
+
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
time.sleep(0.5)
|
|
150
|
+
return not _is_process_alive(pid)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_tracked(
|
|
154
|
+
root: Path,
|
|
155
|
+
command: str,
|
|
156
|
+
*,
|
|
157
|
+
timeout: int = 120,
|
|
158
|
+
capture: bool = True,
|
|
159
|
+
) -> ExecResult:
|
|
160
|
+
"""Execute a command with PID tracking and timeout enforcement.
|
|
161
|
+
|
|
162
|
+
- Writes PID file to .specsmith/pids/<pid>.json
|
|
163
|
+
- Enforces timeout via subprocess.Popen + polling
|
|
164
|
+
- Logs stdout/stderr to .specsmith/logs/
|
|
165
|
+
- Cleans up PID file on completion
|
|
166
|
+
- Cross-platform: works on Windows, Linux, macOS
|
|
167
|
+
"""
|
|
168
|
+
started = datetime.now(tz=timezone.utc).isoformat()
|
|
169
|
+
ts = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
170
|
+
|
|
171
|
+
stdout_path = _logs_dir(root) / f"exec_{ts}.stdout"
|
|
172
|
+
stderr_path = _logs_dir(root) / f"exec_{ts}.stderr"
|
|
173
|
+
|
|
174
|
+
# Determine shell
|
|
175
|
+
if sys.platform == "win32":
|
|
176
|
+
shell_args: list[str] = ["cmd", "/c", command]
|
|
177
|
+
else:
|
|
178
|
+
shell_args = ["bash", "-c", command]
|
|
179
|
+
|
|
180
|
+
stdout_fh = open(stdout_path, "w", encoding="utf-8") if capture else None # noqa: SIM115
|
|
181
|
+
stderr_fh = open(stderr_path, "w", encoding="utf-8") if capture else None # noqa: SIM115
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
proc = subprocess.Popen( # noqa: S603
|
|
185
|
+
shell_args,
|
|
186
|
+
stdout=stdout_fh or subprocess.PIPE,
|
|
187
|
+
stderr=stderr_fh or subprocess.PIPE,
|
|
188
|
+
cwd=str(root),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
tracked = TrackedProcess(
|
|
192
|
+
pid=proc.pid,
|
|
193
|
+
command=command,
|
|
194
|
+
started=started,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
)
|
|
197
|
+
pid_file = _write_pid_file(root, tracked)
|
|
198
|
+
|
|
199
|
+
start = time.monotonic()
|
|
200
|
+
timed_out = False
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
proc.wait(timeout=timeout)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
timed_out = True
|
|
206
|
+
_kill_process(proc.pid)
|
|
207
|
+
proc.wait(timeout=5) # Reap zombie
|
|
208
|
+
|
|
209
|
+
duration = time.monotonic() - start
|
|
210
|
+
exit_code = proc.returncode if proc.returncode is not None else -1
|
|
211
|
+
|
|
212
|
+
# Clean up PID file
|
|
213
|
+
if pid_file.exists():
|
|
214
|
+
pid_file.unlink()
|
|
215
|
+
|
|
216
|
+
return ExecResult(
|
|
217
|
+
command=command,
|
|
218
|
+
exit_code=124 if timed_out else exit_code,
|
|
219
|
+
pid=proc.pid,
|
|
220
|
+
duration=duration,
|
|
221
|
+
timed_out=timed_out,
|
|
222
|
+
stdout_file=str(stdout_path) if capture else "",
|
|
223
|
+
stderr_file=str(stderr_path) if capture else "",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
finally:
|
|
227
|
+
if stdout_fh:
|
|
228
|
+
stdout_fh.close()
|
|
229
|
+
if stderr_fh:
|
|
230
|
+
stderr_fh.close()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def list_processes(root: Path) -> list[TrackedProcess]:
|
|
234
|
+
"""List all tracked processes. Prunes stale PID files for dead processes."""
|
|
235
|
+
pids_dir = _pids_dir(root)
|
|
236
|
+
result: list[TrackedProcess] = []
|
|
237
|
+
|
|
238
|
+
for pid_file in pids_dir.glob("*.json"):
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(pid_file.read_text(encoding="utf-8"))
|
|
241
|
+
tp = TrackedProcess(**data)
|
|
242
|
+
if _is_process_alive(tp.pid):
|
|
243
|
+
result.append(tp)
|
|
244
|
+
else:
|
|
245
|
+
# Stale PID file — process already exited
|
|
246
|
+
pid_file.unlink()
|
|
247
|
+
except (json.JSONDecodeError, TypeError, OSError):
|
|
248
|
+
pid_file.unlink(missing_ok=True)
|
|
249
|
+
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def abort_process(root: Path, pid: int) -> bool:
|
|
254
|
+
"""Abort a specific tracked process by PID. Returns True if killed."""
|
|
255
|
+
killed = _kill_process(pid)
|
|
256
|
+
_remove_pid_file(root, pid)
|
|
257
|
+
return killed
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def abort_all(root: Path) -> list[int]:
|
|
261
|
+
"""Abort all tracked processes. Returns list of killed PIDs."""
|
|
262
|
+
killed: list[int] = []
|
|
263
|
+
for tp in list_processes(root):
|
|
264
|
+
if _kill_process(tp.pid):
|
|
265
|
+
killed.append(tp.pid)
|
|
266
|
+
_remove_pid_file(root, tp.pid)
|
|
267
|
+
return killed
|
|
@@ -790,15 +790,31 @@ def _extract_governance_sections(root: Path) -> dict[str, str]:
|
|
|
790
790
|
# Body-level content keywords for secondary classification.
|
|
791
791
|
# Used when heading doesn't match — scan body text for strong signals.
|
|
792
792
|
_BODY_ARCHITECTURE_KW = [
|
|
793
|
-
"register map",
|
|
794
|
-
"
|
|
795
|
-
"
|
|
796
|
-
"
|
|
793
|
+
"register map",
|
|
794
|
+
"address offset",
|
|
795
|
+
"0x0",
|
|
796
|
+
"register name",
|
|
797
|
+
"block diagram",
|
|
798
|
+
"data flow",
|
|
799
|
+
"interface spec",
|
|
800
|
+
"directory layout",
|
|
801
|
+
"src/",
|
|
802
|
+
"repository structure",
|
|
803
|
+
"milestone",
|
|
804
|
+
"roadmap",
|
|
805
|
+
"completion",
|
|
806
|
+
"phase 2 target",
|
|
797
807
|
]
|
|
798
808
|
_BODY_DRIFT_KW = [
|
|
799
|
-
"subst v:",
|
|
800
|
-
"
|
|
801
|
-
"
|
|
809
|
+
"subst v:",
|
|
810
|
+
"path-length",
|
|
811
|
+
"one-time setup",
|
|
812
|
+
"per-machine",
|
|
813
|
+
"environment variable",
|
|
814
|
+
"install once",
|
|
815
|
+
"bootstrap",
|
|
816
|
+
"windows path",
|
|
817
|
+
"ntfs",
|
|
802
818
|
]
|
|
803
819
|
|
|
804
820
|
for heading, body in sections.items():
|
|
@@ -137,7 +137,7 @@ def _build_file_map(config: ProjectConfig) -> list[tuple[str, str]]:
|
|
|
137
137
|
# Community / compliance files
|
|
138
138
|
files.extend(_build_community_files(config))
|
|
139
139
|
|
|
140
|
-
#
|
|
140
|
+
# Language-specific project files (#41)
|
|
141
141
|
if config.type in (
|
|
142
142
|
ProjectType.CLI_PYTHON,
|
|
143
143
|
ProjectType.LIBRARY_PYTHON,
|
|
@@ -146,10 +146,38 @@ def _build_file_map(config: ProjectConfig) -> list[tuple[str, str]]:
|
|
|
146
146
|
):
|
|
147
147
|
files.append(("pyproject.toml.j2", "pyproject.toml"))
|
|
148
148
|
files.append(("python/init.py.j2", f"src/{config.package_name}/__init__.py"))
|
|
149
|
-
|
|
150
149
|
if config.type == ProjectType.CLI_PYTHON:
|
|
151
150
|
files.append(("python/cli.py.j2", f"src/{config.package_name}/cli.py"))
|
|
152
151
|
|
|
152
|
+
elif config.type in (ProjectType.CLI_RUST, ProjectType.LIBRARY_RUST):
|
|
153
|
+
files.append(("rust/Cargo.toml.j2", "Cargo.toml"))
|
|
154
|
+
if config.type == ProjectType.CLI_RUST:
|
|
155
|
+
files.append(("rust/main.rs.j2", "src/main.rs"))
|
|
156
|
+
|
|
157
|
+
elif config.type == ProjectType.CLI_GO:
|
|
158
|
+
files.append(("go/go.mod.j2", "go.mod"))
|
|
159
|
+
files.append(("go/main.go.j2", "cmd/main.go"))
|
|
160
|
+
|
|
161
|
+
elif config.type in (
|
|
162
|
+
ProjectType.WEB_FRONTEND,
|
|
163
|
+
ProjectType.FULLSTACK_JS,
|
|
164
|
+
):
|
|
165
|
+
files.append(("js/package.json.j2", "package.json"))
|
|
166
|
+
|
|
167
|
+
# ReadTheDocs integration (#38) — Python and doc projects
|
|
168
|
+
if config.type in (
|
|
169
|
+
ProjectType.CLI_PYTHON,
|
|
170
|
+
ProjectType.LIBRARY_PYTHON,
|
|
171
|
+
ProjectType.SPEC_DOCUMENT,
|
|
172
|
+
ProjectType.USER_MANUAL,
|
|
173
|
+
):
|
|
174
|
+
files.append(("docs/readthedocs.yaml.j2", ".readthedocs.yaml"))
|
|
175
|
+
files.append(("docs/mkdocs.yml.j2", "mkdocs.yml"))
|
|
176
|
+
|
|
177
|
+
# Release workflow template (#44) — gitflow + GitHub projects
|
|
178
|
+
if config.vcs_platform == "github" and config.branching_strategy == "gitflow":
|
|
179
|
+
files.append(("workflows/release.yml.j2", ".github/workflows/release.yml"))
|
|
180
|
+
|
|
153
181
|
return files
|
|
154
182
|
|
|
155
183
|
|
|
@@ -406,9 +434,7 @@ def _build_community_files(config: ProjectConfig) -> list[tuple[str, str]]:
|
|
|
406
434
|
files.append(("community/code_of_conduct.md.j2", "CODE_OF_CONDUCT.md"))
|
|
407
435
|
|
|
408
436
|
if "pr-template" in cf and config.vcs_platform == "github":
|
|
409
|
-
files.append(
|
|
410
|
-
("community/pull_request_template.md.j2", ".github/PULL_REQUEST_TEMPLATE.md")
|
|
411
|
-
)
|
|
437
|
+
files.append(("community/pull_request_template.md.j2", ".github/PULL_REQUEST_TEMPLATE.md"))
|
|
412
438
|
|
|
413
439
|
if "issue-templates" in cf and config.vcs_platform == "github":
|
|
414
440
|
files.extend(
|
|
@@ -138,8 +138,7 @@ def run_session_end(root: Path) -> SessionReport:
|
|
|
138
138
|
name="credits",
|
|
139
139
|
status="ok",
|
|
140
140
|
message=(
|
|
141
|
-
f"Credits: ${cs.total_cost_usd:.4f} total, "
|
|
142
|
-
f"{cs.session_count} session(s)"
|
|
141
|
+
f"Credits: ${cs.total_cost_usd:.4f} total, {cs.session_count} session(s)"
|
|
143
142
|
),
|
|
144
143
|
)
|
|
145
144
|
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
site_name: {{ project.name }}
|
|
2
|
+
site_description: {{ project.description or project.name }}
|
|
3
|
+
repo_url: https://github.com/{{ project.name }}
|
|
4
|
+
edit_uri: edit/main/docs/
|
|
5
|
+
|
|
6
|
+
docs_dir: docs/site
|
|
7
|
+
|
|
8
|
+
theme:
|
|
9
|
+
name: material
|
|
10
|
+
palette:
|
|
11
|
+
- scheme: default
|
|
12
|
+
primary: deep purple
|
|
13
|
+
toggle:
|
|
14
|
+
icon: material/brightness-7
|
|
15
|
+
name: Switch to dark mode
|
|
16
|
+
- scheme: slate
|
|
17
|
+
primary: deep purple
|
|
18
|
+
toggle:
|
|
19
|
+
icon: material/brightness-4
|
|
20
|
+
name: Switch to light mode
|
|
21
|
+
features:
|
|
22
|
+
- navigation.sections
|
|
23
|
+
- content.code.copy
|
|
24
|
+
|
|
25
|
+
nav:
|
|
26
|
+
- Home: index.md
|
|
27
|
+
|
|
28
|
+
markdown_extensions:
|
|
29
|
+
- tables
|
|
30
|
+
- admonition
|
|
31
|
+
- pymdownx.highlight
|
|
32
|
+
- pymdownx.superfences
|
|
33
|
+
- toc:
|
|
34
|
+
permalink: true
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
|
|
3
|
+
build:
|
|
4
|
+
os: ubuntu-22.04
|
|
5
|
+
tools:
|
|
6
|
+
{% if project.language == 'python' %}
|
|
7
|
+
python: "3.12"
|
|
8
|
+
{% elif project.language in ('javascript', 'typescript') %}
|
|
9
|
+
nodejs: "20"
|
|
10
|
+
{% endif %}
|
|
11
|
+
|
|
12
|
+
mkdocs:
|
|
13
|
+
configuration: mkdocs.yml
|
|
14
|
+
|
|
15
|
+
{% if project.language == 'python' %}
|
|
16
|
+
python:
|
|
17
|
+
install:
|
|
18
|
+
- method: pip
|
|
19
|
+
path: .
|
|
20
|
+
extra_requirements:
|
|
21
|
+
- docs
|
|
22
|
+
{% endif %}
|