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.
Files changed (104) hide show
  1. {specsmith-0.2.1.dev40/src/specsmith.egg-info → specsmith-0.2.1.dev42}/PKG-INFO +1 -1
  2. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/pyproject.toml +1 -1
  3. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/auditor.py +9 -6
  4. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/cli.py +92 -4
  5. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/credit_analyzer.py +2 -4
  6. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/credits.py +8 -6
  7. specsmith-0.2.1.dev42/src/specsmith/executor.py +267 -0
  8. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/importer.py +23 -7
  9. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/scaffolder.py +31 -5
  10. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/session.py +1 -2
  11. specsmith-0.2.1.dev42/src/specsmith/templates/docs/mkdocs.yml.j2 +34 -0
  12. specsmith-0.2.1.dev42/src/specsmith/templates/docs/readthedocs.yaml.j2 +22 -0
  13. specsmith-0.2.1.dev42/src/specsmith/templates/go/go.mod.j2 +3 -0
  14. specsmith-0.2.1.dev42/src/specsmith/templates/go/main.go.j2 +7 -0
  15. specsmith-0.2.1.dev42/src/specsmith/templates/js/package.json.j2 +48 -0
  16. specsmith-0.2.1.dev42/src/specsmith/templates/rust/Cargo.toml.j2 +19 -0
  17. specsmith-0.2.1.dev42/src/specsmith/templates/rust/main.rs.j2 +15 -0
  18. specsmith-0.2.1.dev42/src/specsmith/templates/scripts/exec.cmd.j2 +63 -0
  19. specsmith-0.2.1.dev42/src/specsmith/templates/scripts/exec.sh.j2 +60 -0
  20. specsmith-0.2.1.dev42/src/specsmith/templates/workflows/release.yml.j2 +101 -0
  21. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/updater.py +5 -2
  22. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42/src/specsmith.egg-info}/PKG-INFO +1 -1
  23. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/SOURCES.txt +9 -0
  24. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_auditor.py +1 -0
  25. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_cli.py +1 -0
  26. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_integrations.py +1 -0
  27. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_scaffolder.py +1 -0
  28. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_smoke.py +1 -2
  29. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_vcs.py +1 -0
  30. specsmith-0.2.1.dev40/src/specsmith/templates/scripts/exec.cmd.j2 +0 -33
  31. specsmith-0.2.1.dev40/src/specsmith/templates/scripts/exec.sh.j2 +0 -38
  32. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/LICENSE +0 -0
  33. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/README.md +0 -0
  34. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/setup.cfg +0 -0
  35. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/__init__.py +0 -0
  36. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/__main__.py +0 -0
  37. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/architect.py +0 -0
  38. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/commands/__init__.py +0 -0
  39. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/compressor.py +0 -0
  40. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/config.py +0 -0
  41. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/differ.py +0 -0
  42. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/doctor.py +0 -0
  43. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/exporter.py +0 -0
  44. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/__init__.py +0 -0
  45. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/aider.py +0 -0
  46. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/base.py +0 -0
  47. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/claude_code.py +0 -0
  48. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/copilot.py +0 -0
  49. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/cursor.py +0 -0
  50. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/gemini.py +0 -0
  51. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/warp.py +0 -0
  52. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/integrations/windsurf.py +0 -0
  53. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/ledger.py +0 -0
  54. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/plugins.py +0 -0
  55. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/releaser.py +0 -0
  56. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/requirements.py +0 -0
  57. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/agents.md.j2 +0 -0
  58. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
  59. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
  60. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/contributing.md.j2 +0 -0
  61. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
  62. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
  63. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/license-MIT.j2 +0 -0
  64. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
  65. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/community/security.md.j2 +0 -0
  66. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  67. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  68. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  69. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
  70. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/editorconfig.j2 +0 -0
  71. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/gitattributes.j2 +0 -0
  72. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/gitignore.j2 +0 -0
  73. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
  74. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  75. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  76. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  77. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  78. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
  79. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/ledger.md.j2 +0 -0
  80. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/pyproject.toml.j2 +0 -0
  81. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/python/cli.py.j2 +0 -0
  82. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/python/init.py.j2 +0 -0
  83. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/readme.md.j2 +0 -0
  84. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  85. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  86. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  87. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  88. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/tools.py +0 -0
  89. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/upgrader.py +0 -0
  90. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/validator.py +0 -0
  91. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/__init__.py +0 -0
  92. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/base.py +0 -0
  93. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/bitbucket.py +0 -0
  94. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/github.py +0 -0
  95. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs/gitlab.py +0 -0
  96. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith/vcs_commands.py +0 -0
  97. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/dependency_links.txt +0 -0
  98. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/entry_points.txt +0 -0
  99. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/requires.txt +0 -0
  100. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/src/specsmith.egg-info/top_level.txt +0 -0
  101. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_compressor.py +0 -0
  102. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_importer.py +0 -0
  103. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_tools.py +0 -0
  104. {specsmith-0.2.1.dev40 → specsmith-0.2.1.dev42}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.2.1.dev40
3
+ Version: 0.2.1.dev42
4
4
  Summary: Forge governed project scaffolds from the Agentic AI Development Workflow Specification.
5
5
  Author: BitConcepts
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specsmith"
7
- version = "0.2.1.dev40"
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 = bool(
129
- list((root / "docs").glob("**/architecture*"))
130
- + list((root / "docs").glob("**/ARCHITECTURE*"))
131
- ) if (root / "docs").is_dir() else False
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, cap: float | None, alert_pct: int | None, watermarks: str | None,
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 if cost_usd is not None else estimate_cost(
171
- model, tokens_in, tokens_out
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, *, since: str = "", month: str = "",
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", "address offset", "0x0", "register name",
794
- "block diagram", "data flow", "interface spec",
795
- "directory layout", "src/", "repository structure",
796
- "milestone", "roadmap", "completion", "phase 2 target",
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:", "path-length", "one-time setup", "per-machine",
800
- "environment variable", "install once", "bootstrap",
801
- "windows path", "ntfs",
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
- # Python project types get pyproject.toml and src layout
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 %}
@@ -0,0 +1,3 @@
1
+ module {{ package_name }}
2
+
3
+ go 1.22
@@ -0,0 +1,7 @@
1
+ package main
2
+
3
+ import "fmt"
4
+
5
+ func main() {
6
+ fmt.Println("{{ project.name }}")
7
+ }