project-init 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. project_init/__init__.py +4 -0
  2. project_init/__main__.py +662 -0
  3. project_init/mcps.py +57 -0
  4. project_init/scaffold.py +374 -0
  5. project_init/templates/base/AGENTS.md.tmpl +50 -0
  6. project_init/templates/base/CLAUDE.md.tmpl +16 -0
  7. project_init/templates/base/CONTRIBUTING.md.tmpl +55 -0
  8. project_init/templates/base/GEMINI.md.tmpl +16 -0
  9. project_init/templates/base/LICENSE.tmpl +231 -0
  10. project_init/templates/base/SECURITY.md.tmpl +26 -0
  11. project_init/templates/base/docs/explanation/index.md +9 -0
  12. project_init/templates/base/docs/how-to/index.md +7 -0
  13. project_init/templates/base/docs/index.md.tmpl +20 -0
  14. project_init/templates/base/docs/reference/index.md +13 -0
  15. project_init/templates/base/docs/tutorials/index.md +7 -0
  16. project_init/templates/base/dot_claude/agents/README.md +30 -0
  17. project_init/templates/base/dot_claude/config.yaml.tmpl +31 -0
  18. project_init/templates/base/dot_claude/docs/README.md +26 -0
  19. project_init/templates/base/dot_claude/docs/adr/adr-001-memory-stack.md.tmpl +22 -0
  20. project_init/templates/base/dot_claude/docs/adr/adr-002-mcp-choices.md.tmpl +32 -0
  21. project_init/templates/base/dot_claude/docs/adr/adr-template.md +29 -0
  22. project_init/templates/base/dot_claude/docs/development/conventions.md.tmpl +31 -0
  23. project_init/templates/base/dot_claude/docs/development/testing.md +25 -0
  24. project_init/templates/base/dot_claude/docs/guides/developer-onboarding.md +110 -0
  25. project_init/templates/base/dot_claude/docs/guides/issue-metadata.md +27 -0
  26. project_init/templates/base/dot_claude/docs/guides/secrets.md +50 -0
  27. project_init/templates/base/dot_claude/docs/guides/using-memory.md +36 -0
  28. project_init/templates/base/dot_claude/hooks/README.md +15 -0
  29. project_init/templates/base/dot_claude/hooks/agent_guard_adapter.py.tmpl +64 -0
  30. project_init/templates/base/dot_claude/hooks/dag_workflow.py +610 -0
  31. project_init/templates/base/dot_claude/memory/MEMORY.md.tmpl +11 -0
  32. project_init/templates/base/dot_claude/memory/README.md +51 -0
  33. project_init/templates/base/dot_claude/memory/SCHEMA.md +52 -0
  34. project_init/templates/base/dot_claude/memory/feedback_conventions.md +11 -0
  35. project_init/templates/base/dot_claude/memory/project_context.md.tmpl +11 -0
  36. project_init/templates/base/dot_claude/memory/user_role.md +7 -0
  37. project_init/templates/base/dot_claude/project-init.md.tmpl +174 -0
  38. project_init/templates/base/dot_claude/rules/go.md +14 -0
  39. project_init/templates/base/dot_claude/rules/hooks.md +30 -0
  40. project_init/templates/base/dot_claude/rules/node.md +17 -0
  41. project_init/templates/base/dot_claude/rules/python.md +25 -0
  42. project_init/templates/base/dot_claude/scripts/README.md +15 -0
  43. project_init/templates/base/dot_claude/scripts/create_issue.sh +577 -0
  44. project_init/templates/base/dot_claude/scripts/create_nojira_pr.sh +3 -0
  45. project_init/templates/base/dot_claude/scripts/finish_pr.sh +3 -0
  46. project_init/templates/base/dot_claude/scripts/install_hooks.sh +55 -0
  47. project_init/templates/base/dot_claude/scripts/monitor_pr.sh +270 -0
  48. project_init/templates/base/dot_claude/scripts/promote_review.sh +3 -0
  49. project_init/templates/base/dot_claude/scripts/push_branch.sh +5 -0
  50. project_init/templates/base/dot_claude/scripts/push_wiki.sh +34 -0
  51. project_init/templates/base/dot_claude/scripts/setup_github.sh +219 -0
  52. project_init/templates/base/dot_claude/scripts/start_issue.sh +134 -0
  53. project_init/templates/base/dot_claude/settings.json.tmpl +83 -0
  54. project_init/templates/base/dot_claude/skills/README.md +12 -0
  55. project_init/templates/base/dot_claude/skills/plan/SKILL.md.tmpl +40 -0
  56. project_init/templates/base/dot_claude/vault/README.md +21 -0
  57. project_init/templates/base/dot_claude/vault/decisions/README.md +22 -0
  58. project_init/templates/base/dot_claude/vault/design/README.md +3 -0
  59. project_init/templates/base/dot_claude/vault/knowledge/README.md +5 -0
  60. project_init/templates/base/dot_claude/vault/sessions/README.md +5 -0
  61. project_init/templates/base/dot_devcontainer/devcontainer.json.tmpl +17 -0
  62. project_init/templates/base/dot_devcontainer/post-create.sh.tmpl +31 -0
  63. project_init/templates/base/dot_env.example.tmpl +13 -0
  64. project_init/templates/base/dot_github/CODEOWNERS.tmpl +12 -0
  65. project_init/templates/base/dot_github/ISSUE_TEMPLATE/bug.yml +98 -0
  66. project_init/templates/base/dot_github/ISSUE_TEMPLATE/chore.yml +82 -0
  67. project_init/templates/base/dot_github/ISSUE_TEMPLATE/config.yml +5 -0
  68. project_init/templates/base/dot_github/ISSUE_TEMPLATE/docs.yml +84 -0
  69. project_init/templates/base/dot_github/ISSUE_TEMPLATE/feature.yml +87 -0
  70. project_init/templates/base/dot_github/ISSUE_TEMPLATE/test.yml +90 -0
  71. project_init/templates/base/dot_github/copilot-instructions.md.tmpl +25 -0
  72. project_init/templates/base/dot_github/hooks/commit-msg +52 -0
  73. project_init/templates/base/dot_github/hooks/pre-commit +16 -0
  74. project_init/templates/base/dot_github/hooks/pre-push +51 -0
  75. project_init/templates/base/dot_github/pull_request_template.md +22 -0
  76. project_init/templates/base/dot_github/workflows/board-automation.yml +232 -0
  77. project_init/templates/base/dot_github/workflows/ci.yml.tmpl +204 -0
  78. project_init/templates/base/dot_github/workflows/docs.yml.tmpl +98 -0
  79. project_init/templates/base/dot_github/workflows/issue-validation.yml +72 -0
  80. project_init/templates/base/dot_github/workflows/review-status.yml +48 -0
  81. project_init/templates/base/dot_github/workflows/validate-pr.yml +103 -0
  82. project_init/templates/base/dot_gitignore.tmpl +41 -0
  83. project_init/templates/base/dot_golangci.yml.tmpl +20 -0
  84. project_init/templates/base/dot_vscode/extensions.json.tmpl +10 -0
  85. project_init/templates/base/dot_vscode/settings.json.tmpl +8 -0
  86. project_init/templates/base/eslint.config.mjs.tmpl +29 -0
  87. project_init/templates/base/justfile.tmpl +95 -0
  88. project_init/templates/base/mise.toml.tmpl +20 -0
  89. project_init/templates/base/mkdocs.yml.tmpl +32 -0
  90. project_init/templates/base/renovate.json +14 -0
  91. project_init/templates/base/ruff.toml.tmpl +31 -0
  92. project_init/templates/base/typedoc.json.tmpl +14 -0
  93. project_init/templates/codex/dot_agents/skills/add_adr/SKILL.md +33 -0
  94. project_init/templates/codex/dot_agents/skills/add_command/SKILL.md +63 -0
  95. project_init/templates/codex/dot_agents/skills/add_hook/SKILL.md +112 -0
  96. project_init/templates/codex/dot_agents/skills/audit/SKILL.md +146 -0
  97. project_init/templates/codex/dot_agents/skills/create_issue/SKILL.md +59 -0
  98. project_init/templates/codex/dot_agents/skills/github_workflow/SKILL.md +80 -0
  99. project_init/templates/codex/dot_agents/skills/request_review/SKILL.md +19 -0
  100. project_init/templates/codex/dot_agents/skills/review/SKILL.md +17 -0
  101. project_init/templates/codex/dot_agents/skills/save_memory/SKILL.md +17 -0
  102. project_init/templates/codex/dot_agents/skills/session_summary/SKILL.md +35 -0
  103. project_init/templates/codex/dot_agents/skills/start_task/SKILL.md +48 -0
  104. project_init/templates/codex/dot_agents/skills/status/SKILL.md +15 -0
  105. project_init/templates/codex/dot_codex/hooks.json.tmpl +17 -0
  106. project_init/templates/fallback/dot_claude/hooks/github_command_guard.sh +11 -0
  107. project_init/templates/fallback/dot_claude/hooks/post_edit_lint.sh +58 -0
  108. project_init/templates/fallback/dot_claude/hooks/pre_commit_gate.sh +81 -0
  109. project_init/templates/fallback/dot_claude/hooks/prod_guard.py +140 -0
  110. project_init/templates/fallback/dot_claude/hooks/session_setup.sh +62 -0
  111. project_init/templates/fallback/dot_claude/hooks/workflow_state_reminder.sh +72 -0
  112. project_init/templates/fallback/dot_claude/skills/INDEX.md +28 -0
  113. project_init/templates/fallback/dot_claude/skills/add_adr/SKILL.md +33 -0
  114. project_init/templates/fallback/dot_claude/skills/add_command/SKILL.md +63 -0
  115. project_init/templates/fallback/dot_claude/skills/add_hook/SKILL.md +112 -0
  116. project_init/templates/fallback/dot_claude/skills/audit/SKILL.md +146 -0
  117. project_init/templates/fallback/dot_claude/skills/create_issue/SKILL.md +59 -0
  118. project_init/templates/fallback/dot_claude/skills/github_workflow/SKILL.md +80 -0
  119. project_init/templates/fallback/dot_claude/skills/request_review/SKILL.md +19 -0
  120. project_init/templates/fallback/dot_claude/skills/review/SKILL.md +17 -0
  121. project_init/templates/fallback/dot_claude/skills/save_memory/SKILL.md +17 -0
  122. project_init/templates/fallback/dot_claude/skills/session_summary/SKILL.md +35 -0
  123. project_init/templates/fallback/dot_claude/skills/start_task/SKILL.md +48 -0
  124. project_init/templates/fallback/dot_claude/skills/status/SKILL.md +15 -0
  125. project_init/templates/gemini/dot_agents/skills/add_adr/SKILL.md +33 -0
  126. project_init/templates/gemini/dot_agents/skills/add_command/SKILL.md +63 -0
  127. project_init/templates/gemini/dot_agents/skills/add_hook/SKILL.md +112 -0
  128. project_init/templates/gemini/dot_agents/skills/audit/SKILL.md +146 -0
  129. project_init/templates/gemini/dot_agents/skills/create_issue/SKILL.md +59 -0
  130. project_init/templates/gemini/dot_agents/skills/github_workflow/SKILL.md +80 -0
  131. project_init/templates/gemini/dot_agents/skills/request_review/SKILL.md +19 -0
  132. project_init/templates/gemini/dot_agents/skills/review/SKILL.md +17 -0
  133. project_init/templates/gemini/dot_agents/skills/save_memory/SKILL.md +17 -0
  134. project_init/templates/gemini/dot_agents/skills/session_summary/SKILL.md +35 -0
  135. project_init/templates/gemini/dot_agents/skills/start_task/SKILL.md +48 -0
  136. project_init/templates/gemini/dot_agents/skills/status/SKILL.md +15 -0
  137. project_init/templates/gemini/dot_claude/scripts/setup_gemini.sh.tmpl +16 -0
  138. project_init/templates/gemini/dot_gemini-extension/commands/add_adr.toml +5 -0
  139. project_init/templates/gemini/dot_gemini-extension/commands/add_command.toml +5 -0
  140. project_init/templates/gemini/dot_gemini-extension/commands/add_hook.toml +5 -0
  141. project_init/templates/gemini/dot_gemini-extension/commands/audit.toml +5 -0
  142. project_init/templates/gemini/dot_gemini-extension/commands/create_issue.toml +5 -0
  143. project_init/templates/gemini/dot_gemini-extension/commands/github_workflow.toml +5 -0
  144. project_init/templates/gemini/dot_gemini-extension/commands/request_review.toml +5 -0
  145. project_init/templates/gemini/dot_gemini-extension/commands/review.toml +5 -0
  146. project_init/templates/gemini/dot_gemini-extension/commands/save_memory.toml +5 -0
  147. project_init/templates/gemini/dot_gemini-extension/commands/session_summary.toml +5 -0
  148. project_init/templates/gemini/dot_gemini-extension/commands/start_task.toml +5 -0
  149. project_init/templates/gemini/dot_gemini-extension/commands/status.toml +5 -0
  150. project_init/templates/gemini/dot_gemini-extension/gemini-extension.json.tmpl +6 -0
  151. project_init/templates/gemini/dot_gemini-extension/hooks/hooks.json.tmpl +18 -0
  152. project_init/templates/graphify/dot_claude/docs/guides/using-graphify.md +37 -0
  153. project_init/templates/graphify/dot_claude/rules/graphify.md +18 -0
  154. project_init/templates/graphify/dot_claude/scripts/setup_graphify.sh +40 -0
  155. project_init/templates/obsidian/dot_claude/scripts/lint_memory.sh +115 -0
  156. project_init/templates/obsidian/dot_claude/vault/decisions/adr-000-project-setup.md.tmpl +22 -0
  157. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/README.md +31 -0
  158. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/app.json +6 -0
  159. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/community-plugins.json +1 -0
  160. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/core-plugins.json +1 -0
  161. project_init/templates/obsidian/dot_claude/vault/log.md +6 -0
  162. project_init/templates/obsidian/dot_claude/vault/templates/decision.md +16 -0
  163. project_init/templates/obsidian/dot_claude/vault/templates/design-note.md +14 -0
  164. project_init/templates/obsidian/dot_claude/vault/templates/knowledge-note.md +12 -0
  165. project_init/templates/obsidian/dot_claude/vault/templates/session-note.md +16 -0
  166. project_init/templates/presets/obsidian-graphify.toml +16 -0
  167. project_init/templates/presets/obsidian-only.toml +14 -0
  168. project_init/upgrade.py +569 -0
  169. project_init-0.3.0.dist-info/METADATA +342 -0
  170. project_init-0.3.0.dist-info/RECORD +173 -0
  171. project_init-0.3.0.dist-info/WHEEL +4 -0
  172. project_init-0.3.0.dist-info/entry_points.txt +2 -0
  173. project_init-0.3.0.dist-info/licenses/LICENSE +201 -0
project_init/mcps.py ADDED
@@ -0,0 +1,57 @@
1
+ """MCP catalog and emit helpers for the project-init wizard.
2
+
3
+ All commands use bunx (bun's npx equivalent) — no npm/npx anywhere.
4
+ PI-15 (replace npx with bun) is satisfied by construction here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Core MCPs always offered as a multi-select in the wizard.
10
+ # Absent intentionally (PI-25 / PI-26):
11
+ # linear — gh CLI + GitHub Issues covers all needs (~15 tools saved)
12
+ # github — gh CLI covers PR/issue management (~35 tools saved)
13
+ # filesystem — Claude Code built-in Read/Write/Edit/Glob/Grep overlap entirely (~10 tools saved)
14
+ MCP_CATALOG: list[dict] = [
15
+ {
16
+ "id": "context7",
17
+ "name": "Context7",
18
+ "description": "Live library documentation lookup",
19
+ "command": "claude mcp add context7 bunx @upstash/context7-mcp",
20
+ },
21
+ ]
22
+
23
+ # Database MCPs — mutually exclusive, offered as a single-choice follow-up.
24
+ DB_CATALOG: dict[str, dict] = {
25
+ "postgres": {
26
+ "id": "postgres",
27
+ "name": "Postgres",
28
+ "command": "claude mcp add postgres bunx @modelcontextprotocol/server-postgres",
29
+ },
30
+ "sqlite": {
31
+ "id": "sqlite",
32
+ "name": "SQLite",
33
+ "command": "claude mcp add sqlite bunx mcp-server-sqlite",
34
+ },
35
+ }
36
+
37
+ # Browser automation MCP — offered as a yes/no follow-up.
38
+ PLAYWRIGHT_MCP: dict = {
39
+ "id": "playwright",
40
+ "name": "Playwright",
41
+ "command": "claude mcp add playwright bunx @playwright/mcp",
42
+ }
43
+
44
+
45
+ def format_installed_mcps(selected: list[dict]) -> str:
46
+ """Human-readable comma-separated list for template substitution."""
47
+ if not selected:
48
+ return "none"
49
+ return ", ".join(m["id"] for m in selected)
50
+
51
+
52
+ def format_installed_mcps_yaml(selected: list[dict]) -> str:
53
+ """Inline YAML list string for config.yaml template."""
54
+ if not selected:
55
+ return "[]"
56
+ items = ", ".join(f'"{m["id"]}"' for m in selected)
57
+ return f"[{items}]"
@@ -0,0 +1,374 @@
1
+ """Core scaffolding logic — pure functions, no user interaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+ _PACKAGE_DIR = Path(__file__).resolve().parent
11
+ _TEMPLATES_DIR = _PACKAGE_DIR / "templates"
12
+ if not _TEMPLATES_DIR.exists():
13
+ # Dev mode: templates live at repo root, not inside the package.
14
+ _TEMPLATES_DIR = _PACKAGE_DIR.parent.parent / "templates"
15
+
16
+ _DOT_PREFIX = "dot_"
17
+ _VAR_RE = re.compile(r"\{\{(\w+)\}\}")
18
+ # Matches an INNERMOST conditional block — the tempered dot forbids another
19
+ # opener inside the body. _render loops to a fixpoint, so nested
20
+ # {{#if outer}}...{{#if inner}}...{{/if}}...{{/if}} resolves inside-out.
21
+ _BLOCK_RE = re.compile(
22
+ r"\{\{#if\s+(\w+)\}\}((?:(?!\{\{#if\s).)*?)\{\{/if(?:\s+\w+)?\}\}",
23
+ re.DOTALL,
24
+ )
25
+ # Used by strict mode to detect unrendered handlebars-style markers.
26
+ # The (?<!\$) negative lookbehind exempts GitHub Actions expressions (${{ ... }}).
27
+ _ANY_PLACEHOLDER_RE = re.compile(r"(?<!\$)\{\{[^}]+\}\}")
28
+
29
+ # Paths under these dirs are never overwritten on re-run (idempotency).
30
+ _PRESERVE_DIRS = {"memory", "vault"}
31
+ # Except READMEs — those are always refreshed.
32
+ _ALWAYS_OVERWRITE = {"README.md"}
33
+
34
+
35
+ class TemplateRenderError(Exception):
36
+ """Raised in strict mode when unrendered placeholders survive scaffolding."""
37
+
38
+
39
+ def list_presets() -> list[dict]:
40
+ """Return all available presets as parsed dicts, sorted by name."""
41
+ presets_dir = _TEMPLATES_DIR / "presets"
42
+ results = []
43
+ for p in sorted(presets_dir.glob("*.toml")):
44
+ with p.open("rb") as f:
45
+ results.append(tomllib.load(f))
46
+ return results
47
+
48
+
49
+ def load_preset(name: str) -> dict:
50
+ """Load a single preset by name (e.g. 'obsidian-only')."""
51
+ path = _TEMPLATES_DIR / "presets" / f"{name}.toml"
52
+ if not path.exists():
53
+ available = [p.stem for p in (_TEMPLATES_DIR / "presets").glob("*.toml")]
54
+ msg = f"Unknown preset {name!r}. Available: {', '.join(available)}"
55
+ raise ValueError(msg)
56
+ with path.open("rb") as f:
57
+ return tomllib.load(f)
58
+
59
+
60
+ def _render(text: str, variables: dict[str, str]) -> str:
61
+ """Replace {{var}} placeholders and process {{#if var}}...{{/if var}} blocks.
62
+
63
+ Conditional blocks may nest; each pass substitutes the innermost blocks,
64
+ looping until no block remains (unclosed markers survive for strict mode
65
+ to flag).
66
+ """
67
+
68
+ def _replace_block(m: re.Match) -> str:
69
+ key = m.group(1)
70
+ return m.group(2) if variables.get(key) else ""
71
+
72
+ while True:
73
+ replaced = _BLOCK_RE.sub(_replace_block, text)
74
+ if replaced == text:
75
+ break
76
+ text = replaced
77
+ # Then simple variable substitution.
78
+ return _VAR_RE.sub(lambda m: variables.get(m.group(1), m.group(0)), text)
79
+
80
+
81
+ def _dot_rename(name: str) -> str:
82
+ """Rename 'dot_foo' to '.foo'."""
83
+ if name.startswith(_DOT_PREFIX):
84
+ return "." + name[len(_DOT_PREFIX) :]
85
+ return name
86
+
87
+
88
+ def _should_preserve(rel_path: Path, target: Path) -> bool:
89
+ """Return True if this file should be skipped on re-run."""
90
+ dest = target / rel_path
91
+ if not dest.exists():
92
+ return False
93
+ # Check if any parent dir is in the preserve set.
94
+ if rel_path.name in _ALWAYS_OVERWRITE:
95
+ return False
96
+ return any(part in _PRESERVE_DIRS for part in rel_path.parts)
97
+
98
+
99
+ def _rendered_bytes(src: Path, variables: dict[str, str], is_template: bool) -> bytes | None:
100
+ """Return the bytes scaffolding would write for *src*, or None if skipped.
101
+
102
+ Mirrors :func:`_emit_file`'s content rules without touching the filesystem,
103
+ so callers can compare against an existing file before deciding to write.
104
+ """
105
+ if is_template:
106
+ rendered = _render(src.read_text(encoding="utf-8"), variables)
107
+ if not rendered.strip():
108
+ return None
109
+ return rendered.encode("utf-8")
110
+ return src.read_bytes()
111
+
112
+
113
+ def _write_bytes(dest: Path, content: bytes, src: Path) -> None:
114
+ """Write *content* to *dest*, preserving *src*'s executable bit."""
115
+ dest.parent.mkdir(parents=True, exist_ok=True)
116
+ dest.write_bytes(content)
117
+ if src.stat().st_mode & 0o111:
118
+ dest.chmod(dest.stat().st_mode | 0o111)
119
+
120
+
121
+ def _new_sibling(dest: Path, content: bytes) -> Path:
122
+ """Pick a ``.new`` sibling path for a file we must not overwrite (PI-179).
123
+
124
+ Mirrors the upgrade conflict convention: an existing ``.new`` may hold a
125
+ user's in-progress merge, so reuse it only when its content already equals
126
+ the fresh render; otherwise take ``.new.1``, ``.new.2``, …
127
+ """
128
+ candidate = dest.parent / (dest.name + ".new")
129
+ counter = 0
130
+ while candidate.exists() and candidate.read_bytes() != content:
131
+ counter += 1
132
+ candidate = dest.parent / (dest.name + f".new.{counter}")
133
+ return candidate
134
+
135
+
136
+ def _has_pending_sibling(dest: Path) -> bool:
137
+ """True if an unresolved ``.new``/``.new.N`` sibling already exists for *dest*.
138
+
139
+ A pending sibling means a prior scaffold preserved this file and the user
140
+ has not merged it yet, so a re-run must keep protecting it (PI-179).
141
+ """
142
+ base = dest.name + ".new"
143
+ return (dest.parent / base).exists() or any(
144
+ p.name.startswith(base + ".") for p in dest.parent.glob(dest.name + ".new.*")
145
+ )
146
+
147
+
148
+ def _protected_as_sibling(
149
+ src: Path, dest: Path, variables: dict[str, str], is_template: bool, *, first: bool
150
+ ) -> Path | None:
151
+ """Write the fresh render beside *dest* instead of overwriting it (PI-179).
152
+
153
+ Returns the ``.new`` sibling path written when *dest* is a user-owned file
154
+ that must be protected — i.e. it exists with content differing from the
155
+ render, and either this is the *first* scaffold or an unresolved sibling
156
+ from an earlier run is still pending (so a re-run does not clobber a file
157
+ the user has not merged). Returns None when the normal write should proceed.
158
+ """
159
+ if not dest.exists() or not (first or _has_pending_sibling(dest)):
160
+ return None
161
+ content = _rendered_bytes(src, variables, is_template)
162
+ if content is None or dest.read_bytes() == content:
163
+ return None
164
+ sibling = _new_sibling(dest, content)
165
+ _write_bytes(sibling, content, src)
166
+ return sibling
167
+
168
+
169
+ def _output_rel_path(src: Path, layer_dir: Path) -> tuple[Path, bool]:
170
+ """Map a template source file to its output-relative path.
171
+
172
+ Renames ``dot_`` segments to dotfiles and strips the ``.tmpl`` suffix.
173
+ Returns the relative path and whether the file is a render template.
174
+ """
175
+ rel_parts = [_dot_rename(p) for p in src.relative_to(layer_dir).parts]
176
+ is_template = rel_parts[-1].endswith(".tmpl")
177
+ if is_template:
178
+ rel_parts[-1] = rel_parts[-1][: -len(".tmpl")]
179
+ return Path(*rel_parts), is_template
180
+
181
+
182
+ def _emit_file(
183
+ src: Path,
184
+ dest: Path,
185
+ variables: dict[str, str],
186
+ is_template: bool,
187
+ ) -> str | None:
188
+ """Write one template file to *dest*; return rendered text for .tmpl files.
189
+
190
+ Returns None when the file was skipped: a template whose rendered output
191
+ is empty or whitespace-only is not created at all. Wrapping an entire
192
+ .tmpl file in ``{{#if lang}}...{{/if}}`` therefore makes the file itself
193
+ conditional on that variable.
194
+ """
195
+ if is_template:
196
+ rendered = _render(src.read_text(encoding="utf-8"), variables)
197
+ if not rendered.strip():
198
+ return None
199
+ dest.parent.mkdir(parents=True, exist_ok=True)
200
+ dest.write_text(rendered, encoding="utf-8")
201
+ else:
202
+ rendered = ""
203
+ dest.parent.mkdir(parents=True, exist_ok=True)
204
+ shutil.copy2(src, dest)
205
+
206
+ # Preserve executable bit.
207
+ if src.stat().st_mode & 0o111:
208
+ dest.chmod(dest.stat().st_mode | 0o111)
209
+ return rendered
210
+
211
+
212
+ def _validate_no_placeholders(rendered_files: list[tuple[Path, str]]) -> None:
213
+ """Raise TemplateRenderError if any rendered file kept a ``{{...}}`` marker."""
214
+ offenders: list[str] = []
215
+ for rel_path, content in rendered_files:
216
+ for match in _ANY_PLACEHOLDER_RE.finditer(content):
217
+ offenders.append(f"{rel_path}: {match.group()}")
218
+ if offenders:
219
+ msg = "strict mode: unrendered placeholders survived scaffolding:\n " + "\n ".join(
220
+ offenders
221
+ )
222
+ raise TemplateRenderError(msg)
223
+
224
+
225
+ def _iter_layer_files(layers: list[str]):
226
+ """Yield (src, layer_dir) for every file across the preset's template layers."""
227
+ for layer_name in layers:
228
+ layer_dir = _TEMPLATES_DIR / layer_name
229
+ if not layer_dir.exists():
230
+ msg = f"Template layer {layer_name!r} not found at {layer_dir}"
231
+ raise FileNotFoundError(msg)
232
+ for src in sorted(layer_dir.rglob("*")):
233
+ if src.is_dir():
234
+ continue
235
+ yield src, layer_dir
236
+
237
+
238
+ def _commit_staged(
239
+ work_dir: Path,
240
+ target: Path,
241
+ staged: list[Path],
242
+ *,
243
+ first: bool = False,
244
+ conflicts: list[tuple[Path, Path]] | None = None,
245
+ ) -> list[Path]:
246
+ """Copy validated files from the strict-mode staging dir into *target*.
247
+
248
+ Honors rerun idempotency: user-owned memory/vault files are not overwritten.
249
+ When a *conflicts* list is passed (protection, PI-179), a user-owned file
250
+ with different content is kept and the fresh render lands as a ``.new``
251
+ sibling. A file is user-owned on the *first* scaffold, or on any run while an
252
+ unresolved sibling from an earlier run is still pending. Each protected file
253
+ is recorded as ``(original_rel, sibling_rel)``.
254
+ """
255
+ created: list[Path] = []
256
+ target.mkdir(parents=True, exist_ok=True)
257
+ for rel_path in staged:
258
+ if _should_preserve(rel_path, target):
259
+ continue
260
+ src = work_dir / rel_path
261
+ dest = target / rel_path
262
+ dest.parent.mkdir(parents=True, exist_ok=True)
263
+ protect = conflicts is not None and (first or _has_pending_sibling(dest))
264
+ if protect and dest.exists() and dest.read_bytes() != src.read_bytes():
265
+ sibling = _new_sibling(dest, src.read_bytes())
266
+ shutil.copy2(src, sibling)
267
+ conflicts.append((rel_path, sibling.relative_to(target)))
268
+ continue
269
+ shutil.copy2(src, dest)
270
+ created.append(rel_path)
271
+ return created
272
+
273
+
274
+ def scaffold(
275
+ target: Path,
276
+ preset: dict,
277
+ variables: dict[str, str],
278
+ *,
279
+ strict: bool = False,
280
+ conflicts: list[tuple[Path, Path]] | None = None,
281
+ ) -> list[Path]:
282
+ """Copy + render template layers into *target*. Return created file paths.
283
+
284
+ A .tmpl file whose rendered output is empty or whitespace-only is skipped
285
+ entirely (see :func:`_emit_file`) — this is how language-specific config
286
+ files are made conditional.
287
+
288
+ When *strict* is True, raise :class:`TemplateRenderError` if any
289
+ ``{{...}}`` placeholder or unclosed conditional survives rendering.
290
+
291
+ In strict mode, all output is written to a temporary directory first.
292
+ Only on successful validation are rendered files committed to target.
293
+
294
+ Passing a *conflicts* list turns on overwrite protection (PI-179): a
295
+ user-owned file whose content differs from the fresh render is never
296
+ overwritten — the render is written as a ``<file>.new`` sibling and the pair
297
+ ``(original_rel, sibling_rel)`` is appended to *conflicts*. A file counts as
298
+ user-owned on the first scaffold (no recorded ``config.yaml``) or, on any
299
+ later run, while an unresolved sibling from an earlier run is still pending —
300
+ so a re-run never clobbers a file the user has not merged yet. memory/vault
301
+ preservation is unchanged either way.
302
+ """
303
+ import uuid
304
+
305
+ first_scaffold = not (target / ".claude" / "config.yaml").exists()
306
+
307
+ layers: list[str] = preset["layers"]
308
+ created: list[Path] = []
309
+ staged: list[Path] = []
310
+ written: set[Path] = set() # paths this run has emitted (later layers may overwrite)
311
+ rendered_files: list[tuple[Path, str]] = [] # for strict-mode scan
312
+
313
+ # For strict mode: write to temp, validate, then copy into target.
314
+ # Non-strict: write directly to target (best-effort behavior acceptable per PI-21).
315
+ if strict:
316
+ # Use a temp directory under target.parent so staged files are close to
317
+ # the final target. UUID suffix prevents collisions.
318
+ temp_suffix = f".partial-{uuid.uuid4().hex[:8]}"
319
+ work_dir = target.parent / (target.name + temp_suffix)
320
+ else:
321
+ work_dir = target
322
+ work_dir.mkdir(parents=True, exist_ok=True)
323
+
324
+ try:
325
+ for src, layer_dir in _iter_layer_files(layers):
326
+ rel_path, is_template = _output_rel_path(src, layer_dir)
327
+
328
+ # For non-strict mode, check preservation against the actual target.
329
+ # For strict mode, we're writing to temp, so skip preservation check.
330
+ if not strict and _should_preserve(rel_path, target):
331
+ continue
332
+
333
+ # Overwrite protection (non-strict; strict handles it at commit time
334
+ # in _commit_staged): never clobber a user-owned file — write the
335
+ # render as a `.new` sibling instead (PI-179). Skip when an earlier
336
+ # layer of THIS run already wrote the path: later layers legitimately
337
+ # overwrite earlier ones, that is not a user file. work_dir == target
338
+ # in non-strict mode, so dest is the real file.
339
+ if (
340
+ not strict
341
+ and conflicts is not None
342
+ and rel_path not in written
343
+ and (
344
+ sibling := _protected_as_sibling(
345
+ src, work_dir / rel_path, variables, is_template, first=first_scaffold
346
+ )
347
+ )
348
+ is not None
349
+ ):
350
+ conflicts.append((rel_path, rel_path.parent / sibling.name))
351
+ continue
352
+
353
+ rendered = _emit_file(src, work_dir / rel_path, variables, is_template)
354
+ if rendered is None:
355
+ continue
356
+ if is_template and strict:
357
+ rendered_files.append((rel_path, rendered))
358
+ (staged if strict else created).append(rel_path)
359
+ written.add(rel_path)
360
+
361
+ if strict:
362
+ _validate_no_placeholders(rendered_files)
363
+ created = _commit_staged(
364
+ work_dir, target, staged, first=first_scaffold, conflicts=conflicts
365
+ )
366
+ shutil.rmtree(work_dir)
367
+
368
+ except Exception:
369
+ # On any error (validation or I/O), clean up temp directory in strict mode.
370
+ if strict and work_dir.exists():
371
+ shutil.rmtree(work_dir)
372
+ raise
373
+
374
+ return created
@@ -0,0 +1,50 @@
1
+ # {{project_name}}
2
+
3
+ {{project_description}}
4
+
5
+ Canonical instructions for **all** coding agents (and humans pairing with
6
+ them). Claude Code reads [CLAUDE.md](CLAUDE.md), which redirects here.
7
+
8
+ ## Start here
9
+
10
+ All agentic-development infrastructure lives under [`.claude/`](.claude/) —
11
+ the directory name is historical; the contents are agent-neutral (bash
12
+ scripts, markdown skills, memory, and docs):
13
+
14
+ - [`.claude/project-init.md`](.claude/project-init.md) — workflow, conventions, task tracking
15
+ - [`.claude/memory/MEMORY.md`](.claude/memory/MEMORY.md) — memory index (read first for context)
16
+ - [`.claude/docs/`](.claude/docs/) — system of record: ADRs and development guides
17
+ - [`.claude/vault/`](.claude/vault/) — human workspace (Obsidian vault)
18
+
19
+ ## Skills (load on demand)
20
+
21
+ Before any GitHub action (create issue, branch, push, PR, merge), check
22
+ the available skills (plugin-provided in the default scaffold;{{#if no_plugin}} indexed in [`.claude/skills/INDEX.md`](.claude/skills/INDEX.md);{{/if no_plugin}} `/help` lists them) and load the relevant
23
+ skill file. Skills are plain markdown (SKILL.md) — load only the one that
24
+ matches what you are about to do.
25
+
26
+ > Scaffolded with [project-init]({{project_init_url}}) on {{created_date}}.
27
+
28
+ ## Key rules for agents
29
+
30
+ - **TDD** — write failing tests before any implementation.
31
+ - **GitHub Projects** — work is tracked on the GitHub Projects board backed by GitHub Issues. Before starting non-trivial work, create or reference an issue.
32
+ - **GitHub workflow** — for any push, PR, review, or merge action, load the `github_workflow` skill{{#if no_plugin}} (`.claude/skills/github_workflow/SKILL.md`){{/if no_plugin}}. Quick ref: branch = `<type>/<KEY>-<n>-<slug>` | PR title = `type(KEY-N): desc` (no scope = no issue) | body includes `Closes #N`.
33
+ {{#if lint_command}}- **Commands** — the `justfile` is the canonical command surface: `just --list` shows every recipe (`setup`, `lint`, `format`, `test`, `docs`, `ci`). Prefer `just <recipe>` over raw tool invocations so every agent and CI run the same commands.
34
+ - **Lint** — `just lint` must pass before closing a task. The linter config enforces docstrings and complexity caps on project code — fix the code, don't loosen the gate.
35
+ {{/if}}- **Docs** — follow the Diátaxis layout in [`docs/`](docs/) (see `docs/index.md`). Record architectural decisions with the `add_adr` skill.
36
+ - **Ownership boundaries** — each tool owns exactly one concern: {{#if mise}}`mise` owns toolchain versions, {{/if mise}}uv/bun own dependencies, `just` owns commands, `.env` owns environment variables. Don't blur them (no mise tasks/env, no version pins in scripts, no commands outside the justfile).
37
+ - **No secrets in code** — never hardcode API keys, tokens, or personal data. Copy `.env.example` to `.env` (gitignored) and load it explicitly; see `.claude/docs/guides/secrets.md` for the escalation path to org secret managers.
38
+ - **No prod credentials in agent sessions** — destructive infra/DB commands are flagged by the `prod_guard` hook (ask interactively, hard-block in fully autonomous mode; escape hatch: `safety.allow` in `.claude/config.yaml`). That guard is a guardrail only — the guarantee is credential separation: production credentials belong to review-gated CI jobs, never to a shell an agent runs in (ADR-012).
39
+ - **Enforcement is agent-agnostic** — secret scanning (gitleaks) and lifecycle gating run as git hooks plus CI checks (`validate-pr`, `secret-scan`), binding every agent and human alike. Run `.claude/scripts/install_hooks.sh` once per clone to activate them.
40
+ {{#if other_agents}}- **Agent support tiers** — only the Claude Code path is functionally CI-tested.{{#if codex}} Codex: skills are discoverable under `.agents/skills/` and the command guard runs via `.codex/hooks.json` (adapter: `.claude/hooks/agent_guard_adapter.py`).{{/if codex}}{{#if gemini}} Gemini CLI: link the project extension once per clone (`.claude/scripts/setup_gemini.sh`) for workflow /commands and the command guard; `GEMINI.md` stays the instruction entry point.{{/if gemini}}{{#if ollama}} Ollama-based agents (Aider, Goose, OpenHands, …): instructions-level only — this file, the portable bash lifecycle scripts, and markdown memory; no agent-specific wiring is maintained.{{/if ollama}} Codex/Gemini overlays are validated by contract tests on the rendered files, not by running those agents; the real security boundary for every agent is the git/CI enforcement above.
41
+ {{/if other_agents}}
42
+
43
+ ## Claude Code specifics
44
+
45
+ This section applies only when the agent is Claude Code. Other agents get
46
+ no automatic hooks — rely on the git/CI enforcement above.
47
+
48
+ - Hooks in `.claude/settings.json` fire automatically: `pre_commit_gate` (lint gate on commit), `github_command_guard` (steers toward lifecycle scripts), `post_edit_lint`, `workflow_state_reminder`.
49
+ - Skills are auto-discovered and invocable as `/command`s.
50
+ - Review plugins: `pr-review-toolkit@claude-plugins-official` is pre-enabled in `.claude/settings.json`; also consider `/plugin install code-review@claude-plugins-official` for full PR reviews.
@@ -0,0 +1,16 @@
1
+ # {{project_name}} — Claude Code entry point
2
+
3
+ Canonical agent instructions live in [AGENTS.md](AGENTS.md) — the
4
+ Linux Foundation standard file most coding agents read natively. Read it
5
+ before working in this codebase; it is the source of truth for workflow,
6
+ conventions, memory, tools, and branch naming. Its "Claude Code specifics"
7
+ section applies to this session.
8
+
9
+ ## Compact Instructions
10
+
11
+ When compacting this conversation, preserve:
12
+ - The project ticket key and GitHub issue number being worked on (e.g. `PI-42`, GitHub `#42`)
13
+ - Files modified in this session (list by path)
14
+ - Test results: pass/fail count and any failing test names
15
+ - Unresolved errors or lint failures
16
+ - Any decisions made that aren't yet committed to `docs/adr/`
@@ -0,0 +1,55 @@
1
+ # Contributing to {{project_name}}
2
+
3
+ {{project_description}}
4
+
5
+ This project was scaffolded with [project-init]({{project_init_url}}): agent
6
+ instructions live in [AGENTS.md](AGENTS.md) (canonical; `CLAUDE.md` and
7
+ `GEMINI.md` redirect there), and the conventions below bind humans and
8
+ coding agents alike. Claude Code gets deterministic enforcement (hooks);
9
+ other agents and humans get the same rules via git hooks and CI.
10
+
11
+ ## Setup
12
+ {{#if justfile}}
13
+ ```bash
14
+ just setup
15
+ ```
16
+ {{/if justfile}}
17
+ Install the repo git hooks once per clone — they enforce commit-message
18
+ format, secret scanning, and branch naming locally:
19
+
20
+ ```bash
21
+ .claude/scripts/install_hooks.sh
22
+ ```
23
+ {{#if justfile}}
24
+ ## Commands
25
+
26
+ The justfile is the canonical command surface — `just --list` shows every
27
+ recipe. CI runs the same recipes, so if `just lint` and `just test` pass
28
+ locally, CI agrees.
29
+ {{/if justfile}}
30
+ ## Branches, commits, and PRs
31
+
32
+ | What | Pattern | Example |
33
+ |---|---|---|
34
+ | Branch | `<type>/<KEY>-<n>-<slug>` | `feat/KEY-42-add-oauth` |
35
+ | PR title | `type(KEY-N): description` | `feat(KEY-42): add OAuth login` |
36
+ | No-issue PR | `type: description` | `chore: bump dev dependency` |
37
+ | PR body | must include `Closes #N` (skip for no-issue PRs) | |
38
+
39
+ `KEY` is the project issue key set in `.claude/config.yaml`
40
+ (`project_key`). Types: `feat` `fix` `chore` `docs` `test`. Work starts
41
+ from an issue — use `.claude/scripts/create_issue.sh` (or the
42
+ `start_task` skill in Claude Code) so metadata lands on the project board.
43
+
44
+ ## Review flow
45
+
46
+ 1. Open a draft PR early; push with `.claude/scripts/push_branch.sh`.
47
+ 2. Mark ready when CI is green. Respond to every review comment —
48
+ including bot reviews — either by fixing or by explaining why not.
49
+ 3. Merges go through `.claude/scripts/monitor_pr.sh <n> --merge` so CI
50
+ and review gates are honored.
51
+
52
+ ## Security
53
+
54
+ See [SECURITY.md](SECURITY.md) for how to report vulnerabilities —
55
+ please do not open public issues for security problems.
@@ -0,0 +1,16 @@
1
+ # Gemini Agent Instructions
2
+
3
+ Canonical instructions for this project live in [AGENTS.md](AGENTS.md).
4
+
5
+ Read [AGENTS.md](AGENTS.md) before working in this codebase. It is the
6
+ source of truth for workflow, conventions, memory, tools, and branch
7
+ naming. Ignore its "Claude Code specifics" section — enforcement that
8
+ applies to you (git hooks, CI checks) is listed under "Key rules for
9
+ agents".
10
+
11
+ Before any GitHub action (create issue, branch, push, PR, merge), check
12
+ [`.claude/skills/INDEX.md`](.claude/skills/INDEX.md) and load the relevant
13
+ skill file.
14
+
15
+ {{#if lint_command}}Discover project commands with `just --list` — the justfile is the canonical, agent-agnostic command surface (lint, format, test, docs, ci).
16
+ {{/if}}