specsmith 0.2.1.dev41__tar.gz → 0.2.1.dev43__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.dev41/src/specsmith.egg-info → specsmith-0.2.1.dev43}/PKG-INFO +1 -1
  2. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/pyproject.toml +1 -1
  3. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/cli.py +101 -3
  4. specsmith-0.2.1.dev43/src/specsmith/executor.py +267 -0
  5. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/scaffolder.py +31 -3
  6. specsmith-0.2.1.dev43/src/specsmith/templates/docs/mkdocs.yml.j2 +34 -0
  7. specsmith-0.2.1.dev43/src/specsmith/templates/docs/readthedocs.yaml.j2 +22 -0
  8. specsmith-0.2.1.dev43/src/specsmith/templates/go/go.mod.j2 +3 -0
  9. specsmith-0.2.1.dev43/src/specsmith/templates/go/main.go.j2 +7 -0
  10. specsmith-0.2.1.dev43/src/specsmith/templates/js/package.json.j2 +48 -0
  11. specsmith-0.2.1.dev43/src/specsmith/templates/rust/Cargo.toml.j2 +19 -0
  12. specsmith-0.2.1.dev43/src/specsmith/templates/rust/main.rs.j2 +15 -0
  13. specsmith-0.2.1.dev43/src/specsmith/templates/scripts/exec.cmd.j2 +63 -0
  14. specsmith-0.2.1.dev43/src/specsmith/templates/scripts/exec.sh.j2 +60 -0
  15. specsmith-0.2.1.dev43/src/specsmith/templates/workflows/release.yml.j2 +101 -0
  16. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/upgrader.py +132 -24
  17. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43/src/specsmith.egg-info}/PKG-INFO +1 -1
  18. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/SOURCES.txt +10 -1
  19. specsmith-0.2.1.dev41/src/specsmith/templates/scripts/exec.cmd.j2 +0 -33
  20. specsmith-0.2.1.dev41/src/specsmith/templates/scripts/exec.sh.j2 +0 -38
  21. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/LICENSE +0 -0
  22. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/README.md +0 -0
  23. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/setup.cfg +0 -0
  24. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/__init__.py +0 -0
  25. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/__main__.py +0 -0
  26. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/architect.py +0 -0
  27. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/auditor.py +0 -0
  28. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/commands/__init__.py +0 -0
  29. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/compressor.py +0 -0
  30. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/config.py +0 -0
  31. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/credit_analyzer.py +0 -0
  32. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/credits.py +0 -0
  33. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/differ.py +0 -0
  34. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/doctor.py +0 -0
  35. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/exporter.py +0 -0
  36. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/importer.py +0 -0
  37. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/__init__.py +0 -0
  38. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/aider.py +0 -0
  39. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/base.py +0 -0
  40. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/claude_code.py +0 -0
  41. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/copilot.py +0 -0
  42. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/cursor.py +0 -0
  43. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/gemini.py +0 -0
  44. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/warp.py +0 -0
  45. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/integrations/windsurf.py +0 -0
  46. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/ledger.py +0 -0
  47. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/plugins.py +0 -0
  48. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/releaser.py +0 -0
  49. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/requirements.py +0 -0
  50. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/session.py +0 -0
  51. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/agents.md.j2 +0 -0
  52. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
  53. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
  54. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/contributing.md.j2 +0 -0
  55. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
  56. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
  57. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/license-MIT.j2 +0 -0
  58. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
  59. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/community/security.md.j2 +0 -0
  60. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  61. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  62. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  63. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/docs/workflow.md.j2 +0 -0
  64. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/editorconfig.j2 +0 -0
  65. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/gitattributes.j2 +0 -0
  66. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/gitignore.j2 +0 -0
  67. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
  68. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  69. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  70. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  71. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  72. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/governance/workflow.md.j2 +0 -0
  73. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/ledger.md.j2 +0 -0
  74. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/python/cli.py.j2 +0 -0
  75. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/python/init.py.j2 +0 -0
  76. {specsmith-0.2.1.dev41/src/specsmith/templates → specsmith-0.2.1.dev43/src/specsmith/templates/python}/pyproject.toml.j2 +0 -0
  77. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/readme.md.j2 +0 -0
  78. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  79. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  80. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  81. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  82. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/tools.py +0 -0
  83. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/updater.py +0 -0
  84. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/validator.py +0 -0
  85. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/__init__.py +0 -0
  86. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/base.py +0 -0
  87. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/bitbucket.py +0 -0
  88. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/github.py +0 -0
  89. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs/gitlab.py +0 -0
  90. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith/vcs_commands.py +0 -0
  91. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/dependency_links.txt +0 -0
  92. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/entry_points.txt +0 -0
  93. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/requires.txt +0 -0
  94. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/src/specsmith.egg-info/top_level.txt +0 -0
  95. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_auditor.py +0 -0
  96. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_cli.py +0 -0
  97. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_compressor.py +0 -0
  98. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_importer.py +0 -0
  99. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_integrations.py +0 -0
  100. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_scaffolder.py +0 -0
  101. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_smoke.py +0 -0
  102. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_tools.py +0 -0
  103. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_validator.py +0 -0
  104. {specsmith-0.2.1.dev41 → specsmith-0.2.1.dev43}/tests/test_vcs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.2.1.dev41
3
+ Version: 0.2.1.dev43
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.dev41"
7
+ version = "0.2.1.dev43"
8
8
  description = "Forge governed project scaffolds from the Agentic AI Development Workflow Specification."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -291,12 +291,23 @@ def compress(project_dir: str, threshold: int, keep_recent: int) -> None:
291
291
  default=".",
292
292
  help="Project root directory.",
293
293
  )
294
- def upgrade(spec_version: str | None, project_dir: str) -> None:
295
- """Update governance files to match a newer spec version."""
294
+ @click.option(
295
+ "--full",
296
+ is_flag=True,
297
+ default=False,
298
+ help="Full sync: also regenerate exec shims, CI, agent files, create missing community files.",
299
+ )
300
+ def upgrade(spec_version: str | None, project_dir: str, full: bool) -> None:
301
+ """Update governance files to match a newer spec version.
302
+
303
+ With --full: also regenerates exec shims (PID tracking), CI configs,
304
+ agent integrations, and creates missing community files. Safe: never
305
+ overwrites AGENTS.md, LEDGER.md, or user documentation.
306
+ """
296
307
  from specsmith.upgrader import run_upgrade
297
308
 
298
309
  root = Path(project_dir).resolve()
299
- result = run_upgrade(root, target_version=spec_version)
310
+ result = run_upgrade(root, target_version=spec_version, full=full)
300
311
  console.print(result.message)
301
312
 
302
313
  if result.updated_files:
@@ -1544,5 +1555,92 @@ def serve(port: int) -> None:
1544
1555
  )
1545
1556
 
1546
1557
 
1558
+ # ---------------------------------------------------------------------------
1559
+ # Process execution and abort
1560
+ # ---------------------------------------------------------------------------
1561
+
1562
+
1563
+ @main.command(name="exec")
1564
+ @click.argument("command")
1565
+ @click.option("--timeout", default=120, help="Timeout in seconds (default: 120).")
1566
+ @click.option("--project-dir", type=click.Path(exists=True), default=".")
1567
+ def exec_cmd(command: str, timeout: int, project_dir: str) -> None:
1568
+ """Execute a command with PID tracking and timeout enforcement.
1569
+
1570
+ Tracks the process in .specsmith/pids/ so it can be listed (specsmith ps)
1571
+ or aborted (specsmith abort). Logs stdout/stderr to .specsmith/logs/.
1572
+ Works cross-platform: Windows, Linux, macOS.
1573
+ """
1574
+ from specsmith.executor import run_tracked
1575
+
1576
+ root = Path(project_dir).resolve()
1577
+ console.print(f"[bold]exec[/bold] {command} (timeout={timeout}s)")
1578
+
1579
+ result = run_tracked(root, command, timeout=timeout)
1580
+
1581
+ if result.timed_out:
1582
+ console.print(f"[red]TIMEOUT[/red] after {timeout}s (PID {result.pid})")
1583
+ elif result.exit_code == 0:
1584
+ console.print(f"[green]OK[/green] ({result.duration:.1f}s) — exit code 0")
1585
+ else:
1586
+ console.print(f"[red]FAILED[/red] ({result.duration:.1f}s) — exit code {result.exit_code}")
1587
+ if result.stdout_file:
1588
+ console.print(f" stdout: {result.stdout_file}")
1589
+ if result.stderr_file:
1590
+ console.print(f" stderr: {result.stderr_file}")
1591
+ raise SystemExit(result.exit_code)
1592
+
1593
+
1594
+ @main.command(name="ps")
1595
+ @click.option("--project-dir", type=click.Path(exists=True), default=".")
1596
+ def ps_cmd(project_dir: str) -> None:
1597
+ """List tracked running processes."""
1598
+ from specsmith.executor import list_processes
1599
+
1600
+ root = Path(project_dir).resolve()
1601
+ procs = list_processes(root)
1602
+ if not procs:
1603
+ console.print("No tracked processes running.")
1604
+ return
1605
+ for p in procs:
1606
+ elapsed = p.elapsed
1607
+ remaining = max(0, p.timeout - elapsed)
1608
+ status = "[red]EXPIRED[/red]" if p.is_expired else f"{remaining:.0f}s left"
1609
+ console.print(f" PID {p.pid} {status} {p.command}")
1610
+ console.print(f"\n {len(procs)} process(es)")
1611
+
1612
+
1613
+ @main.command(name="abort")
1614
+ @click.option("--pid", type=int, default=None, help="Abort a specific PID.")
1615
+ @click.option("--all", "abort_all_flag", is_flag=True, default=False, help="Abort all tracked.")
1616
+ @click.option("--project-dir", type=click.Path(exists=True), default=".")
1617
+ def abort_cmd(pid: int | None, abort_all_flag: bool, project_dir: str) -> None:
1618
+ """Abort tracked process(es). Sends SIGTERM then SIGKILL (POSIX) or taskkill (Windows)."""
1619
+ from specsmith.executor import abort_all, abort_process, list_processes
1620
+
1621
+ root = Path(project_dir).resolve()
1622
+
1623
+ if abort_all_flag:
1624
+ killed = abort_all(root)
1625
+ if killed:
1626
+ console.print(f"[green]Aborted {len(killed)} process(es): {killed}[/green]")
1627
+ else:
1628
+ console.print("No tracked processes to abort.")
1629
+ elif pid:
1630
+ if abort_process(root, pid):
1631
+ console.print(f"[green]Aborted PID {pid}[/green]")
1632
+ else:
1633
+ console.print(f"[red]Could not abort PID {pid}[/red]")
1634
+ else:
1635
+ procs = list_processes(root)
1636
+ if not procs:
1637
+ console.print("No tracked processes. Use --pid or --all.")
1638
+ return
1639
+ console.print("Tracked processes:")
1640
+ for p in procs:
1641
+ console.print(f" PID {p.pid} {p.command}")
1642
+ console.print("\nUse --pid <N> or --all to abort.")
1643
+
1644
+
1547
1645
  if __name__ == "__main__":
1548
1646
  main()
@@ -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
@@ -137,19 +137,47 @@ 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,
144
144
  ProjectType.BACKEND_FRONTEND,
145
145
  ProjectType.BACKEND_FRONTEND_TRAY,
146
146
  ):
147
- files.append(("pyproject.toml.j2", "pyproject.toml"))
147
+ files.append(("python/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
 
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "{{ package_name }}",
3
+ "version": "0.1.0",
4
+ "description": "{{ project.description or project.name }}",
5
+ "license": "{{ project.license }}",
6
+ {% if project.type.value == 'web-frontend' %}
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "lint": "eslint src/",
11
+ "test": "vitest"
12
+ },
13
+ "dependencies": {},
14
+ "devDependencies": {
15
+ "vite": "^6.0.0",
16
+ "eslint": "^9.0.0",
17
+ "vitest": "^3.0.0"
18
+ }
19
+ {% elif project.type.value == 'fullstack-js' %}
20
+ "scripts": {
21
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
22
+ "dev:server": "node server/src/index.js",
23
+ "dev:client": "vite",
24
+ "build": "vite build",
25
+ "lint": "eslint .",
26
+ "test": "vitest"
27
+ },
28
+ "dependencies": {
29
+ "express": "^5.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "concurrently": "^9.0.0",
33
+ "eslint": "^9.0.0",
34
+ "vitest": "^3.0.0"
35
+ }
36
+ {% else %}
37
+ "scripts": {
38
+ "start": "node src/index.js",
39
+ "lint": "eslint src/",
40
+ "test": "vitest"
41
+ },
42
+ "dependencies": {},
43
+ "devDependencies": {
44
+ "eslint": "^9.0.0",
45
+ "vitest": "^3.0.0"
46
+ }
47
+ {% endif %}
48
+ }
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "{{ package_name }}"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "{{ project.description or project.name }}"
6
+ license = "{{ project.license }}"
7
+
8
+ [dependencies]
9
+ {% if project.type.value == 'cli-rust' %}
10
+ clap = { version = "4", features = ["derive"] }
11
+ {% endif %}
12
+
13
+ [dev-dependencies]
14
+
15
+ {% if project.type.value == 'cli-rust' %}
16
+ [[bin]]
17
+ name = "{{ package_name }}"
18
+ path = "src/main.rs"
19
+ {% endif %}
@@ -0,0 +1,15 @@
1
+ use clap::Parser;
2
+
3
+ /// {{ project.description or project.name }}
4
+ #[derive(Parser, Debug)]
5
+ #[command(version, about)]
6
+ struct Args {
7
+ /// Name to greet
8
+ #[arg(short, long, default_value = "world")]
9
+ name: String,
10
+ }
11
+
12
+ fn main() {
13
+ let args = Args::parse();
14
+ println!("Hello, {}!", args.name);
15
+ }
@@ -0,0 +1,63 @@
1
+ @echo off
2
+ REM {{ project.name }} — Command Execution Shim (Windows)
3
+ REM Wraps external commands with PID tracking, timeout enforcement, and abort support.
4
+ REM Usage: scripts\exec.cmd "<command>" [timeout_seconds]
5
+ REM
6
+ REM PID files: .specsmith\pids\<pid>.json (for specsmith ps / specsmith abort)
7
+ REM Logs: .specsmith\logs\exec_<timestamp>.stdout/.stderr
8
+ REM Prefer: specsmith exec "<command>" --timeout <N> (Python-based, full tracking)
9
+
10
+ setlocal enabledelayedexpansion
11
+
12
+ set "COMMAND=%~1"
13
+ set "TIMEOUT_SEC=%~2"
14
+ if "%TIMEOUT_SEC%"=="" set "TIMEOUT_SEC=120"
15
+
16
+ set "PROJECT_ROOT=%~dp0.."
17
+ set "PID_DIR=%PROJECT_ROOT%\.specsmith\pids"
18
+ set "LOG_DIR=%PROJECT_ROOT%\.specsmith\logs"
19
+ if not exist "%PID_DIR%" mkdir "%PID_DIR%"
20
+ if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
21
+
22
+ for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set "DT=%%I"
23
+ set "TIMESTAMP=%DT:~0,4%-%DT:~4,2%-%DT:~6,2%_%DT:~8,2%-%DT:~10,2%-%DT:~12,2%"
24
+ set "STDOUT_LOG=%LOG_DIR%\exec_%TIMESTAMP%.stdout"
25
+ set "STDERR_LOG=%LOG_DIR%\exec_%TIMESTAMP%.stderr"
26
+
27
+ echo [exec] Command : %COMMAND%
28
+ echo [exec] Timeout : %TIMEOUT_SEC%s
29
+
30
+ REM Launch command and capture PID
31
+ start /b cmd /c "%COMMAND%" > "%STDOUT_LOG%" 2> "%STDERR_LOG%"
32
+ for /f "tokens=2" %%P in ('tasklist /fi "imagename eq cmd.exe" /nh ^| findstr /i "cmd"') do (
33
+ set "CMD_PID=%%P"
34
+ )
35
+
36
+ REM Write PID file for tracking
37
+ echo {"pid": %CMD_PID%, "command": "%COMMAND%", "timeout": %TIMEOUT_SEC%} > "%PID_DIR%\%CMD_PID%.json"
38
+
39
+ REM Wait with timeout
40
+ set /a ELAPSED=0
41
+ :wait_loop
42
+ tasklist /fi "PID eq %CMD_PID%" /nh 2>nul | findstr /i "%CMD_PID%" >nul
43
+ if errorlevel 1 goto :done
44
+ if %ELAPSED% geq %TIMEOUT_SEC% goto :timeout
45
+ timeout /t 1 /nobreak >nul
46
+ set /a ELAPSED+=1
47
+ goto :wait_loop
48
+
49
+ :timeout
50
+ echo [exec] TIMEOUT after %TIMEOUT_SEC%s — killing PID %CMD_PID%
51
+ taskkill /F /PID %CMD_PID% /T >nul 2>&1
52
+ del "%PID_DIR%\%CMD_PID%.json" >nul 2>&1
53
+ exit /b 124
54
+
55
+ :done
56
+ del "%PID_DIR%\%CMD_PID%.json" >nul 2>&1
57
+ set "EXIT_CODE=%ERRORLEVEL%"
58
+ if %EXIT_CODE% equ 0 (
59
+ echo [exec] OK — exit code 0
60
+ ) else (
61
+ echo [exec] FAILED — exit code %EXIT_CODE%
62
+ )
63
+ exit /b %EXIT_CODE%