@caddis/cli 0.2.6 → 0.3.0
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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +23 -4
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
- package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +21 -2
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +365 -44
- package/package.json +5 -2
- package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
|
@@ -0,0 +1,1429 @@
|
|
|
1
|
+
"""setup_project_ai — deterministic layer of the Claude Code harness generator.
|
|
2
|
+
|
|
3
|
+
Deploys and customizes the canonical harness (claude-harness/) into a target project:
|
|
4
|
+
• stack detection (pyproject/requirements/package.json + path signals, per stack-map.json)
|
|
5
|
+
• placeholder substitution for provided keys, with a report of any leftovers (Phase 0 friction #1)
|
|
6
|
+
• AGENTS.md-canonical rules hierarchy (root + folder AGENTS.md) with a CLAUDE.md `@AGENTS.md` shim
|
|
7
|
+
beside each, so every agent reads one source of rules and Claude Code inlines it via @import
|
|
8
|
+
• subagents → .claude/agents/, commands → .claude/commands/, settings → .claude/settings.json (merged)
|
|
9
|
+
• frontend Vitest/jsdom test-harness scaffold when react+vitest but no DOM env (Phase 0 friction #4)
|
|
10
|
+
• venv / dev-deps detection (report; create+install only with --install)
|
|
11
|
+
|
|
12
|
+
Idempotent: existing AGENTS.md / CLAUDE.md / settings are preserved unless --force; settings allow-lists
|
|
13
|
+
are always merged (union). This script is the must-not-vary part; AGENTS.md *enrichment* with
|
|
14
|
+
project-specific facts is the AI step of the setup-project-ai skill that wraps this. The CLAUDE.md
|
|
15
|
+
files are @import shims and are never enriched — durable rules always go in the matching AGENTS.md.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python scripts/setup_project_ai.py <target_dir> --name "My App" --desc "One-line description"
|
|
19
|
+
[--set KEY=VALUE ...] [--force] [--install] [--dry-run]
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
import shutil
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
def _resolve_harness_dir() -> Path:
|
|
34
|
+
"""Locate the harness resource root (templates, settings template, stack map).
|
|
35
|
+
|
|
36
|
+
Two supported layouts:
|
|
37
|
+
• caddis dev: scripts/setup_project_ai.py → ../claude-harness/
|
|
38
|
+
• bundled in plugin: plugin/scripts/setup_project_ai.py → ../ (claude-md/ et al.
|
|
39
|
+
sit directly at the plugin root, with no claude-harness/ subdir).
|
|
40
|
+
Pick the first candidate that actually carries the templates.
|
|
41
|
+
"""
|
|
42
|
+
here = Path(__file__).resolve().parent
|
|
43
|
+
for cand in (here.parent / "claude-harness", here.parent):
|
|
44
|
+
if (cand / "claude-md").is_dir() and (cand / "settings.template.json").is_file():
|
|
45
|
+
return cand
|
|
46
|
+
return here.parent / "claude-harness" # dev default; missing-template error surfaced later
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_artifact_helpers():
|
|
50
|
+
"""`(ARTIFACT_DIRS, artifact_root)` from the shared reader, with an inline fallback.
|
|
51
|
+
|
|
52
|
+
The artifact dir name lives in ONE place (claude-harness/scripts/claudster_config.py). This
|
|
53
|
+
generator runs from both layouts above, so the helper is imported off whichever scripts/ dir
|
|
54
|
+
carries it; the fallback re-states the same rule rather than letting a bootstrap crash.
|
|
55
|
+
"""
|
|
56
|
+
here = Path(__file__).resolve().parent
|
|
57
|
+
for cand in (here.parent / "claude-harness" / "scripts", here):
|
|
58
|
+
if (cand / "claudster_config.py").is_file():
|
|
59
|
+
if str(cand) not in sys.path:
|
|
60
|
+
sys.path.insert(0, str(cand))
|
|
61
|
+
break
|
|
62
|
+
try:
|
|
63
|
+
from claudster_config import ARTIFACT_DIRS, artifact_root
|
|
64
|
+
return ARTIFACT_DIRS, artifact_root
|
|
65
|
+
except Exception: # pragma: no cover — defensive
|
|
66
|
+
dirs = (".caddis",)
|
|
67
|
+
|
|
68
|
+
def _artifact_root(root):
|
|
69
|
+
return Path(root) / dirs[0]
|
|
70
|
+
|
|
71
|
+
return dirs, _artifact_root
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ARTIFACT_DIRS, artifact_root = _load_artifact_helpers()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
HARNESS_DIR = _resolve_harness_dir()
|
|
78
|
+
PLACEHOLDER_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}")
|
|
79
|
+
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache",
|
|
80
|
+
".ruff_cache", ".pytest_cache", "dist", "build", ".tanstack", ".codex-tmp"}
|
|
81
|
+
TEXT_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".toml", ".txt",
|
|
82
|
+
".yml", ".yaml", ".cfg", ".ini", ".html", ".css", ".env", ".ps1", ".sh",
|
|
83
|
+
# .mjs was missing until 2026-08-03. A .mjs carrying a {{PLACEHOLDER}} was
|
|
84
|
+
# skipped by the scan, so the leftover-placeholder gate could not see it and
|
|
85
|
+
# the file shipped unrendered and SILENTLY. Found while porting a Playwright
|
|
86
|
+
# prod-render check (an .mjs) into the fleet template. Same class as the
|
|
87
|
+
# VersionBadge trap: a hardcoded extension/file list that a new file type
|
|
88
|
+
# falls straight through.
|
|
89
|
+
".mjs", ".cjs"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
93
|
+
def load_stack_map() -> dict:
|
|
94
|
+
return json.loads((HARNESS_DIR / "stack-map.json").read_text(encoding="utf-8"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def iter_text_files(root: Path):
|
|
98
|
+
for path in root.rglob("*"):
|
|
99
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
100
|
+
continue
|
|
101
|
+
if path.is_file() and path.suffix in TEXT_SUFFIXES:
|
|
102
|
+
yield path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_deps(target: Path) -> tuple[set[str], set[str]]:
|
|
106
|
+
"""Return (python_deps, js_deps) as lowercased package-name sets."""
|
|
107
|
+
py: set[str] = set()
|
|
108
|
+
for fname in ("pyproject.toml", "requirements.txt", "setup.py"):
|
|
109
|
+
fp = target / fname
|
|
110
|
+
if fp.exists():
|
|
111
|
+
text = fp.read_text(encoding="utf-8", errors="ignore").lower()
|
|
112
|
+
py.update(re.findall(r"[a-z0-9][a-z0-9._-]+", text))
|
|
113
|
+
js: set[str] = set()
|
|
114
|
+
for pj in (target / "package.json", target / "frontend" / "package.json"):
|
|
115
|
+
if pj.exists():
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
118
|
+
js.update(k.lower() for k in {**data.get("dependencies", {}),
|
|
119
|
+
**data.get("devDependencies", {})})
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
pass
|
|
122
|
+
return py, js
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def path_exists_any(target: Path, globs: list[str]) -> bool:
|
|
126
|
+
for g in globs:
|
|
127
|
+
if "*" in g:
|
|
128
|
+
if any(p for p in target.glob(g) if not any(s in p.parts for s in SKIP_DIRS)):
|
|
129
|
+
return True
|
|
130
|
+
elif (target / g).exists():
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def detect_stack(target: Path, stack_map: dict) -> dict:
|
|
136
|
+
py_deps, js_deps = read_deps(target)
|
|
137
|
+
matched: list[dict] = []
|
|
138
|
+
for det in stack_map["detectors"]:
|
|
139
|
+
ok = False
|
|
140
|
+
if det.get("any_path") and path_exists_any(target, det["any_path"]):
|
|
141
|
+
ok = True
|
|
142
|
+
if det.get("any_dep") and any(d in py_deps for d in det["any_dep"]):
|
|
143
|
+
ok = True
|
|
144
|
+
if det.get("any_dep_json") and any(d in js_deps for d in det["any_dep_json"]):
|
|
145
|
+
ok = True
|
|
146
|
+
if ok:
|
|
147
|
+
matched.append(det)
|
|
148
|
+
return {"matched": matched, "py_deps": py_deps, "js_deps": js_deps}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def stack_summary(stack: dict) -> str:
|
|
152
|
+
ids = {d["id"] for d in stack["matched"]}
|
|
153
|
+
parts = []
|
|
154
|
+
if "python-backend" in ids:
|
|
155
|
+
parts.append("Python")
|
|
156
|
+
if "fastapi" in ids:
|
|
157
|
+
parts.append("FastAPI")
|
|
158
|
+
if "pytest" in ids:
|
|
159
|
+
parts.append("pytest")
|
|
160
|
+
if "react-frontend" in ids:
|
|
161
|
+
parts.append("React/Vite")
|
|
162
|
+
return " · ".join(parts) if parts else "general"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def render(text: str, mapping: dict[str, str]) -> str:
|
|
166
|
+
def repl(m: re.Match) -> str:
|
|
167
|
+
return mapping.get(m.group(1), m.group(0))
|
|
168
|
+
return PLACEHOLDER_RE.sub(repl, text)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── steps ────────────────────────────────────────────────────────────────────
|
|
172
|
+
def substitute_placeholders(target: Path, mapping: dict[str, str], dry: bool) -> tuple[int, dict[str, list[str]]]:
|
|
173
|
+
"""Replace provided keys across text files; report leftover {{TOKENS}} grouped by file."""
|
|
174
|
+
changed = 0
|
|
175
|
+
leftovers: dict[str, list[str]] = {}
|
|
176
|
+
for fp in iter_text_files(target):
|
|
177
|
+
try:
|
|
178
|
+
text = fp.read_text(encoding="utf-8")
|
|
179
|
+
except (UnicodeDecodeError, PermissionError):
|
|
180
|
+
continue
|
|
181
|
+
if "{{" not in text:
|
|
182
|
+
continue
|
|
183
|
+
new = render(text, mapping)
|
|
184
|
+
if new != text and not dry:
|
|
185
|
+
fp.write_text(new, encoding="utf-8")
|
|
186
|
+
if new != text:
|
|
187
|
+
changed += 1
|
|
188
|
+
remaining = sorted(set(PLACEHOLDER_RE.findall(new)))
|
|
189
|
+
if remaining:
|
|
190
|
+
leftovers[str(fp.relative_to(target))] = remaining
|
|
191
|
+
return changed, leftovers
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# A folder CLAUDE.md is a 2-line @import shim — Claude Code inlines the sibling AGENTS.md (the
|
|
195
|
+
# canonical rules) at load. The shim resolves `@AGENTS.md` relative to its own folder, so the same
|
|
196
|
+
# text works at any depth. Durable folder rules always go in AGENTS.md, never in this shim.
|
|
197
|
+
SUBFOLDER_SHIM = (
|
|
198
|
+
"# Folder conventions — canonical in AGENTS.md (imported below; Claude Code inlines it)\n"
|
|
199
|
+
"@AGENTS.md\n"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def compose_claude_md(target: Path, stack: dict, ident: dict[str, str], force: bool, dry: bool) -> list[str]:
|
|
204
|
+
"""Emit the AGENTS.md-canonical rules hierarchy with a CLAUDE.md @import shim beside each.
|
|
205
|
+
|
|
206
|
+
Root: AGENTS.md (canonical, from agents.md.tmpl) + CLAUDE.md shim (from root.md.tmpl —
|
|
207
|
+
`@AGENTS.md` + a small Claude-native-only block).
|
|
208
|
+
Folders: <dir>/AGENTS.md (the composed fragment body) + <dir>/CLAUDE.md (the 2-line shim).
|
|
209
|
+
stack-map `target` keys stay as `<dir>/CLAUDE.md`; the AGENTS.md path is derived here so the
|
|
210
|
+
map need not change.
|
|
211
|
+
"""
|
|
212
|
+
cm = HARNESS_DIR / "claude-md"
|
|
213
|
+
written: list[str] = []
|
|
214
|
+
has_stack_md = (target / "STACK.md").exists()
|
|
215
|
+
mapping = {
|
|
216
|
+
"PROJECT_NAME": ident["name"],
|
|
217
|
+
"PROJECT_DESCRIPTION": ident["desc"],
|
|
218
|
+
"STACK_SUMMARY": stack_summary(stack),
|
|
219
|
+
"STACK_REFERENCE_LINE": "Full stack reference: `STACK.md`." if has_stack_md else "",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def write(rel: str, content: str):
|
|
223
|
+
dest = target / rel
|
|
224
|
+
if dest.exists() and not force:
|
|
225
|
+
written.append(f"skip (exists): {rel}")
|
|
226
|
+
return
|
|
227
|
+
if not dry:
|
|
228
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
dest.write_text(content, encoding="utf-8")
|
|
230
|
+
written.append(f"write: {rel}")
|
|
231
|
+
|
|
232
|
+
# root: canonical rules + the shim that imports them
|
|
233
|
+
write("AGENTS.md", render((cm / "agents.md.tmpl").read_text(encoding="utf-8"), mapping))
|
|
234
|
+
write("CLAUDE.md", render((cm / "root.md.tmpl").read_text(encoding="utf-8"), mapping))
|
|
235
|
+
|
|
236
|
+
# folder fragments — only when the target folder exists. Canonical → <dir>/AGENTS.md; shim → <dir>/CLAUDE.md.
|
|
237
|
+
by_target: dict[str, list[str]] = {}
|
|
238
|
+
for det in stack["matched"]:
|
|
239
|
+
tgt = det.get("target")
|
|
240
|
+
if not tgt:
|
|
241
|
+
continue
|
|
242
|
+
by_target.setdefault(tgt, []).extend(det.get("fragments", []))
|
|
243
|
+
for tgt, frags in by_target.items():
|
|
244
|
+
folder = (target / tgt).parent
|
|
245
|
+
if not folder.exists():
|
|
246
|
+
written.append(f"skip (no folder): {tgt}")
|
|
247
|
+
continue
|
|
248
|
+
body = "\n".join((cm / f).read_text(encoding="utf-8") for f in frags)
|
|
249
|
+
head, _, _fname = tgt.rpartition("/")
|
|
250
|
+
agents_rel = f"{head}/AGENTS.md" if head else "AGENTS.md"
|
|
251
|
+
write(agents_rel, render(body, mapping))
|
|
252
|
+
write(tgt, render(SUBFOLDER_SHIM, mapping)) # <dir>/CLAUDE.md shim
|
|
253
|
+
return written
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def deploy_dir(src: Path, dest: Path, force: bool, dry: bool) -> list[str]:
|
|
257
|
+
out: list[str] = []
|
|
258
|
+
for fp in sorted(src.glob("*.md")):
|
|
259
|
+
d = dest / fp.name
|
|
260
|
+
if d.exists() and not force:
|
|
261
|
+
out.append(f"skip (exists): {d.name}")
|
|
262
|
+
continue
|
|
263
|
+
if not dry:
|
|
264
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
d.write_text(fp.read_text(encoding="utf-8"), encoding="utf-8")
|
|
266
|
+
out.append(f"deploy: {d.name}")
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# Ask-rules the harness itself injected in older template versions. An `ask` entry OVERRIDES
|
|
271
|
+
# bypassPermissions and forces a prompt every time (precedence: deny → ask → allow), so these
|
|
272
|
+
# silently defeat a user who has opted into no-prompts. The current template ships `ask: []`;
|
|
273
|
+
# on merge we prune these legacy entries so already-deployed projects self-heal. Any OTHER ask
|
|
274
|
+
# rule a user added intentionally is left untouched.
|
|
275
|
+
_LEGACY_HARNESS_ASK = {"Bash(git push:*)", "Bash(rm:*)", "Bash(git reset:*)"}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def merge_settings(target: Path, stack: dict, dry: bool) -> str:
|
|
279
|
+
base = json.loads((HARNESS_DIR / "settings.template.json").read_text(encoding="utf-8"))
|
|
280
|
+
allow = list(base["permissions"]["allow"])
|
|
281
|
+
for det in stack["matched"]:
|
|
282
|
+
for a in det.get("settings_allow", []):
|
|
283
|
+
if a not in allow:
|
|
284
|
+
allow.append(a)
|
|
285
|
+
dest = target / ".claude" / "settings.json"
|
|
286
|
+
notes: list[str] = []
|
|
287
|
+
if dest.exists():
|
|
288
|
+
existing = json.loads(dest.read_text(encoding="utf-8"))
|
|
289
|
+
ex_allow = existing.get("permissions", {}).get("allow", [])
|
|
290
|
+
for a in allow:
|
|
291
|
+
if a not in ex_allow:
|
|
292
|
+
ex_allow.append(a)
|
|
293
|
+
existing.setdefault("permissions", {})["allow"] = ex_allow
|
|
294
|
+
# Self-heal: prune legacy harness-injected `ask` rules that override bypassPermissions.
|
|
295
|
+
ex_ask = existing["permissions"].get("ask", [])
|
|
296
|
+
pruned_ask = [a for a in ex_ask if a not in _LEGACY_HARNESS_ASK]
|
|
297
|
+
if len(pruned_ask) != len(ex_ask):
|
|
298
|
+
removed = [a for a in ex_ask if a in _LEGACY_HARNESS_ASK]
|
|
299
|
+
existing["permissions"]["ask"] = pruned_ask
|
|
300
|
+
notes.append(f"pruned stale ask rules that override bypassPermissions ({', '.join(removed)})")
|
|
301
|
+
# Strip stale hooks block — the caddis plugin owns all hooks via hooks.json.
|
|
302
|
+
# Defining them here too causes double-fire at session start / pre-compact.
|
|
303
|
+
if "hooks" in existing:
|
|
304
|
+
del existing["hooks"]
|
|
305
|
+
notes.append("removed stale hooks block (now owned by caddis plugin)")
|
|
306
|
+
out = existing
|
|
307
|
+
verb = "merge"
|
|
308
|
+
else:
|
|
309
|
+
base["permissions"]["allow"] = allow
|
|
310
|
+
# No statusLine here on purpose: it is user-scope now, installed once per machine
|
|
311
|
+
# by `/caddis:statusline`. A per-project copy is exactly how four status-line
|
|
312
|
+
# scripts came to exist and drift apart.
|
|
313
|
+
out = base
|
|
314
|
+
verb = "write"
|
|
315
|
+
if not dry:
|
|
316
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
dest.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")
|
|
318
|
+
suffix = f" [{'; '.join(notes)}]" if notes else ""
|
|
319
|
+
return f"{verb}: .claude/settings.json ({len(allow)} allow rules){suffix}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def ensure_frontend_test_harness(target: Path, stack: dict, dry: bool) -> list[str]:
|
|
323
|
+
cfg = load_stack_map().get("frontend_test_harness", {})
|
|
324
|
+
js = stack["js_deps"]
|
|
325
|
+
notes: list[str] = []
|
|
326
|
+
if not any(d in js for d in cfg.get("trigger_dep_any", [])):
|
|
327
|
+
return notes # no vitest → nothing to do
|
|
328
|
+
if any(d in js for d in cfg.get("require_dep_any", [])):
|
|
329
|
+
notes.append("frontend test harness: DOM env already present — ok")
|
|
330
|
+
return notes
|
|
331
|
+
fe = target / "frontend" if (target / "frontend" / "package.json").exists() else target
|
|
332
|
+
pj = fe / "package.json"
|
|
333
|
+
if pj.exists() and not dry:
|
|
334
|
+
data = json.loads(pj.read_text(encoding="utf-8"))
|
|
335
|
+
dev = data.setdefault("devDependencies", {})
|
|
336
|
+
for dep in cfg.get("ensure_dev_deps", []):
|
|
337
|
+
dev.setdefault(dep, "latest")
|
|
338
|
+
pj.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
339
|
+
notes.append(f"frontend test harness: added {cfg.get('ensure_dev_deps')} to devDependencies (run npm install)")
|
|
340
|
+
setup_rel = cfg.get("setup_file", "src/test/setup.ts")
|
|
341
|
+
setup_fp = fe / setup_rel
|
|
342
|
+
if not setup_fp.exists() and not dry:
|
|
343
|
+
setup_fp.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
setup_fp.write_text(
|
|
345
|
+
'// Vitest global setup: jest-dom matchers + DOM cleanup between tests.\n'
|
|
346
|
+
'import "@testing-library/jest-dom/vitest";\n'
|
|
347
|
+
'import { afterEach } from "vitest";\n'
|
|
348
|
+
'import { cleanup } from "@testing-library/react";\n\n'
|
|
349
|
+
'afterEach(() => {\n cleanup();\n});\n',
|
|
350
|
+
encoding="utf-8",
|
|
351
|
+
)
|
|
352
|
+
notes.append(f"frontend test harness: wrote {setup_rel}")
|
|
353
|
+
notes.append("frontend test harness: ensure vite config uses `vitest/config` with a "
|
|
354
|
+
"`test` block (environment: jsdom, globals: true, setupFiles: './src/test/setup.ts')")
|
|
355
|
+
return notes
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def check_venv(target: Path, install: bool, dry: bool) -> list[str]:
|
|
359
|
+
notes: list[str] = []
|
|
360
|
+
has_py = (target / "pyproject.toml").exists() or (target / "requirements.txt").exists()
|
|
361
|
+
if not has_py:
|
|
362
|
+
return notes
|
|
363
|
+
venv = target / ".venv"
|
|
364
|
+
if venv.exists():
|
|
365
|
+
notes.append("venv: .venv present — ok")
|
|
366
|
+
elif install and not dry:
|
|
367
|
+
import subprocess
|
|
368
|
+
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=False)
|
|
369
|
+
notes.append("venv: created .venv (install dev deps: .venv/Scripts/pip install -e .[dev])")
|
|
370
|
+
else:
|
|
371
|
+
notes.append("venv: MISSING — create with `python -m venv .venv` then "
|
|
372
|
+
"`pip install -e .[dev]` (or re-run with --install)")
|
|
373
|
+
return notes
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Enforced "green before push" gate. Installed into .git/hooks/pre-push. Pure POSIX sh
|
|
377
|
+
# so it runs under Git's bundled shell on Windows/Linux/macOS.
|
|
378
|
+
#
|
|
379
|
+
# TOOLS RUN IN THE PROJECT'S OWN INTERPRETER, NOT WHATEVER IS ON PATH. This was a real
|
|
380
|
+
# defect found by app-forge's dry run (2026-08-06): the gate resolved bare `pytest` and
|
|
381
|
+
# `mypy` off PATH, which on Windows is the machine-wide C:\Python interpreter — so it
|
|
382
|
+
# tested the app in an environment without the app's dependencies. Meanwhile `ruff` was
|
|
383
|
+
# not on PATH at all and was silently skipped, even though the app had ruff in its venv.
|
|
384
|
+
# It ran the two checks that could not work and skipped the one that would.
|
|
385
|
+
#
|
|
386
|
+
# A DECLARED-BUT-MISSING TOOL NOW BLOCKS. The old rule was "auto-skip anything not
|
|
387
|
+
# installed", which is degrade-open: the gate reported success precisely when it had
|
|
388
|
+
# checked the least. If a project asks for ruff in its pyproject and ruff cannot run,
|
|
389
|
+
# that is a broken environment, not a project without linting — and a check that cannot
|
|
390
|
+
# run must not report success. Tools the project never asked for are still skipped
|
|
391
|
+
# silently, because those genuinely are not part of its standards.
|
|
392
|
+
PRE_PUSH_HOOK = r"""#!/usr/bin/env sh
|
|
393
|
+
# Managed by caddis setup-project-ai. Delete this file to opt out.
|
|
394
|
+
set -eu
|
|
395
|
+
echo "[caddis] pre-push quality gate"
|
|
396
|
+
fail=0
|
|
397
|
+
|
|
398
|
+
# Prefer the project's virtualenv over PATH. Checked in venv-layout order for both
|
|
399
|
+
# Windows (Scripts/) and POSIX (bin/) so the same hook works on either.
|
|
400
|
+
PY=""
|
|
401
|
+
for cand in .venv/Scripts/python.exe .venv/bin/python venv/Scripts/python.exe venv/bin/python; do
|
|
402
|
+
if [ -x "$cand" ]; then PY="$cand"; break; fi
|
|
403
|
+
done
|
|
404
|
+
if [ -z "$PY" ]; then PY=$(command -v python || command -v python3 || true); fi
|
|
405
|
+
|
|
406
|
+
# Runnable IN $PY - not merely present on PATH. This is the whole point: `command -v mypy`
|
|
407
|
+
# answers a question about PATH, and the question we care about is about the environment
|
|
408
|
+
# the app's code actually imports from.
|
|
409
|
+
py_has() { [ -n "$PY" ] && "$PY" -m "$1" --version >/dev/null 2>&1; }
|
|
410
|
+
# Does the project ASK for this tool? If so, being unable to run it is a failure.
|
|
411
|
+
py_wants() { grep -qi -- "$1" pyproject.toml requirements.txt requirements-dev.txt 2>/dev/null; }
|
|
412
|
+
|
|
413
|
+
# Does this project actually contain Python source? A pyproject.toml alone does not mean it
|
|
414
|
+
# does - a config-only or frontend repo can carry one - and mypy exits non-zero with "There
|
|
415
|
+
# are no .py[i] files in directory" when handed nothing. Skipping a checker that has nothing
|
|
416
|
+
# to check is honest; failing the push over it is not.
|
|
417
|
+
py_sources_exist() {
|
|
418
|
+
[ -n "$(find . -name '*.py' -not -path './.venv/*' -not -path './venv/*' -not -path './node_modules/*' -not -path './.git/*' 2>/dev/null | head -1)" ]
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
py_gate() {
|
|
422
|
+
tool=$1; shift
|
|
423
|
+
if py_has "$tool"; then
|
|
424
|
+
echo "[gate] $tool $*"
|
|
425
|
+
rc=0
|
|
426
|
+
"$PY" -m "$tool" "$@" || rc=$?
|
|
427
|
+
if [ "$rc" -ne 0 ]; then
|
|
428
|
+
# pytest exit 5 = "no tests collected". A repo that has not written tests yet is not a
|
|
429
|
+
# failing repo, and blocking it would hit exactly the fresh-scaffold case this fix exists
|
|
430
|
+
# for. Every other non-zero code is a real failure.
|
|
431
|
+
if [ "$tool" = "pytest" ] && [ "$rc" -eq 5 ]; then
|
|
432
|
+
echo "[gate] pytest: no tests collected yet - not treated as a failure"
|
|
433
|
+
else
|
|
434
|
+
fail=1
|
|
435
|
+
fi
|
|
436
|
+
fi
|
|
437
|
+
elif py_wants "$tool"; then
|
|
438
|
+
echo "[gate] $tool: DECLARED by this project but not runnable in $PY - environment is broken" >&2
|
|
439
|
+
echo " fix: activate the venv, or reinstall dev deps (pip install -e '.[dev]')" >&2
|
|
440
|
+
fail=1
|
|
441
|
+
fi
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if [ -f "pyproject.toml" ] || [ -f "requirements.txt" ]; then
|
|
445
|
+
if [ -z "$PY" ]; then
|
|
446
|
+
echo "[gate] python project, but no interpreter found (.venv or PATH) - cannot verify" >&2
|
|
447
|
+
fail=1
|
|
448
|
+
else
|
|
449
|
+
echo "[gate] interpreter: $PY"
|
|
450
|
+
py_gate ruff check .
|
|
451
|
+
if py_sources_exist; then py_gate mypy .; else echo "[skip] mypy: no Python sources"; fi
|
|
452
|
+
py_gate pytest -q
|
|
453
|
+
fi
|
|
454
|
+
fi
|
|
455
|
+
if [ -f "package.json" ] && command -v npm >/dev/null 2>&1; then
|
|
456
|
+
npm run 2>/dev/null | grep -q " lint" && { echo "[gate] npm run lint"; npm run lint --silent || fail=1; }
|
|
457
|
+
npm run 2>/dev/null | grep -q " typecheck" && { echo "[gate] npm run typecheck"; npm run typecheck --silent || fail=1; }
|
|
458
|
+
npm run 2>/dev/null | grep -q " test" && { echo "[gate] npm test"; npm test --silent || fail=1; }
|
|
459
|
+
fi
|
|
460
|
+
# Doc-coverage discipline (any stack). The checker exits non-zero ONLY on a hard invariant
|
|
461
|
+
# (missing route / dangling doc-map link); soft signals warn without failing. Auto-skips when the
|
|
462
|
+
# checker isn't present (older repos). Reuses $PY - resolved above with the project venv preferred -
|
|
463
|
+
# rather than re-probing PATH, so it reads the same tree the rest of the gate does.
|
|
464
|
+
if [ -f "scripts/check_doc_coverage.py" ]; then
|
|
465
|
+
DOC_PY="$PY"
|
|
466
|
+
if [ -z "$DOC_PY" ]; then DOC_PY=$(command -v python || command -v python3 || true); fi
|
|
467
|
+
if [ -n "$DOC_PY" ]; then
|
|
468
|
+
echo "[gate] doc coverage"
|
|
469
|
+
"$DOC_PY" scripts/check_doc_coverage.py --check || fail=1
|
|
470
|
+
fi
|
|
471
|
+
fi
|
|
472
|
+
if [ "$fail" -ne 0 ]; then
|
|
473
|
+
echo "[caddis] push BLOCKED - fix the above, or 'git push --no-verify' to override." >&2
|
|
474
|
+
exit 1
|
|
475
|
+
fi
|
|
476
|
+
echo "[caddis] gate passed - pushing."
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def install_git_hooks(target: Path, force: bool, dry: bool) -> list[str]:
|
|
481
|
+
"""Install the enforced pre-push quality gate into .git/hooks/pre-push."""
|
|
482
|
+
notes: list[str] = []
|
|
483
|
+
git = target / ".git"
|
|
484
|
+
if not git.exists():
|
|
485
|
+
return ["pre-push gate: no .git (not a git repo) — skipped"]
|
|
486
|
+
if git.is_file(): # worktree/submodule: .git is a pointer file
|
|
487
|
+
return ["pre-push gate: .git is a worktree pointer — skipped (install manually if wanted)"]
|
|
488
|
+
hooks_dir = git / "hooks"
|
|
489
|
+
dest = hooks_dir / "pre-push"
|
|
490
|
+
if dest.exists() and not force:
|
|
491
|
+
_hook_txt = dest.read_text(encoding="utf-8", errors="ignore")
|
|
492
|
+
managed = "caddis" in _hook_txt or "claudster" in _hook_txt # recognise pre-rename hooks too
|
|
493
|
+
return [f"pre-push gate: exists ({'ours' if managed else 'yours — left intact, use --force to replace'}) — skipped"]
|
|
494
|
+
if not dry:
|
|
495
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
dest.write_text(PRE_PUSH_HOOK, encoding="utf-8", newline="\n")
|
|
497
|
+
try:
|
|
498
|
+
import os, stat
|
|
499
|
+
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
notes.append("pre-push gate: installed .git/hooks/pre-push (green-before-push enforced)")
|
|
503
|
+
return notes
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def emit_doc_discipline(target: Path, ident: dict[str, str], force: bool, dry: bool) -> list[str]:
|
|
507
|
+
"""Scaffold the doc-coverage discipline into the target:
|
|
508
|
+
• `<artifact-dir>/kb/DOC-MAP.md` — reference-doc index (always);
|
|
509
|
+
• `UI_PAGE_GUIDE.md` — page→endpoints→DB stub (frontend repos only, i.e. a `frontend/` dir);
|
|
510
|
+
• copy `scripts/check_doc_coverage.py` into the target.
|
|
511
|
+
Idempotent: never clobbers an edited file unless --force. The scaffolded DOC-MAP carries no
|
|
512
|
+
`.md` links so a fresh repo is gate-clean (no dangling-link hard failure)."""
|
|
513
|
+
notes: list[str] = []
|
|
514
|
+
cm = HARNESS_DIR / "claude-md"
|
|
515
|
+
mapping = {"PROJECT_NAME": ident["name"], "PROJECT_DESCRIPTION": ident["desc"],
|
|
516
|
+
"ARTIFACT_DIR": artifact_root(target).name}
|
|
517
|
+
|
|
518
|
+
def _emit(rel: str, tmpl: str, label: str):
|
|
519
|
+
src = cm / tmpl
|
|
520
|
+
if not src.is_file():
|
|
521
|
+
notes.append(f"{label}: template missing — skipped")
|
|
522
|
+
return
|
|
523
|
+
dest = target / rel
|
|
524
|
+
if dest.exists() and not force:
|
|
525
|
+
notes.append(f"{label}: {rel} present — kept")
|
|
526
|
+
return
|
|
527
|
+
if not dry:
|
|
528
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
dest.write_text(render(src.read_text(encoding="utf-8"), mapping),
|
|
530
|
+
encoding="utf-8", newline="\n")
|
|
531
|
+
notes.append(f"{label}: wrote {rel}")
|
|
532
|
+
|
|
533
|
+
# DOC-MAP: render the template, then pre-link the repo's discovered reference docs (README,
|
|
534
|
+
# docs/…) and any existing KB notes, so a fresh repo starts with a *useful* index instead of an
|
|
535
|
+
# empty placeholder. Reuses check_doc_coverage's helpers (one implementation of the link logic).
|
|
536
|
+
# Idempotent: an existing (possibly edited) map is kept untouched unless --force.
|
|
537
|
+
dm_dest = artifact_root(target) / "kb" / "DOC-MAP.md"
|
|
538
|
+
dm_rel = dm_dest.relative_to(target).as_posix()
|
|
539
|
+
if not (cm / "doc-map.md.tmpl").is_file():
|
|
540
|
+
notes.append("doc-map: template missing — skipped")
|
|
541
|
+
elif dm_dest.exists() and not force:
|
|
542
|
+
notes.append(f"doc-map: {dm_rel} present — kept")
|
|
543
|
+
else:
|
|
544
|
+
text = render((cm / "doc-map.md.tmpl").read_text(encoding="utf-8"), mapping)
|
|
545
|
+
n_links = None
|
|
546
|
+
try:
|
|
547
|
+
_cs = str(HARNESS_DIR / "scripts")
|
|
548
|
+
if _cs not in sys.path:
|
|
549
|
+
sys.path.insert(0, _cs)
|
|
550
|
+
import check_doc_coverage as _cdc
|
|
551
|
+
text = _cdc.insert_table_rows(text, "Knowledge base", _cdc.kb_note_rows(target))
|
|
552
|
+
text = _cdc.insert_table_rows(text, "Other key", _cdc.reference_rows(target))
|
|
553
|
+
n_links = text.count("](")
|
|
554
|
+
except Exception:
|
|
555
|
+
pass # discovery is a nicety — a plain template scaffold is still valid & gate-clean
|
|
556
|
+
if not dry:
|
|
557
|
+
dm_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
dm_dest.write_text(text, encoding="utf-8", newline="\n")
|
|
559
|
+
notes.append(f"doc-map: wrote {dm_rel}" + (f" ({n_links} link(s) pre-indexed)" if n_links else ""))
|
|
560
|
+
|
|
561
|
+
if (target / "frontend").exists():
|
|
562
|
+
_emit("UI_PAGE_GUIDE.md", "ui-page-guide.md.tmpl", "page guide")
|
|
563
|
+
|
|
564
|
+
# Copy the checker + its shared config reader into the target's scripts/ (same pattern as
|
|
565
|
+
# claudster_config.py must ride along or the checker's [doc_coverage]
|
|
566
|
+
# override can't be read (it falls back to defaults — safe, but the config would be inert).
|
|
567
|
+
for fname, label in (
|
|
568
|
+
("check_doc_coverage.py", "doc-coverage checker"),
|
|
569
|
+
("claudster_config.py", "config reader"),
|
|
570
|
+
):
|
|
571
|
+
src = HARNESS_DIR / "scripts" / fname
|
|
572
|
+
dest = target / "scripts" / fname
|
|
573
|
+
if not src.is_file():
|
|
574
|
+
notes.append(f"{label}: source missing — skipped")
|
|
575
|
+
elif dest.exists() and not force:
|
|
576
|
+
notes.append(f"{label}: scripts/{fname} present — skipped")
|
|
577
|
+
else:
|
|
578
|
+
if not dry:
|
|
579
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8", newline="\n")
|
|
581
|
+
notes.append(f"{label}: wrote scripts/{fname}")
|
|
582
|
+
return notes
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def extract_project_facts(target: Path, stack: dict) -> dict:
|
|
586
|
+
"""Mechanically pull the facts the AI enrichment step (skill Step 3) otherwise has to
|
|
587
|
+
hunt for: run/test/build commands, env-var names, CI/deploy workflows, entry points.
|
|
588
|
+
Best-effort — any unreadable source is skipped. NEVER reads a real .env (only *.example),
|
|
589
|
+
and captures variable NAMES only, never values."""
|
|
590
|
+
facts: dict[str, list[str]] = {"commands": [], "env": [], "workflows": [], "entry": []}
|
|
591
|
+
|
|
592
|
+
# npm scripts (root + frontend/)
|
|
593
|
+
for pj in (target / "package.json", target / "frontend" / "package.json"):
|
|
594
|
+
if pj.is_file():
|
|
595
|
+
try:
|
|
596
|
+
scripts = json.loads(pj.read_text(encoding="utf-8")).get("scripts", {})
|
|
597
|
+
rel = pj.parent.relative_to(target)
|
|
598
|
+
prefix = "" if str(rel) == "." else f"(in {rel}/) "
|
|
599
|
+
for name in scripts:
|
|
600
|
+
facts["commands"].append(f"{prefix}npm run {name}")
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
# python scripts + the obvious test runner
|
|
605
|
+
pp = target / "pyproject.toml"
|
|
606
|
+
if pp.is_file():
|
|
607
|
+
try:
|
|
608
|
+
import tomllib
|
|
609
|
+
data = tomllib.loads(pp.read_text(encoding="utf-8"))
|
|
610
|
+
tables = [data.get("project", {}).get("scripts", {}),
|
|
611
|
+
data.get("tool", {}).get("poetry", {}).get("scripts", {})]
|
|
612
|
+
for table in tables:
|
|
613
|
+
for name in (table or {}):
|
|
614
|
+
facts["commands"].append(f"{name} (pyproject script)")
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
617
|
+
if pp.is_file() or (target / "requirements.txt").is_file():
|
|
618
|
+
facts["commands"].append("pytest -q (if pytest configured)")
|
|
619
|
+
|
|
620
|
+
# env var NAMES from example files only — never the real .env (it holds secrets)
|
|
621
|
+
for envf in (".env.example", ".env.sample", ".env.template"):
|
|
622
|
+
ef = target / envf
|
|
623
|
+
if ef.is_file():
|
|
624
|
+
try:
|
|
625
|
+
for line in ef.read_text(encoding="utf-8").splitlines():
|
|
626
|
+
s = line.strip()
|
|
627
|
+
if s and not s.startswith("#") and "=" in s:
|
|
628
|
+
nm = s.split("=", 1)[0].strip()
|
|
629
|
+
if nm and nm == nm.upper() and nm.replace("_", "").isalnum():
|
|
630
|
+
facts["env"].append(nm)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
break
|
|
634
|
+
|
|
635
|
+
# CI / deploy workflows
|
|
636
|
+
for wfdir in (".gitea/workflows", ".github/workflows"):
|
|
637
|
+
d = target / wfdir
|
|
638
|
+
if d.is_dir():
|
|
639
|
+
for f in sorted(list(d.glob("*.yml")) + list(d.glob("*.yaml"))):
|
|
640
|
+
facts["workflows"].append(f"{wfdir}/{f.name}")
|
|
641
|
+
|
|
642
|
+
# entry points: detected stack folders + common entry files
|
|
643
|
+
for det in stack.get("matched", []):
|
|
644
|
+
tgt = det.get("target")
|
|
645
|
+
if not tgt:
|
|
646
|
+
continue
|
|
647
|
+
folder = (target / tgt).parent
|
|
648
|
+
if folder.exists() and str(folder.relative_to(target)) != ".":
|
|
649
|
+
facts["entry"].append(f"{folder.relative_to(target)}/ ({det.get('id', 'stack')})")
|
|
650
|
+
for cand in ("main.py", "app.py", "manage.py", "src/main.tsx", "src/main.ts",
|
|
651
|
+
"src/index.tsx", "src/App.tsx"):
|
|
652
|
+
if (target / cand).is_file():
|
|
653
|
+
facts["entry"].append(cand)
|
|
654
|
+
|
|
655
|
+
for k, vals in facts.items():
|
|
656
|
+
seen: set[str] = set()
|
|
657
|
+
facts[k] = [v for v in vals if not (v in seen or seen.add(v))]
|
|
658
|
+
return facts
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def write_project_facts(target: Path, facts: dict, dry: bool) -> list[str]:
|
|
662
|
+
"""Seed <artifact-dir>/PROJECT-FACTS.md so the AI enrichment step starts from real data."""
|
|
663
|
+
if not any(facts.values()):
|
|
664
|
+
return ["project facts: nothing auto-extractable — skipped"]
|
|
665
|
+
out = [
|
|
666
|
+
"# Project facts — auto-extracted by setup-project-ai",
|
|
667
|
+
"",
|
|
668
|
+
"> Starting point for AGENTS.md enrichment (skill Step 3). Pulled mechanically from",
|
|
669
|
+
"> package.json / pyproject.toml / .env.example / workflows — **verify, refine, fold the",
|
|
670
|
+
"> right ones into the matching AGENTS.md (root vs backend/ vs frontend/) — the canonical rules",
|
|
671
|
+
"> file, NOT the CLAUDE.md shim — then delete this file.**",
|
|
672
|
+
"",
|
|
673
|
+
]
|
|
674
|
+
|
|
675
|
+
def sec(title: str, items: list[str]) -> list[str]:
|
|
676
|
+
return [f"## {title}", "", *[f"- `{i}`" for i in items], ""] if items else []
|
|
677
|
+
|
|
678
|
+
out += sec("Commands (run / test / build)", facts["commands"])
|
|
679
|
+
out += sec("Environment variables (names only — values live in your real .env)", facts["env"])
|
|
680
|
+
out += sec("CI / deploy workflows", facts["workflows"])
|
|
681
|
+
out += sec("Entry points / key folders", facts["entry"])
|
|
682
|
+
dest = artifact_root(target) / "PROJECT-FACTS.md"
|
|
683
|
+
if not dry:
|
|
684
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
685
|
+
dest.write_text("\n".join(out), encoding="utf-8")
|
|
686
|
+
n = sum(len(v) for v in facts.values())
|
|
687
|
+
rel = dest.relative_to(target).as_posix()
|
|
688
|
+
return [f"project facts: wrote {rel} ({n} facts — fold into the hierarchy, then delete)"]
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
# Scaffolded into <artifact-dir>/comms/. The convention is deliberately thin - one file per
|
|
692
|
+
# message, one row here - because the failure it prevents is not complexity, it is evaporation.
|
|
693
|
+
COMMS_REGISTER_TEMPLATE = """# Comms register
|
|
694
|
+
|
|
695
|
+
Outbound messages live in this directory - anything that must be **sent to a human outside this
|
|
696
|
+
repo**: an access request, a decision from another team, a change only someone else can make.
|
|
697
|
+
|
|
698
|
+
**A message not in this table does not exist.** The directory stops a draft from evaporating; this
|
|
699
|
+
table stops a sent message from going unchased. Comms are almost always sent to get an *action*, and
|
|
700
|
+
an unanswered ask looks exactly like a forgotten one unless something is tracking it.
|
|
701
|
+
|
|
702
|
+
| Raised | Audience | Subject | Status | Blocks | File |
|
|
703
|
+
|---|---|---|---|---|---|
|
|
704
|
+
| _(none yet)_ | | | | | |
|
|
705
|
+
|
|
706
|
+
## Status vocabulary
|
|
707
|
+
|
|
708
|
+
| Status | Means |
|
|
709
|
+
|---|---|
|
|
710
|
+
| `DRAFT` | Written, not sent. Needs a human to send it. |
|
|
711
|
+
| `SENT` | Sent - record the date and channel. |
|
|
712
|
+
| `ANSWERED` | A reply arrived. Record the outcome, including a "no". |
|
|
713
|
+
| `ACTIONED` | The thing we asked for actually happened. |
|
|
714
|
+
| `DROPPED` | Deliberately abandoned - **record why**, or the next session re-raises it. |
|
|
715
|
+
|
|
716
|
+
`ANSWERED` and `ACTIONED` are separate on purpose: "they replied" and "they did it" are different
|
|
717
|
+
states, and treating them as one is how work stalls while looking resolved.
|
|
718
|
+
|
|
719
|
+
## Writing one
|
|
720
|
+
|
|
721
|
+
- Name it `YYYY-MM-DD-<audience>-<subject>.md`, and add its row here in the SAME commit.
|
|
722
|
+
- **Write it send-ready.** The recipient has not read the plan. No internal paths, no repo slugs, no
|
|
723
|
+
phase numbers. If it cannot be pasted into an email unedited, it is not finished.
|
|
724
|
+
- **State the ask in one sentence, near the top.** Everything else is supporting evidence.
|
|
725
|
+
- **Give them a cheap way to say yes** - an alternative that costs them less than the primary ask
|
|
726
|
+
converts far better than take-it-or-leave-it.
|
|
727
|
+
- `blocks:` is the field that earns its keep: what stalls if this is never answered?
|
|
728
|
+
- **Never put secrets here.** Comms are the artifact most likely to be copied outward.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# Scaffolded into <artifact-dir>/parking-lot/. Same reasoning as the comms register: a directory
|
|
733
|
+
# alone fixes "never written down", and it takes a stated contract to fix "written down in six
|
|
734
|
+
# different shapes so nothing can count them".
|
|
735
|
+
PARKING_LOT_README_TEMPLATE = """# Parking lot - the ONE register of future work
|
|
736
|
+
|
|
737
|
+
Anything that must happen later lives here. A defect you are not fixing now, a proposal, a parked
|
|
738
|
+
plan, an idea worth keeping. **One item, one file.**
|
|
739
|
+
|
|
740
|
+
**If it is not in this directory, it is not on the backlog.** That is the whole rule. Every other
|
|
741
|
+
place work used to hide - a note inside a plan, a "things owed" section in `relay.md`, a KB note, a
|
|
742
|
+
second register file - now POINTS here instead of holding the item itself.
|
|
743
|
+
|
|
744
|
+
## Why one place
|
|
745
|
+
|
|
746
|
+
Future work spread across many files looks organised and is not. Nobody can answer "what is left to
|
|
747
|
+
do?" without reading all of them, so nobody asks, and items are re-raised or quietly dropped. One
|
|
748
|
+
directory makes the answer a directory listing.
|
|
749
|
+
|
|
750
|
+
## Frontmatter (this is checked, not suggested)
|
|
751
|
+
|
|
752
|
+
```yaml
|
|
753
|
+
---
|
|
754
|
+
type: parking-lot # always this, whatever the item started life as
|
|
755
|
+
status: open # open | doing | done | dropped
|
|
756
|
+
future: yes # OPTIONAL. yes = decided and owed. Absent or `no` = a candidate.
|
|
757
|
+
severity: high # OPTIONAL. high | medium | low
|
|
758
|
+
found: 2026-08-14 # OPTIONAL. When it was raised.
|
|
759
|
+
found-by: <repo/session> # OPTIONAL. Who or what raised it.
|
|
760
|
+
---
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
`status` and `future` are **two independent questions**. `status` asks where the item is in its
|
|
764
|
+
life. `future` asks whether we have committed to it. An item can be `status: open` and
|
|
765
|
+
`future: yes` - agreed, not started. Do not fold them together.
|
|
766
|
+
|
|
767
|
+
## Status vocabulary
|
|
768
|
+
|
|
769
|
+
| Status | Means |
|
|
770
|
+
|---|---|
|
|
771
|
+
| `open` | Raised, not started. |
|
|
772
|
+
| `doing` | Someone is on it now. |
|
|
773
|
+
| `done` | Finished. `caddis_tidy.py --apply` moves it to `done/`. |
|
|
774
|
+
| `dropped` | Deliberately abandoned. **Write why in the body**, or the next session re-raises it. |
|
|
775
|
+
|
|
776
|
+
The list is closed on purpose. `wip`, `todo` and `pending` are not accepted, because "how many open
|
|
777
|
+
items?" has no answer when one state has three spellings.
|
|
778
|
+
|
|
779
|
+
## Writing one
|
|
780
|
+
|
|
781
|
+
- Name it `<short-slug>.md`. No number prefix: two sessions filing at once would pick the same number.
|
|
782
|
+
- Say what you saw, then what it costs, then the suggested fix. Evidence first.
|
|
783
|
+
- Give it a `severity` an outsider could check, not one that reflects how annoyed you were.
|
|
784
|
+
- **Do not start a second register.** A file here over 20 KB fails the check. If an item grew that
|
|
785
|
+
big it is several items.
|
|
786
|
+
|
|
787
|
+
## What does NOT belong here
|
|
788
|
+
|
|
789
|
+
| Not this | Where it goes | Why |
|
|
790
|
+
|---|---|---|
|
|
791
|
+
| Work in flight right now | `.caddis/plans/<feature>.md` | A plan is being executed. A parking-lot item is not. |
|
|
792
|
+
| A task interrupted mid-session | `/caddis:digress` stack | That is a pause, not a backlog item. `/caddis:resume` pops it. |
|
|
793
|
+
| An ask only an outsider can resolve | `.caddis/comms/register.md` | Blocked on a human, not on us. |
|
|
794
|
+
| Board cards | `.caddis/backlog/` | **docket writes that directory.** It is a projection of the board, not a hand-written register. Never edit it by hand. |
|
|
795
|
+
|
|
796
|
+
## Commands
|
|
797
|
+
|
|
798
|
+
- `/caddis:park` - file an item correctly, or list what is open.
|
|
799
|
+
- `python scripts/caddis_tidy.py --check` - fails (exit 1) on any item that breaks the contract.
|
|
800
|
+
- `python scripts/caddis_tidy.py --apply` - sweeps `done` and `dropped` items into `done/`.
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# Scaffolded into <artifact-dir>/kb/environment-map.md. An empty file with the right headings gets
|
|
805
|
+
# filled in; a blank page does not, and neither does a rule with nowhere to write.
|
|
806
|
+
ENVIRONMENT_MAP_TEMPLATE = """---
|
|
807
|
+
type: reference
|
|
808
|
+
title: Environment map - hosts, logins, repos, and what is really where
|
|
809
|
+
description: Durable facts about the environment this project runs in. Read on demand; not loaded every turn.
|
|
810
|
+
tags: [environment, hosts, access, topology]
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
# Environment map
|
|
814
|
+
|
|
815
|
+
**Facts about the world outside this repository.** Hostnames, which login actually works, where
|
|
816
|
+
another team's repository lives, what a folder really contains, which port a service listens on.
|
|
817
|
+
|
|
818
|
+
## The rule
|
|
819
|
+
|
|
820
|
+
**If you learn an environment fact, write it here in the same turn.** A fact that exists only in a
|
|
821
|
+
conversation is lost the moment that session ends, and the next session cannot know it was ever
|
|
822
|
+
said.
|
|
823
|
+
|
|
824
|
+
Three real examples of the cost:
|
|
825
|
+
|
|
826
|
+
| Fact | Told in chat | What it cost |
|
|
827
|
+
|---|---|---|
|
|
828
|
+
| A reporting tool refused Windows auth; a named SQL login was required | yes | ~10 minutes, **three separate times** |
|
|
829
|
+
| A queue table's true row count | measured, written to one file and not another | An incoming session found the contradiction and had to resolve it |
|
|
830
|
+
| An app lived in the local Gitea, not on disk | yes | A session searched the filesystem for it; the user had to stop it |
|
|
831
|
+
|
|
832
|
+
The third is the clearest. Documents cited a file in that repository **by path**, so somebody had
|
|
833
|
+
read it - and nothing anywhere recorded **where the repository was**.
|
|
834
|
+
|
|
835
|
+
## Why here and not in AGENTS.md
|
|
836
|
+
|
|
837
|
+
`AGENTS.md` and `CLAUDE.md` load on **every** turn, so a connection string there costs context in
|
|
838
|
+
every session forever. `AGENTS.md` is also for durable **rules**, and a hostname is not a rule. The
|
|
839
|
+
KB is read on demand, indexed in `DOC-MAP.md`, and gate-checked. This is the right shelf.
|
|
840
|
+
|
|
841
|
+
**Never put a secret here.** Record *where* a credential lives and *which* account works, never the
|
|
842
|
+
value. Keys belong in a keys file outside every git repository.
|
|
843
|
+
|
|
844
|
+
## Hosts and services
|
|
845
|
+
|
|
846
|
+
| Host | What runs there | How to reach it | Notes |
|
|
847
|
+
|---|---|---|---|
|
|
848
|
+
| _(none recorded yet)_ | | | |
|
|
849
|
+
|
|
850
|
+
## Databases
|
|
851
|
+
|
|
852
|
+
| Database | Server | Which login works | Gotchas |
|
|
853
|
+
|---|---|---|---|
|
|
854
|
+
| _(none recorded yet)_ | | | |
|
|
855
|
+
|
|
856
|
+
Record the login that **works**, and the one that looks right and does not. The second saves more
|
|
857
|
+
time than the first.
|
|
858
|
+
|
|
859
|
+
## Repositories that are not this one
|
|
860
|
+
|
|
861
|
+
| Repository | Where it actually lives | Why we care |
|
|
862
|
+
|---|---|---|
|
|
863
|
+
| _(none recorded yet)_ | | |
|
|
864
|
+
|
|
865
|
+
"Where it actually lives" means the clone URL or the host - not a guess that it is on disk.
|
|
866
|
+
|
|
867
|
+
## Data outside the repository
|
|
868
|
+
|
|
869
|
+
| What | Where | Refreshed how |
|
|
870
|
+
|---|---|---|
|
|
871
|
+
| _(none recorded yet)_ | | |
|
|
872
|
+
|
|
873
|
+
## Accounts and access
|
|
874
|
+
|
|
875
|
+
| What | Account or role | Requested from | Notes |
|
|
876
|
+
|---|---|---|---|
|
|
877
|
+
| _(none recorded yet)_ | | | |
|
|
878
|
+
|
|
879
|
+
## Things that look like facts and are not
|
|
880
|
+
|
|
881
|
+
Readings that mislead: a virtualisation flag that reads false because a hypervisor is already
|
|
882
|
+
running, a file whose encoding mangles under an older shell, a command whose exit code means
|
|
883
|
+
"nothing to do" rather than "failed". Record each one you hit, with what it actually means.
|
|
884
|
+
|
|
885
|
+
| Reading | Looks like | Actually means |
|
|
886
|
+
|---|---|---|
|
|
887
|
+
| _(none recorded yet)_ | | |
|
|
888
|
+
"""
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
ARTIFACT_GITIGNORE = """\
|
|
892
|
+
# caddis artifacts — commit plans/handoffs/agent-docs/prd; ignore transient state
|
|
893
|
+
reviews/*.html
|
|
894
|
+
usage-log.jsonl
|
|
895
|
+
agent-log.jsonl
|
|
896
|
+
.last-usage-review
|
|
897
|
+
relay.md
|
|
898
|
+
relay/
|
|
899
|
+
PROJECT-FACTS.md
|
|
900
|
+
memory.jsonl
|
|
901
|
+
"""
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
ARTIFACT_CONFIG_EXAMPLE = """\
|
|
905
|
+
# caddis per-repo configuration — a documented template.
|
|
906
|
+
#
|
|
907
|
+
# Nothing here takes effect until you copy this file to `config.toml` (same directory) and
|
|
908
|
+
# uncomment the keys you want. The caddis hooks/scripts read `config.toml` (never this `.example`)
|
|
909
|
+
# from the repo root's `.caddis/`. Unknown sections/keys are ignored, so a section that a given
|
|
910
|
+
# caddis version doesn't yet honor is harmless to leave in place.
|
|
911
|
+
#
|
|
912
|
+
# All three sections below are honored. Every key falls back to a baked-in default, so uncomment
|
|
913
|
+
# only the ones you want to change.
|
|
914
|
+
|
|
915
|
+
# ── [guard] — PreToolUse safety hook (hooks/guard.py) ── LIVE ──
|
|
916
|
+
# The guard classifies each Bash/Edit/Write into deny | ask | allow. `allow` is an escape hatch that
|
|
917
|
+
# may ONLY DOWNGRADE an `ask` to `allow` — it can never override a `deny` (a secret write, a
|
|
918
|
+
# catastrophic shell command). Each entry is a case-insensitive SUBSTRING matched against the command
|
|
919
|
+
# text (Bash) or the file path (Edit/Write). Keep entries specific: a broad substring like "git"
|
|
920
|
+
# would silence every gated git operation.
|
|
921
|
+
#
|
|
922
|
+
# KILL SWITCH — turn the guard OFF entirely (bypasses ALL tiers, deny included). For users who run
|
|
923
|
+
# Claude Code with `bypassPermissions` and want no second enforcement layer. Either:
|
|
924
|
+
# enabled = false # (or mode = "off") — disables the guard for THIS repo.
|
|
925
|
+
# env CADDIS_GUARD_DISABLED=1 — global, applies everywhere, survives plugin auto-updates.
|
|
926
|
+
# The env var is the recommended global switch; add it under `"env"` in ~/.claude/settings.json.
|
|
927
|
+
[guard]
|
|
928
|
+
# enabled = false
|
|
929
|
+
allow = [
|
|
930
|
+
# "git push --force origin scratch", # allow force-push to one throwaway branch
|
|
931
|
+
# "yarn.lock", # stop confirming routine lockfile edits in this repo
|
|
932
|
+
# "npm publish --dry-run", # a publish you run often and trust
|
|
933
|
+
]
|
|
934
|
+
|
|
935
|
+
# ── [doc_coverage] — reference-doc gate (scripts/check_doc_coverage.py) ── LIVE ──
|
|
936
|
+
# [doc_coverage]
|
|
937
|
+
# route_tree = "frontend/src/routeTree.gen.ts" # where the generated route tree lives
|
|
938
|
+
# page_guide = "UI_PAGE_GUIDE.md" # the guide that must cover every live route
|
|
939
|
+
# agents_md_budget = 200 # warn when an always-loaded AGENTS.md (+ its CLAUDE.md
|
|
940
|
+
# # shim) exceeds N lines. `claude_md_budget` is the
|
|
941
|
+
# # accepted back-compat alias for this key.
|
|
942
|
+
# ignore_routes = ["/health", "/__internal"] # routes intentionally left out of the page guide
|
|
943
|
+
|
|
944
|
+
# ── [dream_memory] — short-term fact store (scripts/dream_memory.py) ── LIVE ──
|
|
945
|
+
# [dream_memory]
|
|
946
|
+
# prune_age_days = 14 # single-hit facts older than this decay out
|
|
947
|
+
# max_facts = 200 # hard cap on the store
|
|
948
|
+
# surface_limit = 5 # how many facts SessionStart prints
|
|
949
|
+
"""
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def stamp_caddis_version(target: Path, dry: bool) -> list[str]:
|
|
953
|
+
"""Record WHICH caddis scaffolded this project, in `.caddis/.caddis-version`.
|
|
954
|
+
|
|
955
|
+
Without this, "is this app's harness stale?" cannot be answered without diffing every
|
|
956
|
+
generated file against a current caddis - so in practice nobody asks, and apps drift silently
|
|
957
|
+
for months. One line makes the question cheap, and makes a fleet-wide staleness sweep a loop
|
|
958
|
+
rather than an investigation.
|
|
959
|
+
|
|
960
|
+
Deliberately NOT gitignored: the whole point is that a reviewer, or a future session, can see
|
|
961
|
+
what built this and when. Rewritten on every run, so re-running the harness updates the stamp.
|
|
962
|
+
"""
|
|
963
|
+
notes: list[str] = []
|
|
964
|
+
version = "unknown"
|
|
965
|
+
# The harness may be running from an installed plugin (.../cache/caddis/caddis/1.3.68/scripts)
|
|
966
|
+
# or from a source checkout. Read the version from the plugin path when that is where we are;
|
|
967
|
+
# a checkout has no released version number, and saying "source-checkout" is more honest than
|
|
968
|
+
# inventing one.
|
|
969
|
+
here = Path(__file__).resolve()
|
|
970
|
+
parts = [p.name for p in here.parents]
|
|
971
|
+
if "caddis" in parts:
|
|
972
|
+
for parent in here.parents:
|
|
973
|
+
if re.fullmatch(r"\d+\.\d+\.\d+", parent.name):
|
|
974
|
+
version = parent.name
|
|
975
|
+
break
|
|
976
|
+
if version == "unknown" and "plugins" not in parts:
|
|
977
|
+
version = "source-checkout"
|
|
978
|
+
|
|
979
|
+
dest = artifact_root(target) / ".caddis-version"
|
|
980
|
+
stamped = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
981
|
+
body = "\n".join([
|
|
982
|
+
"# Which caddis scaffolded this project. Rewritten whenever the harness is re-run.",
|
|
983
|
+
"# Answers \"is this app's harness stale?\" without diffing every generated file.",
|
|
984
|
+
f"caddis_version={version}",
|
|
985
|
+
f"scaffolded_at={stamped}",
|
|
986
|
+
f"harness_path={here.parent}",
|
|
987
|
+
"",
|
|
988
|
+
])
|
|
989
|
+
if not dry:
|
|
990
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
991
|
+
dest.write_text(body, encoding="utf-8")
|
|
992
|
+
notes.append(f"caddis stamp: {version} -> {dest.relative_to(target)}")
|
|
993
|
+
return notes
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
# ── the `Plain` output style ────────────────────────────────────────────────────────────────
|
|
997
|
+
# caddis SUPPLIES this style and never selects it. Claude Code offers `force-for-plugin: true`,
|
|
998
|
+
# which would apply it automatically and override the user's own `outputStyle`; that was considered
|
|
999
|
+
# and rejected. A plugin that silently repoints a global preference is the behaviour people file
|
|
1000
|
+
# bugs about, and it would make `Explanatory` unreachable for as long as caddis is enabled.
|
|
1001
|
+
# So: write the file where the picker looks, and let the human choose it in `/config`.
|
|
1002
|
+
OUTPUT_STYLE_SRC_REL = ("claude-md", "output-style-plain.md")
|
|
1003
|
+
OUTPUT_STYLE_DEST_NAME = "Plain.md"
|
|
1004
|
+
OUTPUT_STYLE_STAMP_NAME = ".caddis-plain.sha256"
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _style_hash(text: str) -> str:
|
|
1008
|
+
"""Hash on LF-normalised text. Git checkouts flip line endings on Windows, and without this
|
|
1009
|
+
a pure CRLF/LF difference reads as "the user edited it" and freezes updates forever."""
|
|
1010
|
+
return hashlib.sha256(text.replace("\r\n", "\n").encode("utf-8")).hexdigest()
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def provision_output_style(dry: bool, home: Path | None = None) -> list[str]:
|
|
1014
|
+
"""Put `Plain.md` in the user's global output-styles dir. Never selects it, never edits settings.
|
|
1015
|
+
|
|
1016
|
+
Update rule: write when absent; refresh when the file on disk still matches the copy caddis last
|
|
1017
|
+
shipped; otherwise leave it alone. The stamp file is what separates "unmodified, safe to update"
|
|
1018
|
+
from "the user tuned this" — without it the only safe policy is never to update, and a shipped
|
|
1019
|
+
wording fix would never reach anyone who installed once.
|
|
1020
|
+
"""
|
|
1021
|
+
if os.environ.get("CADDIS_NO_OUTPUT_STYLE"):
|
|
1022
|
+
return ["output style: skipped (CADDIS_NO_OUTPUT_STYLE set)"]
|
|
1023
|
+
src = HARNESS_DIR.joinpath(*OUTPUT_STYLE_SRC_REL)
|
|
1024
|
+
if not src.is_file():
|
|
1025
|
+
return [f"output style: skipped (no source at {src})"]
|
|
1026
|
+
try:
|
|
1027
|
+
new_text = src.read_text(encoding="utf-8")
|
|
1028
|
+
except OSError as exc:
|
|
1029
|
+
return [f"output style: skipped ({type(exc).__name__})"]
|
|
1030
|
+
|
|
1031
|
+
# CADDIS_HOME exists so a test (or a sandboxed run) can point this at a temp dir. Without it,
|
|
1032
|
+
# merely importing and calling this function writes into the real user's home — which is exactly
|
|
1033
|
+
# the kind of side effect a test suite must never have.
|
|
1034
|
+
root = home or Path(os.environ.get("CADDIS_HOME") or Path.home())
|
|
1035
|
+
dest_dir = root / ".claude" / "output-styles"
|
|
1036
|
+
dest = dest_dir / OUTPUT_STYLE_DEST_NAME
|
|
1037
|
+
stamp = dest_dir / OUTPUT_STYLE_STAMP_NAME
|
|
1038
|
+
new_hash = _style_hash(new_text)
|
|
1039
|
+
|
|
1040
|
+
if dest.is_file():
|
|
1041
|
+
cur_hash = _style_hash(dest.read_text(encoding="utf-8", errors="ignore"))
|
|
1042
|
+
if cur_hash == new_hash:
|
|
1043
|
+
return [f"output style: {dest} already current"]
|
|
1044
|
+
prev = ""
|
|
1045
|
+
if stamp.is_file():
|
|
1046
|
+
prev = stamp.read_text(encoding="utf-8", errors="ignore").strip()
|
|
1047
|
+
if prev and prev != cur_hash:
|
|
1048
|
+
return [f"output style: {dest} kept — locally edited, not overwritten"]
|
|
1049
|
+
action = "updated"
|
|
1050
|
+
else:
|
|
1051
|
+
action = "wrote"
|
|
1052
|
+
|
|
1053
|
+
if not dry:
|
|
1054
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
1055
|
+
dest.write_text(new_text, encoding="utf-8")
|
|
1056
|
+
stamp.write_text(new_hash + "\n", encoding="utf-8")
|
|
1057
|
+
return [f"output style: {action} {dest} (select it in /config — caddis never sets it for you)"]
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
# ── user-level settings.json ────────────────────────────────────────────────────────────────
|
|
1061
|
+
TODO_TOOLS_ENV_KEY = "CLAUDE_CODE_ENABLE_TODO_TOOLS"
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _user_settings_path(home: Path | None = None) -> Path:
|
|
1065
|
+
root = home or Path(os.environ.get("CADDIS_HOME") or Path.home())
|
|
1066
|
+
return root / ".claude" / "settings.json"
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def ensure_user_env(dry: bool, home: Path | None = None) -> list[str]:
|
|
1070
|
+
"""Add `CLAUDE_CODE_ENABLE_TODO_TOOLS=1` to the user's global settings, additively.
|
|
1071
|
+
|
|
1072
|
+
WHY THIS ONE IS SAFE TO SET WHEN `outputStyle` WAS NOT. Adding a key to `env` does not override
|
|
1073
|
+
a preference the user expressed — it turns on a capability that is otherwise absent. An existing
|
|
1074
|
+
value is ALWAYS left alone, including an explicit "0", so a deliberate opt-out survives every
|
|
1075
|
+
reinstall.
|
|
1076
|
+
|
|
1077
|
+
NEVER REWRITE A FILE THAT DOES NOT PARSE. Found live on 2026-08-16: a hand-added line in this
|
|
1078
|
+
exact file was missing its comma, so the JSON was invalid and Claude Code silently ignored
|
|
1079
|
+
EVERY setting in it — guard mode, permissions, hooks, statusline, plugins. A "helpful" rewrite
|
|
1080
|
+
of an unparseable file would have discarded all of it. So this reports the breakage and stops,
|
|
1081
|
+
which also converts a silent total failure into a visible one. That report is most of the value
|
|
1082
|
+
here; the key it adds is the smaller half.
|
|
1083
|
+
"""
|
|
1084
|
+
if os.environ.get("CADDIS_NO_SETTINGS_ENV"):
|
|
1085
|
+
return ["user env: skipped (CADDIS_NO_SETTINGS_ENV set)"]
|
|
1086
|
+
path = _user_settings_path(home)
|
|
1087
|
+
|
|
1088
|
+
if path.is_file():
|
|
1089
|
+
raw = path.read_text(encoding="utf-8", errors="ignore")
|
|
1090
|
+
try:
|
|
1091
|
+
data = json.loads(raw)
|
|
1092
|
+
except json.JSONDecodeError as exc:
|
|
1093
|
+
return [f"user env: {path} is NOT VALID JSON ({exc.msg}, line {exc.lineno}) — "
|
|
1094
|
+
"every setting in it is being ignored. Left untouched; fix it by hand."]
|
|
1095
|
+
if not isinstance(data, dict):
|
|
1096
|
+
return [f"user env: {path} is not a JSON object — left untouched"]
|
|
1097
|
+
else:
|
|
1098
|
+
raw, data = "", {}
|
|
1099
|
+
|
|
1100
|
+
env_block = data.get("env")
|
|
1101
|
+
if env_block is None:
|
|
1102
|
+
env_block = {}
|
|
1103
|
+
elif not isinstance(env_block, dict):
|
|
1104
|
+
return [f"user env: {path} has a non-object `env` — left untouched"]
|
|
1105
|
+
|
|
1106
|
+
if TODO_TOOLS_ENV_KEY in env_block:
|
|
1107
|
+
return [f"user env: {TODO_TOOLS_ENV_KEY} already set to "
|
|
1108
|
+
f"{env_block[TODO_TOOLS_ENV_KEY]!r} — left alone"]
|
|
1109
|
+
|
|
1110
|
+
env_block[TODO_TOOLS_ENV_KEY] = "1"
|
|
1111
|
+
data["env"] = env_block
|
|
1112
|
+
if not dry:
|
|
1113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1114
|
+
# Back up before touching a global config, and write via a temp file so an interrupted
|
|
1115
|
+
# write cannot leave the user with a half-written settings.json — the failure mode this
|
|
1116
|
+
# whole function exists to notice.
|
|
1117
|
+
if raw:
|
|
1118
|
+
path.with_suffix(".json.caddis-bak").write_text(raw, encoding="utf-8")
|
|
1119
|
+
tmp = path.with_suffix(".json.caddis-tmp")
|
|
1120
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
1121
|
+
os.replace(tmp, path)
|
|
1122
|
+
verb = "would add" if dry else "added"
|
|
1123
|
+
return [f"user env: {verb} {TODO_TOOLS_ENV_KEY}=1 to {path}"
|
|
1124
|
+
+ ("" if not raw else " (previous version saved as settings.json.caddis-bak)")]
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def scaffold_artifact_dir(target: Path, dry: bool) -> list[str]:
|
|
1128
|
+
"""Create the harness-owned .caddis/ artifact tree + a default .gitignore + a config example.
|
|
1129
|
+
|
|
1130
|
+
Committed subdirs: plans, handoffs, agent-docs, prd, kb, prompts, comms. Transient state
|
|
1131
|
+
(reviews/*.html, usage-log.jsonl, .last-usage-review, relay*, PROJECT-FACTS.md, memory.jsonl)
|
|
1132
|
+
is gitignored. .caddis/ is the default home for every working-artifact kind (Track A
|
|
1133
|
+
Phase A3) — kb/ and prompts/ round out plans/prd/agent-docs/reviews so nothing has to
|
|
1134
|
+
scatter to the repo root or .github/. Also drops a documented `config.toml.example`
|
|
1135
|
+
(guard/doc_coverage/dream_memory). Idempotent; never clobbers an existing .gitignore or
|
|
1136
|
+
config example.
|
|
1137
|
+
|
|
1138
|
+
Writes into the repo's `.caddis/`.
|
|
1139
|
+
"""
|
|
1140
|
+
notes: list[str] = []
|
|
1141
|
+
root = artifact_root(target)
|
|
1142
|
+
label = root.name
|
|
1143
|
+
# `comms` holds outbound messages — anything only a human OUTSIDE this repo can resolve
|
|
1144
|
+
# (an access request, a firmware change, a decision from another team). Without a home,
|
|
1145
|
+
# those get written into a chat reply and evaporate when the session ends.
|
|
1146
|
+
# `parking-lot` holds future work — the ONE register. It was a convention with no home until
|
|
1147
|
+
# 2026-08-14: it appeared nowhere in the scaffold, nowhere in the AGENTS.md "Where things live"
|
|
1148
|
+
# table, and nowhere in the tidy lifecycle. So future work scattered to nine places at once and
|
|
1149
|
+
# no session could answer "what is left to do?". A directory the harness creates on every
|
|
1150
|
+
# project is what makes "one place" true rather than aspirational.
|
|
1151
|
+
for sub in ("plans", "handoffs", "agent-docs", "reviews", "prd", "kb", "prompts", "comms",
|
|
1152
|
+
"parking-lot"):
|
|
1153
|
+
d = root / sub
|
|
1154
|
+
if d.is_dir():
|
|
1155
|
+
continue
|
|
1156
|
+
if not dry:
|
|
1157
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
1158
|
+
notes.append(f"scaffold: {label}/{sub}/")
|
|
1159
|
+
|
|
1160
|
+
# The comms REGISTER is the half that earns its keep, so it is scaffolded rather than left
|
|
1161
|
+
# to convention. A directory only fixes "drafted but never sent"; the register fixes "sent
|
|
1162
|
+
# but never chased" — and comms are almost always sent to GET AN ACTION, so an unanswered
|
|
1163
|
+
# ask is indistinguishable from a forgotten one unless something tracks it.
|
|
1164
|
+
reg = root / "comms" / "register.md"
|
|
1165
|
+
if reg.exists():
|
|
1166
|
+
notes.append(f"scaffold: {label}/comms/register.md present — kept")
|
|
1167
|
+
else:
|
|
1168
|
+
if not dry:
|
|
1169
|
+
reg.parent.mkdir(parents=True, exist_ok=True)
|
|
1170
|
+
reg.write_text(COMMS_REGISTER_TEMPLATE, encoding="utf-8")
|
|
1171
|
+
notes.append(f"scaffold: wrote {label}/comms/register.md")
|
|
1172
|
+
# The parking-lot README is the CONTRACT, not decoration. Without it the directory is just a
|
|
1173
|
+
# folder, and a folder does not tell the next agent that `type: parking-lot` is mandatory or
|
|
1174
|
+
# that a second register is forbidden. Same reasoning as comms/register.md above.
|
|
1175
|
+
pl = root / "parking-lot" / "README.md"
|
|
1176
|
+
if pl.exists():
|
|
1177
|
+
notes.append(f"scaffold: {label}/parking-lot/README.md present — kept")
|
|
1178
|
+
else:
|
|
1179
|
+
if not dry:
|
|
1180
|
+
pl.parent.mkdir(parents=True, exist_ok=True)
|
|
1181
|
+
pl.write_text(PARKING_LOT_README_TEMPLATE, encoding="utf-8")
|
|
1182
|
+
notes.append(f"scaffold: wrote {label}/parking-lot/README.md")
|
|
1183
|
+
# The environment map. Same reasoning as the two registers above: the failure is not complexity,
|
|
1184
|
+
# it is EVAPORATION. Environment facts used to land wherever the agent happened to be typing —
|
|
1185
|
+
# relay.md (rewritten at every handover), mid-plan (nobody looks), a KB note (only when framed
|
|
1186
|
+
# as a trap), or most often a chat reply that ends with the session. One project lost time to
|
|
1187
|
+
# the same three facts repeatedly. A file with headings gets filled in; a blank page does not.
|
|
1188
|
+
em = root / "kb" / "environment-map.md"
|
|
1189
|
+
if em.exists():
|
|
1190
|
+
notes.append(f"scaffold: {label}/kb/environment-map.md present — kept")
|
|
1191
|
+
else:
|
|
1192
|
+
if not dry:
|
|
1193
|
+
em.parent.mkdir(parents=True, exist_ok=True)
|
|
1194
|
+
em.write_text(ENVIRONMENT_MAP_TEMPLATE, encoding="utf-8")
|
|
1195
|
+
notes.append(f"scaffold: wrote {label}/kb/environment-map.md")
|
|
1196
|
+
gi = root / ".gitignore"
|
|
1197
|
+
if gi.exists():
|
|
1198
|
+
notes.append(f"scaffold: {label}/.gitignore present — kept")
|
|
1199
|
+
else:
|
|
1200
|
+
if not dry:
|
|
1201
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
1202
|
+
gi.write_text(ARTIFACT_GITIGNORE, encoding="utf-8")
|
|
1203
|
+
notes.append(f"scaffold: wrote {label}/.gitignore")
|
|
1204
|
+
cfg = root / "config.toml.example"
|
|
1205
|
+
if cfg.exists():
|
|
1206
|
+
notes.append(f"scaffold: {label}/config.toml.example present — kept")
|
|
1207
|
+
else:
|
|
1208
|
+
if not dry:
|
|
1209
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
1210
|
+
cfg.write_text(ARTIFACT_CONFIG_EXAMPLE, encoding="utf-8")
|
|
1211
|
+
notes.append(f"scaffold: wrote {label}/config.toml.example")
|
|
1212
|
+
return notes
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def relocate_legacy(target: Path, dry: bool) -> list[str]:
|
|
1216
|
+
"""One-way relocate of pre-artifact-dir harness files into the repo's artifact dir.
|
|
1217
|
+
|
|
1218
|
+
Moves each source only when it exists AND the destination does not — never
|
|
1219
|
+
clobbers an already-migrated file (logs a skip instead). Idempotent: a second
|
|
1220
|
+
run finds no sources and is a no-op. Also migrates .github/plans/*.md into
|
|
1221
|
+
<artifact-dir>/plans/ — EXCEPT on the caddis authoring source (the caddis repo,
|
|
1222
|
+
detected by a claude-harness/ dir), where .github/plans is pool-synced to the
|
|
1223
|
+
public caddis-plugin mirror and must stay put (migration decision 5).
|
|
1224
|
+
|
|
1225
|
+
The destination is always `.caddis/` — this relocation is about the OLD `.claude/`+root
|
|
1226
|
+
layout, not about renaming a repo's artifact dir (that is `/caddis:migrate-dir`, opt-in).
|
|
1227
|
+
"""
|
|
1228
|
+
art = artifact_root(target).name
|
|
1229
|
+
moves = [
|
|
1230
|
+
("relay.md", f"{art}/relay.md"),
|
|
1231
|
+
(".claude/usage-log.jsonl", f"{art}/usage-log.jsonl"),
|
|
1232
|
+
(".claude/.last-usage-review", f"{art}/.last-usage-review"),
|
|
1233
|
+
(".claude/PROJECT-FACTS.md", f"{art}/PROJECT-FACTS.md"),
|
|
1234
|
+
(".claude/relay", f"{art}/relay"),
|
|
1235
|
+
]
|
|
1236
|
+
notes: list[str] = []
|
|
1237
|
+
for src_rel, dst_rel in moves:
|
|
1238
|
+
src, dst = target / src_rel, target / dst_rel
|
|
1239
|
+
if not src.exists():
|
|
1240
|
+
continue
|
|
1241
|
+
if dst.exists():
|
|
1242
|
+
notes.append(f"migrate: skip (already at {dst_rel}) — left legacy {src_rel} in place")
|
|
1243
|
+
continue
|
|
1244
|
+
if not dry:
|
|
1245
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1246
|
+
shutil.move(str(src), str(dst))
|
|
1247
|
+
notes.append(f"migrate: {src_rel} → {dst_rel}")
|
|
1248
|
+
|
|
1249
|
+
# .github/plans/*.md → <artifact-dir>/plans/ — per-file (the dir may already exist from the
|
|
1250
|
+
# scaffold step), clobber-safe. SKIPPED for the caddis authoring source (the caddis repo),
|
|
1251
|
+
# where .github/plans is pool-synced to the public caddis-plugin mirror (migration decision 5).
|
|
1252
|
+
# Detected by the presence of a claude-harness/ dir in the target.
|
|
1253
|
+
gh_plans = target / ".github" / "plans"
|
|
1254
|
+
if gh_plans.is_dir():
|
|
1255
|
+
if (target / "claude-harness").is_dir():
|
|
1256
|
+
notes.append("migrate: .github/plans/ kept (authoring source — pool-synced)")
|
|
1257
|
+
else:
|
|
1258
|
+
dest_dir = target / art / "plans"
|
|
1259
|
+
for src in sorted(gh_plans.glob("*.md")):
|
|
1260
|
+
dst = dest_dir / src.name
|
|
1261
|
+
if dst.exists():
|
|
1262
|
+
notes.append(f"migrate: skip (already at {art}/plans/{src.name})")
|
|
1263
|
+
continue
|
|
1264
|
+
if not dry:
|
|
1265
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
1266
|
+
shutil.move(str(src), str(dst))
|
|
1267
|
+
notes.append(f"migrate: .github/plans/{src.name} → {art}/plans/{src.name}")
|
|
1268
|
+
|
|
1269
|
+
if not notes:
|
|
1270
|
+
notes.append("migrate: no legacy files to relocate")
|
|
1271
|
+
return notes
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
# ── main ─────────────────────────────────────────────────────────────────────
|
|
1275
|
+
def main() -> int:
|
|
1276
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
1277
|
+
sys.stdout.reconfigure(encoding="utf-8") # Windows cp1252 can't encode → · ✅ ⚠
|
|
1278
|
+
ap = argparse.ArgumentParser(description="Deploy the Claude Code harness into a project.")
|
|
1279
|
+
ap.add_argument("target", type=Path)
|
|
1280
|
+
ap.add_argument("--name", default=None, help="Project name for CLAUDE.md identity")
|
|
1281
|
+
ap.add_argument("--desc", default="", help="One-line project description")
|
|
1282
|
+
ap.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
|
|
1283
|
+
help="Placeholder substitution, e.g. --set API_PORT_DEV=8099 (repeatable)")
|
|
1284
|
+
ap.add_argument("--substitute", action="store_true",
|
|
1285
|
+
help="Apply placeholder substitution across repo files (for fresh-from-template "
|
|
1286
|
+
"projects only). OFF by default — existing projects are scanned/reported, never rewritten.")
|
|
1287
|
+
ap.add_argument("--force", action="store_true", help="Overwrite existing CLAUDE.md/AGENTS.md/harness files")
|
|
1288
|
+
ap.add_argument("--install", action="store_true", help="Create venv if missing")
|
|
1289
|
+
ap.add_argument("--dry-run", action="store_true", help="Report actions without writing")
|
|
1290
|
+
ap.add_argument("--hooks-only", action="store_true",
|
|
1291
|
+
help="Install ONLY the pre-push gate and exit. For callers that create the git "
|
|
1292
|
+
"repo after deploying the harness - the generator runs this script at stage "
|
|
1293
|
+
"10 and `git init` at stage 11, so the gate could never install and no "
|
|
1294
|
+
"bootstrapped app has ever had one.")
|
|
1295
|
+
ap.add_argument("--vendor", action="store_true",
|
|
1296
|
+
help="Copy plugin-owned agents+commands into .claude/ (for raw checkouts without "
|
|
1297
|
+
"the caddis plugin installed; plugin installs load them globally — no copy needed)")
|
|
1298
|
+
args = ap.parse_args()
|
|
1299
|
+
|
|
1300
|
+
target = args.target.resolve()
|
|
1301
|
+
if not target.is_dir():
|
|
1302
|
+
print(f"ERROR: target not a directory: {target}", file=sys.stderr)
|
|
1303
|
+
return 2
|
|
1304
|
+
if not HARNESS_DIR.is_dir():
|
|
1305
|
+
print(f"ERROR: harness templates not found at {HARNESS_DIR}", file=sys.stderr)
|
|
1306
|
+
return 2
|
|
1307
|
+
|
|
1308
|
+
if args.hooks_only:
|
|
1309
|
+
# Idempotent and clobber-safe: never replaces a hook it did not write unless --force.
|
|
1310
|
+
# Exists because the gate cannot install before the repo does. Safe to call on an
|
|
1311
|
+
# already-set-up project, which is what makes it usable as a retrofit for existing apps.
|
|
1312
|
+
for note in install_git_hooks(target, args.force, args.dry_run):
|
|
1313
|
+
print(f" {note}")
|
|
1314
|
+
return 0
|
|
1315
|
+
|
|
1316
|
+
name = args.name or target.name
|
|
1317
|
+
mapping: dict[str, str] = {"PROJECT_NAME": name}
|
|
1318
|
+
if args.desc:
|
|
1319
|
+
mapping["PROJECT_DESCRIPTION"] = args.desc
|
|
1320
|
+
for kv in args.set:
|
|
1321
|
+
if "=" in kv:
|
|
1322
|
+
k, v = kv.split("=", 1)
|
|
1323
|
+
mapping[k.strip()] = v.strip()
|
|
1324
|
+
|
|
1325
|
+
stack_map = load_stack_map()
|
|
1326
|
+
stack = detect_stack(target, stack_map)
|
|
1327
|
+
ident = {"name": name, "desc": args.desc or f"{name} — (fill in: what this project is)."}
|
|
1328
|
+
|
|
1329
|
+
print(f"=== setup-project-ai → {target} {'(dry-run)' if args.dry_run else ''}")
|
|
1330
|
+
print(f"Stack detected: {stack_summary(stack)} [{', '.join(d['id'] for d in stack['matched']) or 'none'}]")
|
|
1331
|
+
|
|
1332
|
+
# Substitution rewrites repo files — only when explicitly requested (fresh-from-template).
|
|
1333
|
+
# Otherwise report-only so we never touch an existing project's docs/code.
|
|
1334
|
+
apply_sub = args.substitute and not args.dry_run
|
|
1335
|
+
sub_changed, leftovers = substitute_placeholders(target, mapping, dry=not apply_sub)
|
|
1336
|
+
if args.substitute:
|
|
1337
|
+
print(f"\n-- placeholders: substituted in {sub_changed} file(s)")
|
|
1338
|
+
else:
|
|
1339
|
+
print(f"\n-- placeholders: report-only ({sub_changed} file(s) contain provided keys; "
|
|
1340
|
+
f"pass --substitute to apply — intended for fresh template copies, not existing repos)")
|
|
1341
|
+
if leftovers:
|
|
1342
|
+
print(f" ⚠ UNRESOLVED placeholders in {len(leftovers)} file(s)"
|
|
1343
|
+
f"{' — provide via --set' if args.substitute else ' (informational)'}:")
|
|
1344
|
+
seen: set[str] = set()
|
|
1345
|
+
for f, toks in sorted(leftovers.items()):
|
|
1346
|
+
for t in toks:
|
|
1347
|
+
seen.add(t)
|
|
1348
|
+
print(f" {f}: {', '.join(toks)}")
|
|
1349
|
+
print(f" tokens needing values: {', '.join(sorted(seen))}")
|
|
1350
|
+
|
|
1351
|
+
# Fail-loud gate: in --substitute mode (a fresh-from-template render) any leftover
|
|
1352
|
+
# {{TOKEN}} means a half-rendered harness. Refuse to emit — exit 3 so the bootstrap
|
|
1353
|
+
# aborts rather than shipping a CLAUDE.md with literal {{TOKEN}}s a future session
|
|
1354
|
+
# would read as truth. Report-only runs (existing repos) are never gated.
|
|
1355
|
+
if args.substitute and leftovers:
|
|
1356
|
+
print("\nERROR: unresolved {{TOKEN}}s remain after --substitute — refusing to emit a "
|
|
1357
|
+
"half-rendered harness.\n Provide the missing values via --set and re-run.",
|
|
1358
|
+
file=sys.stderr)
|
|
1359
|
+
return 3
|
|
1360
|
+
|
|
1361
|
+
print("\n-- CLAUDE.md hierarchy")
|
|
1362
|
+
for line in compose_claude_md(target, stack, ident, args.force, args.dry_run):
|
|
1363
|
+
print(f" {line}")
|
|
1364
|
+
|
|
1365
|
+
print(f"-- migrate legacy state → {artifact_root(target).name}")
|
|
1366
|
+
for line in relocate_legacy(target, args.dry_run):
|
|
1367
|
+
print(f" {line}")
|
|
1368
|
+
|
|
1369
|
+
print("-- project facts (auto-extracted → seed for enrichment)")
|
|
1370
|
+
for line in write_project_facts(target, extract_project_facts(target, stack), args.dry_run):
|
|
1371
|
+
print(f" {line}")
|
|
1372
|
+
|
|
1373
|
+
print(f"-- {artifact_root(target).name} artifact tree")
|
|
1374
|
+
for line in scaffold_artifact_dir(target, args.dry_run):
|
|
1375
|
+
print(f" {line}")
|
|
1376
|
+
for line in stamp_caddis_version(target, args.dry_run):
|
|
1377
|
+
print(f" {line}")
|
|
1378
|
+
|
|
1379
|
+
print("-- output style (supplied, never selected for you)")
|
|
1380
|
+
for line in provision_output_style(args.dry_run):
|
|
1381
|
+
print(f" {line}")
|
|
1382
|
+
|
|
1383
|
+
print("-- user settings (additive only, never overwrites a value you set)")
|
|
1384
|
+
for line in ensure_user_env(args.dry_run):
|
|
1385
|
+
print(f" {line}")
|
|
1386
|
+
|
|
1387
|
+
print("-- doc-coverage discipline (DOC-MAP + page guide + checker)")
|
|
1388
|
+
for line in emit_doc_discipline(target, ident, args.force, args.dry_run):
|
|
1389
|
+
print(f" {line}")
|
|
1390
|
+
|
|
1391
|
+
if args.vendor:
|
|
1392
|
+
print("\n-- subagents (vendored)")
|
|
1393
|
+
for line in deploy_dir(HARNESS_DIR / "agents", target / ".claude" / "agents", args.force, args.dry_run):
|
|
1394
|
+
print(f" {line}")
|
|
1395
|
+
print("-- commands (vendored)")
|
|
1396
|
+
for line in deploy_dir(HARNESS_DIR / "commands", target / ".claude" / "commands", args.force, args.dry_run):
|
|
1397
|
+
print(f" {line}")
|
|
1398
|
+
else:
|
|
1399
|
+
print("\n-- subagents/commands: skipped (provided by the caddis plugin; pass --vendor for a raw checkout)")
|
|
1400
|
+
|
|
1401
|
+
print("\n-- settings")
|
|
1402
|
+
print(f" {merge_settings(target, stack, args.dry_run)}")
|
|
1403
|
+
|
|
1404
|
+
print("-- status line")
|
|
1405
|
+
print(" user-scope now — run /caddis:statusline once per machine (not per project)")
|
|
1406
|
+
|
|
1407
|
+
print("-- git hooks")
|
|
1408
|
+
for line in install_git_hooks(target, args.force, args.dry_run):
|
|
1409
|
+
print(f" {line}")
|
|
1410
|
+
|
|
1411
|
+
fe_notes = ensure_frontend_test_harness(target, stack, args.dry_run)
|
|
1412
|
+
if fe_notes:
|
|
1413
|
+
print("\n-- frontend test harness")
|
|
1414
|
+
for n in fe_notes:
|
|
1415
|
+
print(f" {n}")
|
|
1416
|
+
|
|
1417
|
+
venv_notes = check_venv(target, args.install, args.dry_run)
|
|
1418
|
+
if venv_notes:
|
|
1419
|
+
print("\n-- python env")
|
|
1420
|
+
for n in venv_notes:
|
|
1421
|
+
print(f" {n}")
|
|
1422
|
+
|
|
1423
|
+
print("\n✅ done. Next: review CLAUDE.md hierarchy, then enrich it with project-specific facts "
|
|
1424
|
+
"(the setup-project-ai skill's AI step), and run a smoke test.")
|
|
1425
|
+
return 0
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
if __name__ == "__main__":
|
|
1429
|
+
raise SystemExit(main())
|