@caddis/cli 0.2.5 → 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.
Files changed (54) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +2 -0
  34. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +22 -0
  35. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +33 -0
  40. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +213 -0
  41. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  42. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  43. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  44. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  45. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  48. package/bundles/manifest.json +3 -3
  49. package/dist/claude-deepseek.js +544 -0
  50. package/dist/claude-glm.js +544 -0
  51. package/dist/claude-oss.js +544 -0
  52. package/dist/cli.js +375 -46
  53. package/package.json +5 -2
  54. 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())