specced 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specced/__init__.py +11 -0
- specced/_paths.py +11 -0
- specced/cli.py +178 -0
- specced/detect.py +299 -0
- specced/scaffold.py +761 -0
- specced/templates/code-review/README.md +25 -0
- specced/templates/code-review/_template.md +21 -0
- specced/templates/mcp/servers.json +36 -0
- specced/templates/presets/go.json +16 -0
- specced/templates/presets/java-spring.json +16 -0
- specced/templates/presets/node-express.json +16 -0
- specced/templates/presets/node-next.json +16 -0
- specced/templates/presets/node-react.json +16 -0
- specced/templates/presets/node-svelte.json +16 -0
- specced/templates/presets/node.json +16 -0
- specced/templates/presets/python-django.json +16 -0
- specced/templates/presets/python-fastapi.json +26 -0
- specced/templates/presets/python.json +16 -0
- specced/templates/presets/ruby-rails.json +16 -0
- specced/templates/presets/rust.json +16 -0
- specced/templates/project/CONSTITUTION.md.tmpl +60 -0
- specced/templates/project/Makefile.tmpl +27 -0
- specced/templates/project/mcp.json.tmpl +9 -0
- specced/templates/project/settings.json.tmpl +27 -0
- specced/templates/rules/README.md +26 -0
- specced/templates/rules/_template.md +28 -0
- specced/templates/skills/add-integration/SKILL.md +67 -0
- specced/templates/skills/api-endpoint/SKILL.md +72 -0
- specced/templates/skills/background-worker/SKILL.md +50 -0
- specced/templates/skills/capture-rule/SKILL.md +50 -0
- specced/templates/skills/code-review/SKILL.md +40 -0
- specced/templates/skills/db-migration/SKILL.md +62 -0
- specced/templates/skills/debug-issue/SKILL.md +47 -0
- specced/templates/skills/decision-record/SKILL.md +62 -0
- specced/templates/skills/dependency-upgrade/SKILL.md +63 -0
- specced/templates/skills/new-domain-skill/SKILL.md +52 -0
- specced/templates/skills/perf-investigation/SKILL.md +81 -0
- specced/templates/skills/prepare-pr/SKILL.md +80 -0
- specced/templates/skills/refactor/SKILL.md +53 -0
- specced/templates/skills/regen-client/SKILL.md +79 -0
- specced/templates/skills/release/SKILL.md +62 -0
- specced/templates/skills/repo-task-proof-loop/AGENTS.md +29 -0
- specced/templates/skills/repo-task-proof-loop/CLAUDE.md +33 -0
- specced/templates/skills/repo-task-proof-loop/LICENSE +201 -0
- specced/templates/skills/repo-task-proof-loop/README.md +181 -0
- specced/templates/skills/repo-task-proof-loop/SKILL.md +255 -0
- specced/templates/skills/repo-task-proof-loop/VERIFICATION.md +35 -0
- specced/templates/skills/repo-task-proof-loop/agents/openai.yaml +6 -0
- specced/templates/skills/repo-task-proof-loop/assets/schemas/evidence.schema.json +77 -0
- specced/templates/skills/repo-task-proof-loop/assets/schemas/verdict.schema.json +62 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/claude/task-builder.md.tmpl +39 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/claude/task-fixer.md.tmpl +27 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/claude/task-spec-freezer.md.tmpl +31 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/claude/task-verifier.md.tmpl +36 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/codex/task-builder.toml.tmpl +41 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/codex/task-fixer.toml.tmpl +28 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/codex/task-spec-freezer.toml.tmpl +30 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/codex/task-verifier.toml.tmpl +36 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/evidence.json.tmpl +20 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/evidence.md.tmpl +27 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/managed-block-agents.md.tmpl +31 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/managed-block-claude.md.tmpl +35 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/problems.md.tmpl +17 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/raw.build.txt.tmpl +1 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/raw.lint.txt.tmpl +1 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/raw.test-integration.txt.tmpl +1 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/raw.test-unit.txt.tmpl +1 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/spec.md.tmpl +51 -0
- specced/templates/skills/repo-task-proof-loop/assets/templates/verdict.json.tmpl +13 -0
- specced/templates/skills/repo-task-proof-loop/references/COMMANDS.md +396 -0
- specced/templates/skills/repo-task-proof-loop/references/REFERENCE.md +183 -0
- specced/templates/skills/repo-task-proof-loop/references/SCHEMAS.md +127 -0
- specced/templates/skills/repo-task-proof-loop/references/SUBAGENTS.md +206 -0
- specced/templates/skills/repo-task-proof-loop/scripts/task_loop.py +710 -0
- specced/templates/skills/repo-task-proof-loop/scripts/verify_package.py +519 -0
- specced/templates/skills/security-review/SKILL.md +85 -0
- specced/templates/skills/write-tests/SKILL.md +78 -0
- specced-0.1.0.dist-info/METADATA +169 -0
- specced-0.1.0.dist-info/RECORD +83 -0
- specced-0.1.0.dist-info/WHEEL +4 -0
- specced-0.1.0.dist-info/entry_points.txt +2 -0
- specced-0.1.0.dist-info/licenses/LICENSE +21 -0
- specced-0.1.0.dist-info/licenses/NOTICE +20 -0
specced/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""specced — install a reusable, interview-driven agentic coding setup into any repo.
|
|
2
|
+
|
|
3
|
+
The Python package is the *deterministic* half of specced: it scaffolds the
|
|
4
|
+
proof-loop engine, project-scoped agents, managed guide blocks, and the
|
|
5
|
+
Layer-2 structure (constitution, rules, code-review, mcp, Makefile vocabulary).
|
|
6
|
+
|
|
7
|
+
The *intelligent* half — the interview that fills in project-specific content —
|
|
8
|
+
lives in the Claude Code plugin under ``plugin/`` and drives this CLI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
specced/_paths.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Resource-path resolution shared by scaffold and detect (avoids an import cycle)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def templates_dir() -> Path:
|
|
10
|
+
"""Path to the bundled templates directory (works for editable + wheel)."""
|
|
11
|
+
return Path(str(files("specced"))) / "templates"
|
specced/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""specced command-line interface.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over :mod:`specced.scaffold`. Every command prints a JSON result to
|
|
4
|
+
stdout so the interview agent (or a human) can read exactly what happened.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import scaffold
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _repo_root(args: argparse.Namespace) -> Path:
|
|
18
|
+
start = Path(args.repo_root).resolve() if args.repo_root else Path.cwd()
|
|
19
|
+
return scaffold.discover_repo_root(start)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
23
|
+
print(json.dumps(payload, indent=2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
27
|
+
_emit(
|
|
28
|
+
scaffold.init_repo(
|
|
29
|
+
_repo_root(args),
|
|
30
|
+
minimal=args.minimal,
|
|
31
|
+
force=args.force,
|
|
32
|
+
format_cmd=args.format_cmd,
|
|
33
|
+
preset=args.preset,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_detect(args: argparse.Namespace) -> int:
|
|
40
|
+
_emit(scaffold.detect_repo(_repo_root(args)))
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_presets(args: argparse.Namespace) -> int:
|
|
45
|
+
_emit({"presets": scaffold.list_presets()})
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_add_mcp(args: argparse.Namespace) -> int:
|
|
50
|
+
result = scaffold.compose_mcp(_repo_root(args), args.names, force=args.force)
|
|
51
|
+
_emit(result)
|
|
52
|
+
return 1 if result["unknown"] else 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_add_skill(args: argparse.Namespace) -> int:
|
|
56
|
+
result = scaffold.add_skill(_repo_root(args), args.name, force=args.force)
|
|
57
|
+
_emit(result)
|
|
58
|
+
return 0 if result.get("ok") else 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_list_skills(args: argparse.Namespace) -> int:
|
|
62
|
+
_emit({"library_skills": scaffold.list_library_skills()})
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
67
|
+
_emit(scaffold.sync(_repo_root(args)))
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
72
|
+
result = scaffold.doctor(_repo_root(args))
|
|
73
|
+
_emit(result)
|
|
74
|
+
return 0 if result["ok"] else 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
78
|
+
_emit(scaffold.status(_repo_root(args)))
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
83
|
+
_emit({"specced": scaffold.SPECCED_VERSION, "engine": scaffold.engine_version()})
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
88
|
+
parser = argparse.ArgumentParser(
|
|
89
|
+
prog="specced",
|
|
90
|
+
description="Install and manage the specced agentic coding setup in a repo.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
94
|
+
common.add_argument(
|
|
95
|
+
"--repo-root",
|
|
96
|
+
default=None,
|
|
97
|
+
help="Path inside the target repo. Defaults to the current directory.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
101
|
+
|
|
102
|
+
p_init = sub.add_parser(
|
|
103
|
+
"init", parents=[common], help="Install the setup into the repo (idempotent)."
|
|
104
|
+
)
|
|
105
|
+
p_init.add_argument(
|
|
106
|
+
"--preset",
|
|
107
|
+
default=None,
|
|
108
|
+
help="Stack preset name, or 'auto' to detect (see: specced presets).",
|
|
109
|
+
)
|
|
110
|
+
p_init.add_argument(
|
|
111
|
+
"--minimal",
|
|
112
|
+
action="store_true",
|
|
113
|
+
help="Install only the engine, agents, managed blocks, and config "
|
|
114
|
+
"(skip Layer-2 content files so the interview can author them).",
|
|
115
|
+
)
|
|
116
|
+
p_init.add_argument("--force", action="store_true", help="Overwrite existing files.")
|
|
117
|
+
p_init.add_argument(
|
|
118
|
+
"--format-cmd",
|
|
119
|
+
default=None,
|
|
120
|
+
help="Format+lint command for the Stop hook (default: 'make fmt lint').",
|
|
121
|
+
)
|
|
122
|
+
p_init.set_defaults(func=cmd_init)
|
|
123
|
+
|
|
124
|
+
p_detect = sub.add_parser(
|
|
125
|
+
"detect", parents=[common], help="Inspect the repo and report stack signals (JSON)."
|
|
126
|
+
)
|
|
127
|
+
p_detect.set_defaults(func=cmd_detect)
|
|
128
|
+
|
|
129
|
+
p_presets = sub.add_parser("presets", help="List available stack presets.")
|
|
130
|
+
p_presets.set_defaults(func=cmd_presets)
|
|
131
|
+
|
|
132
|
+
p_mcp = sub.add_parser(
|
|
133
|
+
"add-mcp", parents=[common], help="Add MCP servers to .mcp.json from the catalog."
|
|
134
|
+
)
|
|
135
|
+
p_mcp.add_argument(
|
|
136
|
+
"names", nargs="+", help="Server names (see 'mcp_catalog' in: specced status)."
|
|
137
|
+
)
|
|
138
|
+
p_mcp.add_argument("--force", action="store_true", help="Overwrite an existing server entry.")
|
|
139
|
+
p_mcp.set_defaults(func=cmd_add_mcp)
|
|
140
|
+
|
|
141
|
+
p_add = sub.add_parser(
|
|
142
|
+
"add-skill", parents=[common], help="Install a domain skill from the library."
|
|
143
|
+
)
|
|
144
|
+
p_add.add_argument("name", help="Library skill name (see: specced list-skills).")
|
|
145
|
+
p_add.add_argument("--force", action="store_true", help="Replace if already present.")
|
|
146
|
+
p_add.set_defaults(func=cmd_add_skill)
|
|
147
|
+
|
|
148
|
+
p_list = sub.add_parser("list-skills", help="List available library skills.")
|
|
149
|
+
p_list.set_defaults(func=cmd_list_skills)
|
|
150
|
+
|
|
151
|
+
p_sync = sub.add_parser(
|
|
152
|
+
"sync",
|
|
153
|
+
parents=[common],
|
|
154
|
+
help="Refresh engine + agents + managed blocks to this specced version.",
|
|
155
|
+
)
|
|
156
|
+
p_sync.set_defaults(func=cmd_sync)
|
|
157
|
+
|
|
158
|
+
p_doctor = sub.add_parser("doctor", parents=[common], help="Verify the setup is consistent.")
|
|
159
|
+
p_doctor.set_defaults(func=cmd_doctor)
|
|
160
|
+
|
|
161
|
+
p_status = sub.add_parser(
|
|
162
|
+
"status", parents=[common], help="Show installed components and config."
|
|
163
|
+
)
|
|
164
|
+
p_status.set_defaults(func=cmd_status)
|
|
165
|
+
|
|
166
|
+
p_version = sub.add_parser("version", help="Print specced and engine versions.")
|
|
167
|
+
p_version.set_defaults(func=cmd_version)
|
|
168
|
+
|
|
169
|
+
return parser
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
args = build_parser().parse_args(argv)
|
|
174
|
+
return int(args.func(args))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
raise SystemExit(main())
|
specced/detect.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Stack detection for the bootstrap interview.
|
|
2
|
+
|
|
3
|
+
Pure, dependency-free inspection of a repo: languages, package managers, tracks,
|
|
4
|
+
candidate verification commands, data/infra signals, and existing conventions.
|
|
5
|
+
The interview reads this so it can *confirm* detections instead of asking blind,
|
|
6
|
+
and ``specced init --preset auto`` uses it to pick a preset.
|
|
7
|
+
|
|
8
|
+
Kept stdlib-only (text + JSON scans, no tomllib) to honor requires-python >= 3.10.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ._paths import templates_dir
|
|
19
|
+
|
|
20
|
+
TRACK_DIRS = (
|
|
21
|
+
"backend",
|
|
22
|
+
"frontend",
|
|
23
|
+
"ml",
|
|
24
|
+
"infra",
|
|
25
|
+
"services",
|
|
26
|
+
"api",
|
|
27
|
+
"web",
|
|
28
|
+
"app",
|
|
29
|
+
"apps",
|
|
30
|
+
"packages",
|
|
31
|
+
"server",
|
|
32
|
+
"client",
|
|
33
|
+
"mobile",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DB_IMAGES = (
|
|
37
|
+
"postgres",
|
|
38
|
+
"mysql",
|
|
39
|
+
"mariadb",
|
|
40
|
+
"redis",
|
|
41
|
+
"qdrant",
|
|
42
|
+
"mongo",
|
|
43
|
+
"elasticsearch",
|
|
44
|
+
"clickhouse",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read(path: Path) -> str:
|
|
49
|
+
try:
|
|
50
|
+
return path.read_text(encoding="utf-8")
|
|
51
|
+
except OSError:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
56
|
+
try:
|
|
57
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
58
|
+
except (OSError, json.JSONDecodeError):
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _manifest_dirs(root: Path) -> list[Path]:
|
|
63
|
+
"""Root plus immediate track subdirs — enough to see a backend/+frontend/ monorepo."""
|
|
64
|
+
dirs = [root]
|
|
65
|
+
for name in TRACK_DIRS:
|
|
66
|
+
d = root / name
|
|
67
|
+
if d.is_dir():
|
|
68
|
+
dirs.append(d)
|
|
69
|
+
return dirs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect(repo_root: Path) -> dict[str, Any]:
|
|
73
|
+
root = repo_root
|
|
74
|
+
languages: set[str] = set()
|
|
75
|
+
frameworks: set[str] = set()
|
|
76
|
+
tools: set[str] = set()
|
|
77
|
+
node_scripts: dict[str, str] = {}
|
|
78
|
+
|
|
79
|
+
for d in _manifest_dirs(root):
|
|
80
|
+
pyproject = _read(d / "pyproject.toml")
|
|
81
|
+
if pyproject or (d / "requirements.txt").exists() or (d / "setup.py").exists():
|
|
82
|
+
languages.add("python")
|
|
83
|
+
for dep in ("fastapi", "django", "flask", "sqlalchemy", "alembic", "pydantic"):
|
|
84
|
+
if re.search(rf"\b{dep}\b", pyproject, re.IGNORECASE):
|
|
85
|
+
frameworks.add(dep)
|
|
86
|
+
for tool in ("ruff", "black", "mypy", "pytest", "tox", "nox"):
|
|
87
|
+
if re.search(rf"\b{tool}\b", pyproject, re.IGNORECASE):
|
|
88
|
+
tools.add(tool)
|
|
89
|
+
|
|
90
|
+
pkg = _load_json(d / "package.json")
|
|
91
|
+
if pkg:
|
|
92
|
+
languages.add("node")
|
|
93
|
+
node_scripts.update(pkg.get("scripts", {}) or {})
|
|
94
|
+
alldeps = {**(pkg.get("dependencies") or {}), **(pkg.get("devDependencies") or {})}
|
|
95
|
+
for dep in (
|
|
96
|
+
"next",
|
|
97
|
+
"react",
|
|
98
|
+
"vue",
|
|
99
|
+
"svelte",
|
|
100
|
+
"@sveltejs/kit",
|
|
101
|
+
"vite",
|
|
102
|
+
"express",
|
|
103
|
+
"@nestjs/core",
|
|
104
|
+
"vitest",
|
|
105
|
+
"jest",
|
|
106
|
+
"eslint",
|
|
107
|
+
"prettier",
|
|
108
|
+
"typescript",
|
|
109
|
+
):
|
|
110
|
+
if dep in alldeps:
|
|
111
|
+
frameworks.add(dep.split("/")[0].lstrip("@"))
|
|
112
|
+
|
|
113
|
+
if (d / "go.mod").exists():
|
|
114
|
+
languages.add("go")
|
|
115
|
+
if (d / "Cargo.toml").exists():
|
|
116
|
+
languages.add("rust")
|
|
117
|
+
jvm = _read(d / "pom.xml") + _read(d / "build.gradle") + _read(d / "build.gradle.kts")
|
|
118
|
+
if jvm:
|
|
119
|
+
languages.add("java")
|
|
120
|
+
if re.search(r"spring", jvm, re.IGNORECASE):
|
|
121
|
+
frameworks.add("spring")
|
|
122
|
+
gemfile = _read(d / "Gemfile")
|
|
123
|
+
if gemfile:
|
|
124
|
+
languages.add("ruby")
|
|
125
|
+
if re.search(r"\brails\b", gemfile, re.IGNORECASE):
|
|
126
|
+
frameworks.add("rails")
|
|
127
|
+
|
|
128
|
+
if (root / ".golangci.yml").exists() or (root / ".golangci.yaml").exists():
|
|
129
|
+
tools.add("golangci-lint")
|
|
130
|
+
|
|
131
|
+
tracks = [name for name in TRACK_DIRS if (root / name).is_dir()]
|
|
132
|
+
|
|
133
|
+
# data / infra
|
|
134
|
+
infra: dict[str, Any] = {
|
|
135
|
+
"databases": [],
|
|
136
|
+
"supabase": False,
|
|
137
|
+
"migrations": None,
|
|
138
|
+
"docker_compose": False,
|
|
139
|
+
}
|
|
140
|
+
for compose in ("docker-compose.yml", "docker-compose.yaml", "compose.yml"):
|
|
141
|
+
text = _read(root / compose)
|
|
142
|
+
if text:
|
|
143
|
+
infra["docker_compose"] = True
|
|
144
|
+
for img in DB_IMAGES:
|
|
145
|
+
if re.search(rf"image:\s*[\"']?{img}", text, re.IGNORECASE):
|
|
146
|
+
if img not in infra["databases"]:
|
|
147
|
+
infra["databases"].append(img)
|
|
148
|
+
if (root / "supabase").is_dir():
|
|
149
|
+
infra["supabase"] = True
|
|
150
|
+
if "postgres" not in infra["databases"]:
|
|
151
|
+
infra["databases"].append("postgres")
|
|
152
|
+
for mig in ("alembic", "migrations", "prisma", "db/migrate"):
|
|
153
|
+
if (root / mig).exists() or any((root / t / "alembic").exists() for t in tracks):
|
|
154
|
+
infra["migrations"] = mig
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
existing = {
|
|
158
|
+
"constitution": (root / "CONSTITUTION.md").exists(),
|
|
159
|
+
"claude_dir": (root / ".claude").is_dir(),
|
|
160
|
+
"docs": (root / "docs").is_dir(),
|
|
161
|
+
"ci": (root / ".github" / "workflows").is_dir(),
|
|
162
|
+
"git": (root / ".git").exists(),
|
|
163
|
+
"specced": (root / ".specced" / "config.json").exists(),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
detection = {
|
|
167
|
+
"repo_root": str(root),
|
|
168
|
+
"languages": sorted(languages),
|
|
169
|
+
"frameworks": sorted(frameworks),
|
|
170
|
+
"tools": sorted(tools),
|
|
171
|
+
"node_scripts": node_scripts,
|
|
172
|
+
"tracks": tracks,
|
|
173
|
+
"infra": infra,
|
|
174
|
+
"existing": existing,
|
|
175
|
+
"monorepo": len([t for t in tracks if t in ("backend", "frontend", "ml")]) >= 2,
|
|
176
|
+
}
|
|
177
|
+
detection["suggested_preset"] = suggest_preset(detection)
|
|
178
|
+
detection["suggested_mcp"] = suggest_mcp(detection)
|
|
179
|
+
detection["suggested_verification"] = suggest_verification(detection)
|
|
180
|
+
detection["summary"] = summary(detection)
|
|
181
|
+
return detection
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _load_presets() -> list[dict[str, Any]]:
|
|
185
|
+
out: list[dict[str, Any]] = []
|
|
186
|
+
for path in sorted((templates_dir() / "presets").glob("*.json")):
|
|
187
|
+
try:
|
|
188
|
+
out.append(json.loads(path.read_text(encoding="utf-8")))
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
continue
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def suggest_preset(d: dict[str, Any]) -> str | None:
|
|
195
|
+
"""Pick the highest-priority preset whose declared markers match the repo.
|
|
196
|
+
|
|
197
|
+
Detection is DATA-DRIVEN: each preset file (templates/presets/*.json) carries a
|
|
198
|
+
`detect` block, e.g. ``{"any_frameworks": ["next"], "priority": 52}``. The preset's
|
|
199
|
+
top-level ``language`` must be present in the repo; if ``any_frameworks`` is given,
|
|
200
|
+
at least one must match. A preset with no ``any_frameworks`` is the generic fallback
|
|
201
|
+
for its language. Adding an auto-detected preset is therefore one JSON file, no code.
|
|
202
|
+
"""
|
|
203
|
+
langs = set(d["languages"])
|
|
204
|
+
fw = set(d["frameworks"])
|
|
205
|
+
best: str | None = None
|
|
206
|
+
best_priority = -1
|
|
207
|
+
for preset in _load_presets():
|
|
208
|
+
det = preset.get("detect")
|
|
209
|
+
if det is None:
|
|
210
|
+
continue
|
|
211
|
+
language = det.get("language") or preset.get("language")
|
|
212
|
+
if not language or language not in langs:
|
|
213
|
+
continue
|
|
214
|
+
any_frameworks = det.get("any_frameworks") or []
|
|
215
|
+
if any_frameworks and not (fw & set(any_frameworks)):
|
|
216
|
+
continue
|
|
217
|
+
priority = det.get("priority", 0)
|
|
218
|
+
if priority > best_priority:
|
|
219
|
+
best_priority = priority
|
|
220
|
+
best = preset.get("name")
|
|
221
|
+
return best
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def suggest_mcp(d: dict[str, Any]) -> list[str]:
|
|
225
|
+
servers = ["context7"]
|
|
226
|
+
if d["existing"]["ci"] or d["existing"]["git"]:
|
|
227
|
+
servers.append("github")
|
|
228
|
+
infra = d["infra"]
|
|
229
|
+
if infra["supabase"]:
|
|
230
|
+
servers.append("supabase")
|
|
231
|
+
elif "postgres" in infra["databases"]:
|
|
232
|
+
servers.append("postgres")
|
|
233
|
+
if "qdrant" in infra["databases"]:
|
|
234
|
+
servers.append("qdrant")
|
|
235
|
+
# de-dup, preserve order
|
|
236
|
+
seen: set[str] = set()
|
|
237
|
+
return [s for s in servers if not (s in seen or seen.add(s))]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def suggest_verification(d: dict[str, Any]) -> dict[str, str | None]:
|
|
241
|
+
langs = d["languages"]
|
|
242
|
+
tools = set(d["tools"])
|
|
243
|
+
scripts = d["node_scripts"]
|
|
244
|
+
out: dict[str, str | None] = {"fmt": None, "lint": None, "test": None, "build": None}
|
|
245
|
+
if "python" in langs:
|
|
246
|
+
out["fmt"] = "ruff format ." if "ruff" in tools else "black ."
|
|
247
|
+
out["lint"] = "ruff check ." + (" && mypy ." if "mypy" in tools else "")
|
|
248
|
+
out["test"] = "pytest -q"
|
|
249
|
+
out["build"] = "python -m build"
|
|
250
|
+
if "go" in langs:
|
|
251
|
+
out["fmt"] = "gofmt -w ."
|
|
252
|
+
out["lint"] = "golangci-lint run" if "golangci-lint" in tools else "go vet ./..."
|
|
253
|
+
out["test"] = "go test ./..."
|
|
254
|
+
out["build"] = "go build ./..."
|
|
255
|
+
if "rust" in langs:
|
|
256
|
+
out["fmt"] = "cargo fmt"
|
|
257
|
+
out["lint"] = "cargo clippy --all-targets -- -D warnings"
|
|
258
|
+
out["test"] = "cargo test"
|
|
259
|
+
out["build"] = "cargo build --release"
|
|
260
|
+
if "java" in langs:
|
|
261
|
+
out["fmt"] = "./mvnw spotless:apply"
|
|
262
|
+
out["lint"] = "./mvnw -q verify -DskipTests"
|
|
263
|
+
out["test"] = "./mvnw test"
|
|
264
|
+
out["build"] = "./mvnw -q package -DskipTests"
|
|
265
|
+
if "ruby" in langs:
|
|
266
|
+
out["fmt"] = "bundle exec rubocop -A"
|
|
267
|
+
out["lint"] = "bundle exec rubocop"
|
|
268
|
+
out["test"] = "bundle exec rspec"
|
|
269
|
+
out["build"] = "bundle exec rails zeitwerk:check"
|
|
270
|
+
if "node" in langs:
|
|
271
|
+
if "lint" in scripts:
|
|
272
|
+
out["lint"] = "npm run lint"
|
|
273
|
+
if "build" in scripts:
|
|
274
|
+
out["build"] = "npm run build"
|
|
275
|
+
if "test" in scripts:
|
|
276
|
+
out["test"] = "npm test"
|
|
277
|
+
if "format" in scripts:
|
|
278
|
+
out["fmt"] = "npm run format"
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def summary(d: dict[str, Any]) -> list[str]:
|
|
283
|
+
lines = []
|
|
284
|
+
lines.append(f"languages: {', '.join(d['languages']) or 'unknown'}")
|
|
285
|
+
if d["frameworks"]:
|
|
286
|
+
lines.append(f"frameworks: {', '.join(d['frameworks'])}")
|
|
287
|
+
if d["tracks"]:
|
|
288
|
+
lines.append(
|
|
289
|
+
f"tracks: {', '.join(d['tracks'])}" + (" (monorepo)" if d["monorepo"] else "")
|
|
290
|
+
)
|
|
291
|
+
if d["infra"]["databases"]:
|
|
292
|
+
lines.append(
|
|
293
|
+
f"data: {', '.join(d['infra']['databases'])}"
|
|
294
|
+
+ (" +supabase" if d["infra"]["supabase"] else "")
|
|
295
|
+
)
|
|
296
|
+
if d["suggested_preset"]:
|
|
297
|
+
lines.append(f"suggested preset: {d['suggested_preset']}")
|
|
298
|
+
lines.append(f"suggested mcp: {', '.join(d['suggested_mcp'])}")
|
|
299
|
+
return lines
|