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
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env python3
2
+ """DAG-based workflow enforcement for the GitHub lifecycle.
3
+
4
+ Subcommands:
5
+ check <node> exit 0 if every prerequisite of <node> is satisfied,
6
+ exit 2 otherwise (with reason on stdout).
7
+ guard read PreToolUse hook input JSON from stdin, map the
8
+ Bash command to a target node, emit
9
+ {decision: block, reason: ...} if disallowed.
10
+ nodes list every DAG node and its prerequisites.
11
+ push [<branch>] [N] push current (or named) branch with retry + remote-SHA
12
+ verification (handles transient GitHub 5xx).
13
+ promote [<pr>] mark current (or numbered) draft PR ready for review.
14
+ finish [<pr>] [--review-cycle N]
15
+ push, promote, then exec monitor_pr.sh --merge.
16
+ create-pr-nojira <type> <title> [--branch B] [--base B]
17
+ create a no-issue feature branch + draft PR.
18
+
19
+ The `check` and `guard` paths are pure read-only; they're used by hooks and
20
+ lifecycle scripts. The other subcommands consolidate the bash lifecycle
21
+ scripts (push_branch.sh, promote_review.sh, finish_pr.sh,
22
+ create_nojira_pr.sh) so the tool is the single source of truth for the
23
+ GitHub workflow. The .sh files become thin shims that exec into here.
24
+
25
+ Issue-ref prefix detection:
26
+ By default, branches matching `[A-Z]{2,}-<n>` (e.g. PI-98, ACME-42) are
27
+ treated as issue-backed. Override with the DAG_ISSUE_PREFIX env var to pin
28
+ a specific prefix (e.g. DAG_ISSUE_PREFIX=PROJ matches only `PROJ-<n>`).
29
+ Branches with no recognized prefix fall through to the no-jira flow.
30
+
31
+ stdlib only.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import os
38
+ import re
39
+ import subprocess
40
+ import sys
41
+ import time
42
+ from pathlib import Path
43
+
44
+ CACHE_PATH = Path(".claude/.workflow-state.json")
45
+
46
+ GRAPH: dict[str, list[str]] = {
47
+ "issue.created": [],
48
+ "branch.created": [],
49
+ "branch.pushed": ["branch.created"],
50
+ "pr.opened": ["branch.pushed", "issue.created"],
51
+ "ci.green": ["pr.opened"],
52
+ "review.approved": ["pr.opened"],
53
+ "pr.merged": ["ci.green", "review.approved"],
54
+ }
55
+
56
+ _CONFIGURED_PREFIX = os.environ.get("DAG_ISSUE_PREFIX", "").strip()
57
+ if _CONFIGURED_PREFIX:
58
+ ISSUE_RE: re.Pattern[str] = re.compile(
59
+ rf"\b{re.escape(_CONFIGURED_PREFIX)}-(\d+)\b", re.IGNORECASE
60
+ )
61
+ else:
62
+ ISSUE_RE = re.compile(r"\b[A-Z]{2,}-(\d+)\b")
63
+
64
+
65
+ def _run(cmd: list[str]) -> tuple[int, str]:
66
+ try:
67
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
68
+ except (subprocess.TimeoutExpired, FileNotFoundError):
69
+ return 1, ""
70
+ return proc.returncode, proc.stdout
71
+
72
+
73
+ def _gh(args: list[str]) -> tuple[int, str]:
74
+ return _run(["gh", *args])
75
+
76
+
77
+ def _git(args: list[str]) -> tuple[int, str]:
78
+ return _run(["git", *args])
79
+
80
+
81
+ def _current_branch() -> str | None:
82
+ code, out = _git(["branch", "--show-current"])
83
+ branch = out.strip()
84
+ return branch if code == 0 and branch else None
85
+
86
+
87
+ def _issue_from_branch(branch: str) -> int | None:
88
+ m = ISSUE_RE.search(branch)
89
+ return int(m.group(1)) if m else None
90
+
91
+
92
+ def check_issue_created() -> tuple[bool, str]:
93
+ branch = _current_branch()
94
+ if not branch:
95
+ return False, "no current branch"
96
+ n = _issue_from_branch(branch)
97
+ if n is None:
98
+ return True, f"branch '{branch}' has no issue ref (no-jira flow allowed)"
99
+ code, out = _gh(["issue", "view", str(n), "--json", "number,state"])
100
+ if code != 0:
101
+ return False, f"issue #{n} not found via gh"
102
+ try:
103
+ data = json.loads(out or "{}")
104
+ except json.JSONDecodeError:
105
+ return False, f"issue #{n}: malformed gh output"
106
+ if data.get("number") != n:
107
+ return False, f"issue #{n} not present"
108
+ return True, f"issue #{n} exists ({data.get('state', 'UNKNOWN')})"
109
+
110
+
111
+ def check_branch_created() -> tuple[bool, str]:
112
+ branch = _current_branch()
113
+ if not branch:
114
+ return False, "not in a git repo / no current branch"
115
+ if branch in {"main", "master"}:
116
+ return False, "must be on a feature branch, not main/master"
117
+ return True, f"on branch '{branch}'"
118
+
119
+
120
+ def check_branch_pushed() -> tuple[bool, str]:
121
+ branch = _current_branch()
122
+ if not branch:
123
+ return False, "no current branch"
124
+ code, _ = _git(["rev-parse", "--verify", f"origin/{branch}"])
125
+ if code != 0:
126
+ return False, f"origin/{branch} does not exist (push the branch first)"
127
+ code, out = _git(["rev-list", "--count", f"origin/{branch}..HEAD"])
128
+ if code == 0 and out.strip() and out.strip() != "0":
129
+ return False, f"branch has {out.strip()} unpushed commit(s)"
130
+ return True, f"branch '{branch}' is pushed and up to date with origin"
131
+
132
+
133
+ def check_pr_opened() -> tuple[bool, str]:
134
+ code, out = _gh(["pr", "view", "--json", "number,state"])
135
+ if code != 0:
136
+ return False, "no PR exists for the current branch"
137
+ try:
138
+ data = json.loads(out or "{}")
139
+ except json.JSONDecodeError:
140
+ return False, "malformed gh pr view output"
141
+ if data.get("state") != "OPEN":
142
+ return False, f"PR is {data.get('state', 'unknown')}, not OPEN"
143
+ return True, f"PR #{data.get('number')} is open"
144
+
145
+
146
+ def check_ci_green() -> tuple[bool, str]:
147
+ code, out = _gh(["pr", "view", "--json", "number,statusCheckRollup"])
148
+ if code != 0:
149
+ return False, "cannot read PR / CI status"
150
+ try:
151
+ data = json.loads(out or "{}")
152
+ except json.JSONDecodeError:
153
+ return False, "malformed gh statusCheckRollup output"
154
+ n = data.get("number", "?")
155
+ rollup = data.get("statusCheckRollup") or []
156
+ pending = failing = 0
157
+ for entry in rollup:
158
+ name = (entry.get("name") or entry.get("context") or "").lower()
159
+ if "review/decision" in name:
160
+ continue
161
+ status = (entry.get("status") or "").upper()
162
+ conclusion = (entry.get("conclusion") or "").upper()
163
+ if conclusion in {"FAILURE", "TIMED_OUT", "CANCELLED", "ERROR", "ACTION_REQUIRED"}:
164
+ failing += 1
165
+ elif status in {"PENDING", "QUEUED", "IN_PROGRESS", "WAITING"} or (
166
+ not conclusion and not status
167
+ ):
168
+ pending += 1
169
+ if failing:
170
+ return False, f"PR #{n}: {failing} CI check(s) failing"
171
+ if pending:
172
+ return False, f"PR #{n}: {pending} CI check(s) still running"
173
+ return True, f"PR #{n}: CI green"
174
+
175
+
176
+ def check_review_approved() -> tuple[bool, str]:
177
+ code, out = _gh(["pr", "view", "--json", "number,reviewDecision"])
178
+ if code != 0:
179
+ return False, "cannot read PR review decision"
180
+ try:
181
+ data = json.loads(out or "{}")
182
+ except json.JSONDecodeError:
183
+ return False, "malformed gh reviewDecision output"
184
+ n = data.get("number", "?")
185
+ decision = data.get("reviewDecision") or ""
186
+ if decision == "APPROVED":
187
+ return True, f"PR #{n}: review approved"
188
+ if decision == "CHANGES_REQUESTED":
189
+ return False, f"PR #{n}: review requested changes"
190
+ return False, f"PR #{n}: review pending (decision={decision or 'none'})"
191
+
192
+
193
+ def check_pr_merged() -> tuple[bool, str]:
194
+ code, out = _gh(["pr", "view", "--json", "number,state"])
195
+ if code != 0:
196
+ return False, "no PR for current branch"
197
+ try:
198
+ data = json.loads(out or "{}")
199
+ except json.JSONDecodeError:
200
+ return False, "malformed gh output"
201
+ if data.get("state") == "MERGED":
202
+ return True, f"PR #{data.get('number')} is merged"
203
+ return False, f"PR #{data.get('number')} state is {data.get('state')}"
204
+
205
+
206
+ CHECKS = {
207
+ "issue.created": check_issue_created,
208
+ "branch.created": check_branch_created,
209
+ "branch.pushed": check_branch_pushed,
210
+ "pr.opened": check_pr_opened,
211
+ "ci.green": check_ci_green,
212
+ "review.approved": check_review_approved,
213
+ "pr.merged": check_pr_merged,
214
+ }
215
+
216
+
217
+ def prereqs_satisfied(node: str, _seen: set[str] | None = None) -> tuple[bool, str]:
218
+ """Walk all ancestors of `node` and return (True, '') if every prereq passes,
219
+ else (False, '<first failing prereq>: <reason>').
220
+ """
221
+ if node not in GRAPH:
222
+ return False, f"unknown node: {node}"
223
+ seen = _seen if _seen is not None else set()
224
+ if node in seen:
225
+ return True, ""
226
+ seen.add(node)
227
+ for prereq in GRAPH[node]:
228
+ ok, reason = CHECKS[prereq]()
229
+ if not ok:
230
+ return False, f"{prereq}: {reason}"
231
+ ok, reason = prereqs_satisfied(prereq, seen)
232
+ if not ok:
233
+ return False, reason
234
+ return True, "all prerequisites satisfied"
235
+
236
+
237
+ # Steering rules: command pattern -> (target_node | None, redirect_message)
238
+ # The first matching rule wins. target_node=None means a hard block with no
239
+ # DAG validation; otherwise, prereqs of target_node are appended to the reason.
240
+ COMMAND_RULES: list[tuple[re.Pattern[str], str | None, str]] = [
241
+ (
242
+ re.compile(r"git\s+push\s+(?:\S+\s+)?(?:origin\s+)?(?:main|master)\b"),
243
+ None,
244
+ "Direct pushes to main/master are blocked. Open a feature branch and PR.",
245
+ ),
246
+ (
247
+ re.compile(r"\bgh\s+api\s+repos/[^/\s]+/[^/\s]+/pulls/\d+/merge\b"),
248
+ "pr.merged",
249
+ "Use .claude/scripts/monitor_pr.sh <pr> --merge instead of `gh api .../merge` so CI and review gates are honored.",
250
+ ),
251
+ (
252
+ re.compile(r"\bgh\s+pr\s+merge\b"),
253
+ "pr.merged",
254
+ "Use .claude/scripts/monitor_pr.sh <pr> --merge instead of `gh pr merge` so CI, review waits, and review cycles are handled.",
255
+ ),
256
+ (
257
+ re.compile(r"\bgh\s+pr\s+checks\b.*--watch"),
258
+ None,
259
+ "Use .claude/scripts/monitor_pr.sh <pr> --merge instead of `gh pr checks --watch`.",
260
+ ),
261
+ (
262
+ re.compile(r"\bgh\s+pr\s+ready\b"),
263
+ "pr.opened",
264
+ "Use .claude/scripts/promote_review.sh instead of `gh pr ready`.",
265
+ ),
266
+ (
267
+ re.compile(r"\bgh\s+pr\s+create\b"),
268
+ "pr.opened",
269
+ "Use .claude/scripts/start_issue.sh (issue-backed) or .claude/scripts/create_nojira_pr.sh (no issue) instead of `gh pr create`.",
270
+ ),
271
+ (
272
+ re.compile(r"\bgh\s+issue\s+create\b"),
273
+ None,
274
+ "Use .claude/scripts/create_issue.sh (or the start_task skill) so priority, references, and acceptance criteria are captured.",
275
+ ),
276
+ (
277
+ re.compile(r"\bgit\s+push\b"),
278
+ "branch.pushed",
279
+ "Use .claude/scripts/push_branch.sh instead of raw `git push` so transient GitHub failures are retried and the remote SHA is verified.",
280
+ ),
281
+ ]
282
+
283
+
284
+ _HEREDOC_RE = re.compile(
285
+ r"<<-?\s*['\"]?(\w+)['\"]?[ \t]*\n.*?\n\1[ \t]*(?:\n|$)",
286
+ re.DOTALL,
287
+ )
288
+
289
+
290
+ def _strip_heredocs(cmd: str) -> str:
291
+ """Remove heredoc body text so pattern rules don't fire on body content."""
292
+ return _HEREDOC_RE.sub("", cmd)
293
+
294
+
295
+ def _redirect_target_exists(reason: str) -> bool:
296
+ """Best-effort: scan the redirect message for a `.claude/scripts/<name>`
297
+ reference and check whether the file exists. If no reference is found,
298
+ treat the rule as always-applicable (e.g. main/master block).
299
+ """
300
+ m = re.search(r"\.claude/scripts/([\w.-]+)", reason)
301
+ if not m:
302
+ return True
303
+ return (Path(".claude/scripts") / m.group(1)).exists()
304
+
305
+
306
+ def guard(payload: dict) -> dict | None:
307
+ cmd = ((payload.get("tool_input") or {}).get("command") or "").strip()
308
+ if not cmd:
309
+ return None
310
+
311
+ # Strip heredoc bodies so pattern rules don't fire on body content
312
+ # (e.g. `gh issue create --body "$(cat <<'EOF'\n...git push...\nEOF\n)"`)
313
+ cmd_scan = _strip_heredocs(cmd)
314
+
315
+ for pattern, target, message in COMMAND_RULES:
316
+ if not pattern.search(cmd_scan):
317
+ continue
318
+ # If the redirect points at a wrapper script that doesn't exist in
319
+ # this repo, skip (don't block — there's nothing to redirect to).
320
+ if not _redirect_target_exists(message):
321
+ continue
322
+
323
+ reason = message
324
+ if target is not None:
325
+ ok, why = prereqs_satisfied(target)
326
+ if not ok:
327
+ reason = f"{message}\n\nDAG prerequisite for {target} not met: {why}."
328
+ return {"decision": "block", "reason": reason}
329
+ return None
330
+
331
+
332
+ def cmd_check(node: str) -> int:
333
+ if node not in GRAPH:
334
+ sys.stdout.write(f"unknown node: {node}\n")
335
+ return 2
336
+ ok, reason = prereqs_satisfied(node)
337
+ if ok:
338
+ # Also report the node's own check, for human consumption.
339
+ own_ok, own_reason = CHECKS[node]()
340
+ marker = "OK" if own_ok else "REACHABLE (state not yet satisfied)"
341
+ sys.stdout.write(f"{marker}: {node} — {own_reason}\n")
342
+ return 0 if own_ok else 0 # prereqs satisfied = transition allowed
343
+ sys.stdout.write(f"BLOCKED: cannot reach {node}: {reason}\n")
344
+ return 2
345
+
346
+
347
+ def cmd_guard() -> int:
348
+ raw = sys.stdin.read()
349
+ if not raw:
350
+ return 0
351
+ try:
352
+ payload = json.loads(raw)
353
+ except json.JSONDecodeError:
354
+ return 0
355
+ result = guard(payload)
356
+ if result is not None:
357
+ sys.stdout.write(json.dumps(result))
358
+ return 0
359
+
360
+
361
+ def _detect_pr_number() -> int | None:
362
+ code, out = _gh(["pr", "view", "--json", "number", "-q", ".number"])
363
+ if code != 0:
364
+ return None
365
+ try:
366
+ return int(out.strip())
367
+ except ValueError:
368
+ return None
369
+
370
+
371
+ def cmd_push(branch: str | None, max_retries: int, *, force: bool = False) -> int:
372
+ """Push the current (or named) branch with retry + remote-SHA verification.
373
+
374
+ Handles transient GitHub 5xx where `git push` exits non-zero but the
375
+ commit actually landed on the remote. *force* adds --force-with-lease
376
+ for the rebase-after-squash-merge case; main/master are refused.
377
+ """
378
+ if branch is None:
379
+ branch = _current_branch()
380
+ if not branch:
381
+ sys.stderr.write("push: no current branch\n")
382
+ return 1
383
+ if force and branch in ("main", "master"):
384
+ sys.stderr.write("push: refusing to force-push main/master\n")
385
+ return 1
386
+
387
+ code, sha_out = _git(["rev-parse", branch])
388
+ if code != 0:
389
+ sys.stderr.write(f"push: cannot resolve sha for {branch}\n")
390
+ return 1
391
+ expected_sha = sha_out.strip()
392
+
393
+ def remote_has_sha() -> bool:
394
+ code, out = _git(["ls-remote", "origin", f"refs/heads/{branch}"])
395
+ if code != 0:
396
+ return False
397
+ for line in out.splitlines():
398
+ parts = line.split()
399
+ if parts and parts[0] == expected_sha:
400
+ return True
401
+ return False
402
+
403
+ for attempt in range(max_retries + 1):
404
+ push_cmd = ["git", "push", "-u", "origin", branch]
405
+ if force:
406
+ push_cmd.append("--force-with-lease")
407
+ proc = subprocess.run(push_cmd)
408
+ if proc.returncode == 0:
409
+ sys.stdout.write(f"push: pushed {branch} ({expected_sha})\n")
410
+ return 0
411
+ if remote_has_sha():
412
+ sys.stdout.write(
413
+ f"push: remote already has {expected_sha} on {branch} "
414
+ "(transient error, treating as success)\n"
415
+ )
416
+ _git(["branch", f"--set-upstream-to=origin/{branch}", branch])
417
+ return 0
418
+ if attempt < max_retries:
419
+ time.sleep(3)
420
+ sys.stderr.write(f"push: failed after {max_retries} retries\n")
421
+ return 1
422
+
423
+
424
+ def cmd_promote(pr_number: int | None) -> int:
425
+ """Mark a draft PR ready for review."""
426
+ if pr_number is None:
427
+ pr_number = _detect_pr_number()
428
+ if pr_number is None:
429
+ sys.stderr.write(
430
+ "promote: no PR found for current branch. Pass a PR number.\n"
431
+ )
432
+ return 1
433
+ code, out = _gh(
434
+ ["pr", "view", str(pr_number), "--json", "isDraft", "-q", ".isDraft"]
435
+ )
436
+ if code != 0:
437
+ sys.stderr.write(f"promote: cannot read PR #{pr_number}\n")
438
+ return 1
439
+ if out.strip() == "false":
440
+ _, url = _gh(["pr", "view", str(pr_number), "--json", "url", "-q", ".url"])
441
+ sys.stdout.write(
442
+ f"PR #{pr_number} is already ready for review: {url.strip()}\n"
443
+ )
444
+ return 0
445
+ code, _ = _gh(["pr", "ready", str(pr_number)])
446
+ if code != 0:
447
+ sys.stderr.write(f"promote: gh pr ready failed for #{pr_number}\n")
448
+ return code
449
+ _, url = _gh(["pr", "view", str(pr_number), "--json", "url", "-q", ".url"])
450
+ sys.stdout.write(
451
+ f"PR #{pr_number} is now ready for review: {url.strip()}\n"
452
+ )
453
+ return 0
454
+
455
+
456
+ def cmd_finish(pr_number: int | None, review_cycle: int | None) -> int:
457
+ """Push, promote, then hand off to monitor_pr.sh for CI/review/merge."""
458
+ rc = cmd_push(None, 3)
459
+ if rc != 0:
460
+ return rc
461
+ if pr_number is None:
462
+ pr_number = _detect_pr_number()
463
+ if pr_number is None:
464
+ sys.stderr.write("finish: no PR found for current branch.\n")
465
+ return 1
466
+ rc = cmd_promote(pr_number)
467
+ if rc != 0:
468
+ return rc
469
+ monitor_args = [".claude/scripts/monitor_pr.sh", str(pr_number), "--merge"]
470
+ if review_cycle is not None:
471
+ monitor_args += ["--review-cycle", str(review_cycle)]
472
+ return subprocess.run(monitor_args).returncode
473
+
474
+
475
+ _VALID_TYPES = {"feat", "fix", "chore", "docs", "test"}
476
+ _BRANCH_RE = re.compile(r"^(feat|fix|chore|docs|test)/[A-Za-z0-9._/-]+$")
477
+
478
+
479
+ def _slugify(text: str) -> str:
480
+ s = re.sub(r"[^a-z0-9]+", "-", text.lower())
481
+ return s.strip("-")
482
+
483
+
484
+ def cmd_create_pr_nojira(
485
+ type_: str, title: str, branch: str | None, base: str | None
486
+ ) -> int:
487
+ """Create a no-issue feature branch (if needed) and open a draft PR."""
488
+ if type_ not in _VALID_TYPES:
489
+ sys.stderr.write(
490
+ f"ERROR: invalid type '{type_}'. Valid: {' '.join(sorted(_VALID_TYPES))}\n"
491
+ )
492
+ return 1
493
+ if not title.strip():
494
+ sys.stderr.write("ERROR: title must not be empty\n")
495
+ return 1
496
+
497
+ current = _current_branch()
498
+ if not branch:
499
+ if current and current not in {"main", "master"}:
500
+ branch = current
501
+ else:
502
+ slug = _slugify(title)
503
+ if not slug:
504
+ sys.stderr.write(
505
+ "ERROR: title must contain at least one letter or number\n"
506
+ )
507
+ return 1
508
+ prefix = "nojira-"
509
+ max_slug = max(12, 80 - len(type_) - 1 - len(prefix))
510
+ slug = slug[:max_slug].rstrip("-")
511
+ branch = f"{type_}/{prefix}{slug}"
512
+
513
+ if not _BRANCH_RE.match(branch):
514
+ sys.stderr.write(
515
+ f"ERROR: branch '{branch}' must start with feat|fix|chore|docs|test/\n"
516
+ )
517
+ return 1
518
+
519
+ if current == branch:
520
+ sys.stdout.write(f"Already on branch {branch}\n")
521
+ else:
522
+ code, _ = _git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"])
523
+ if code == 0:
524
+ sys.stdout.write(f"Branch {branch} already exists - switching\n")
525
+ _git(["checkout", branch])
526
+ else:
527
+ _git(["checkout", "-b", branch])
528
+
529
+ rc = cmd_push(branch, 3)
530
+ if rc != 0:
531
+ return rc
532
+
533
+ code, url = _gh(["pr", "view", "--json", "url", "-q", ".url"])
534
+ if code == 0 and url.strip():
535
+ sys.stdout.write(f"Draft PR already exists: {url.strip()}\n")
536
+ return 0
537
+
538
+ # Conventional Commits, no scope = no linked issue (ADR-006)
539
+ pr_title = f"{type_}: {title}"
540
+ pr_body = "No linked issue (nojira)."
541
+ args = ["pr", "create", "--draft", "--title", pr_title, "--body", pr_body]
542
+ if base:
543
+ args += ["--base", base]
544
+ code, out = _gh(args)
545
+ if code != 0:
546
+ sys.stderr.write("create-pr-nojira: gh pr create failed\n")
547
+ return code
548
+ sys.stdout.write(f"Draft PR: {out.strip()}\n")
549
+ return 0
550
+
551
+
552
+ def main(argv: list[str] | None = None) -> int:
553
+ parser = argparse.ArgumentParser(prog="dag_workflow")
554
+ sub = parser.add_subparsers(dest="cmd", required=True)
555
+
556
+ p_check = sub.add_parser("check", help="check whether a node is reachable")
557
+ p_check.add_argument("node", help="DAG node name (e.g. pr.merged)")
558
+
559
+ sub.add_parser("guard", help="PreToolUse hook entrypoint (reads stdin)")
560
+ sub.add_parser("nodes", help="list all DAG nodes")
561
+
562
+ p_push = sub.add_parser("push", help="push current branch with retry + SHA verify")
563
+ p_push.add_argument("branch", nargs="?", default=None)
564
+ p_push.add_argument("max_retries", nargs="?", type=int, default=3)
565
+ p_push.add_argument(
566
+ "--force-with-lease",
567
+ action="store_true",
568
+ dest="force_with_lease",
569
+ help="force-push safely after a rebase (refused on main/master)",
570
+ )
571
+
572
+ p_promote = sub.add_parser("promote", help="mark a draft PR ready for review")
573
+ p_promote.add_argument("pr_number", nargs="?", type=int, default=None)
574
+
575
+ p_finish = sub.add_parser("finish", help="push, promote, monitor_pr --merge")
576
+ p_finish.add_argument("pr_number", nargs="?", type=int, default=None)
577
+ p_finish.add_argument("--review-cycle", type=int, default=None)
578
+
579
+ p_nojira = sub.add_parser(
580
+ "create-pr-nojira",
581
+ help="create a no-issue branch + draft PR",
582
+ )
583
+ p_nojira.add_argument("type", choices=sorted(_VALID_TYPES))
584
+ p_nojira.add_argument("title")
585
+ p_nojira.add_argument("--branch", default=None)
586
+ p_nojira.add_argument("--base", default=None)
587
+
588
+ args = parser.parse_args(argv)
589
+
590
+ if args.cmd == "check":
591
+ return cmd_check(args.node)
592
+ if args.cmd == "guard":
593
+ return cmd_guard()
594
+ if args.cmd == "nodes":
595
+ for node, prereqs in GRAPH.items():
596
+ sys.stdout.write(f"{node}: requires={prereqs or '[]'}\n")
597
+ return 0
598
+ if args.cmd == "push":
599
+ return cmd_push(args.branch, args.max_retries, force=args.force_with_lease)
600
+ if args.cmd == "promote":
601
+ return cmd_promote(args.pr_number)
602
+ if args.cmd == "finish":
603
+ return cmd_finish(args.pr_number, args.review_cycle)
604
+ if args.cmd == "create-pr-nojira":
605
+ return cmd_create_pr_nojira(args.type, args.title, args.branch, args.base)
606
+ return 1
607
+
608
+
609
+ if __name__ == "__main__":
610
+ sys.exit(main())
@@ -0,0 +1,11 @@
1
+ <!--
2
+ MEMORY.md — project-level memory index.
3
+
4
+ One bullet per memory file. Keep lines under ~150 chars.
5
+ Each referenced file has frontmatter: name, description, type (user | feedback | project | reference).
6
+ See SCHEMA.md in this directory for the full convention.
7
+ -->
8
+
9
+ - [Project context](project_context.md) — goals, constraints, key decisions
10
+ - [User role](user_role.md) — developer background and preferences
11
+ - [Conventions](feedback_conventions.md) — agent behavior rules for this project
@@ -0,0 +1,51 @@
1
+ # `.claude/memory/`
2
+
3
+ Small, grep-able, human-readable memory files. Intended for facts an agent should reuse across sessions.
4
+
5
+ See [`SCHEMA.md`](SCHEMA.md) for the authoritative governance rules (types, frontmatter, naming, lint enforcement).
6
+
7
+ ## Convention
8
+
9
+ Every memory file has YAML frontmatter:
10
+
11
+ ```markdown
12
+ ---
13
+ name: <short title>
14
+ description: <one-line summary used for relevance ranking>
15
+ type: user | feedback | project | reference
16
+ ---
17
+
18
+ <body>
19
+ ```
20
+
21
+ ### Types
22
+
23
+ | Type | When to use |
24
+ |---|---|
25
+ | `user` | About the human (role, preferences, expertise). |
26
+ | `feedback` | Rules/corrections the user has given. Include **Why** and **How to apply**. |
27
+ | `project` | Current-state facts about the project (deadlines, decisions, stakeholders). Include **Why** and **How to apply**. |
28
+ | `reference` | Pointers to external systems (GitHub projects, dashboards, channels). |
29
+
30
+ ### What NOT to save
31
+
32
+ - Code patterns/architecture (derivable from the repo)
33
+ - Git history (use `git log`)
34
+ - Ephemeral task state (use TODOs)
35
+ - Anything already in `CLAUDE.md` or `project-init.md`
36
+
37
+ ## Index
38
+
39
+ `MEMORY.md` in this directory is the index — one line per memory file. Keep it tight.
40
+
41
+ ## Why this split exists
42
+
43
+ - `memory/` = small structured facts, agent-curated, fast to grep.
44
+ - `vault/` = human-authored documentation (Obsidian). Larger, richer.
45
+
46
+ ## Graphify (if present)
47
+
48
+ If this project was scaffolded with the `obsidian-graphify` preset,
49
+ `.claude/rules/graphify.md` documents the code knowledge graph: query
50
+ `graphify-out/graph.json` before grepping, rebuilt per commit. One-time
51
+ setup: `.claude/scripts/setup_graphify.sh`.