clauderizer 0.2.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.
- clauderizer/__init__.py +12 -0
- clauderizer/__main__.py +4 -0
- clauderizer/assets.py +43 -0
- clauderizer/cli.py +256 -0
- clauderizer/config.py +175 -0
- clauderizer/graph/__init__.py +9 -0
- clauderizer/graph/cascade.py +127 -0
- clauderizer/graph/index.py +90 -0
- clauderizer/graph/query.py +78 -0
- clauderizer/hook/__init__.py +1 -0
- clauderizer/hook/sessionstart.py +36 -0
- clauderizer/markdown/__init__.py +10 -0
- clauderizer/markdown/frontmatter.py +150 -0
- clauderizer/markdown/sections.py +138 -0
- clauderizer/markdown/writer.py +100 -0
- clauderizer/mcp_server.py +281 -0
- clauderizer/model.py +150 -0
- clauderizer/mutations.py +404 -0
- clauderizer/paths.py +82 -0
- clauderizer/profiles/__init__.py +7 -0
- clauderizer/profiles/detect.py +105 -0
- clauderizer/profiles/generic.toml +16 -0
- clauderizer/profiles/go.toml +14 -0
- clauderizer/profiles/node.toml +14 -0
- clauderizer/profiles/python.toml +14 -0
- clauderizer/profiles/ruby.toml +14 -0
- clauderizer/rituals/__init__.py +9 -0
- clauderizer/rituals/_tables.py +74 -0
- clauderizer/rituals/handoff.py +112 -0
- clauderizer/rituals/preflight.py +161 -0
- clauderizer/rituals/status_bundle.py +117 -0
- clauderizer/scaffold/__init__.py +1 -0
- clauderizer/scaffold/init.py +218 -0
- clauderizer/skills/clauderizer-amend/SKILL.md +21 -0
- clauderizer/skills/clauderizer-cascade/SKILL.md +16 -0
- clauderizer/skills/clauderizer-close-gameplan/SKILL.md +12 -0
- clauderizer/skills/clauderizer-do-phase/SKILL.md +16 -0
- clauderizer/skills/clauderizer-new-gameplan/SKILL.md +13 -0
- clauderizer/skills/clauderizer-record/SKILL.md +15 -0
- clauderizer/templates/GAMEPLAN-PROCEDURE.md +1171 -0
- clauderizer/templates/claude_stanza.md +22 -0
- clauderizer/templates/docs/ARCHITECTURE.md +13 -0
- clauderizer/templates/docs/DATASOURCES.md +8 -0
- clauderizer/templates/docs/DECISIONS.md +8 -0
- clauderizer/templates/docs/DEPLOYMENT.md +9 -0
- clauderizer/templates/docs/ENGINEERING-PRINCIPLES.md +6 -0
- clauderizer/templates/docs/GLOSSARY.md +7 -0
- clauderizer/templates/docs/HARDENING.md +8 -0
- clauderizer/templates/docs/INCIDENTS.md +7 -0
- clauderizer/templates/docs/INVARIANTS.md +7 -0
- clauderizer/templates/docs/REQUIREMENTS.md +13 -0
- clauderizer/templates/docs/SCHEMA.md +7 -0
- clauderizer/templates/docs/SECURITY.md +13 -0
- clauderizer/templates/docs/TESTING.md +14 -0
- clauderizer/templates/docs/VISION.md +13 -0
- clauderizer/templates/frontmatter/external-service.md +13 -0
- clauderizer/templates/frontmatter/feature.md +15 -0
- clauderizer/templates/frontmatter/subsystem.md +14 -0
- clauderizer/templates/gameplan/CHAT-HANDOFF-INDEX.md +45 -0
- clauderizer/templates/gameplan/GAMEPLAN.md +44 -0
- clauderizer/templates/gameplan/PHASE-STATUS.md +18 -0
- clauderizer/templates/gameplan/handoff.md +31 -0
- clauderizer/tools_list.py +24 -0
- clauderizer-0.2.0.dist-info/METADATA +333 -0
- clauderizer-0.2.0.dist-info/RECORD +68 -0
- clauderizer-0.2.0.dist-info/WHEEL +4 -0
- clauderizer-0.2.0.dist-info/entry_points.txt +4 -0
- clauderizer-0.2.0.dist-info/licenses/LICENSE +21 -0
clauderizer/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Clauderizer — drop-in, MCP-native working memory for AI agents.
|
|
2
|
+
|
|
3
|
+
Markdown is the source of truth. Everything else (the graph index, the MCP
|
|
4
|
+
server, the rituals) is derived from it and can be rebuilt at any time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
# The version of the gameplan procedure this engine was built against. The
|
|
10
|
+
# engine ships GAMEPLAN-PROCEDURE.md verbatim; `clauderize doctor` warns if a
|
|
11
|
+
# host repo's procedure has drifted to a different MAJOR version.
|
|
12
|
+
PROCEDURE_VERSION = "1.1.0"
|
clauderizer/__main__.py
ADDED
clauderizer/assets.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Access to packaged template / profile / skill assets.
|
|
2
|
+
|
|
3
|
+
Assets ship inside the wheel (see pyproject force-include). At runtime we read
|
|
4
|
+
them straight off disk relative to this module — simple and import-light.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from string import Template
|
|
11
|
+
|
|
12
|
+
_PKG = Path(__file__).parent
|
|
13
|
+
TEMPLATES = _PKG / "templates"
|
|
14
|
+
SKILLS = _PKG / "skills"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def template_text(rel: str) -> str:
|
|
18
|
+
"""Read a template by path relative to the templates dir."""
|
|
19
|
+
return (TEMPLATES / rel).read_text(encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render(rel: str, /, **vars: str) -> str:
|
|
23
|
+
"""Read a ``$placeholder`` template and substitute ``vars``.
|
|
24
|
+
|
|
25
|
+
Uses ``safe_substitute`` so a stray ``$`` in a template never crashes a write.
|
|
26
|
+
"""
|
|
27
|
+
return Template(template_text(rel)).safe_substitute(**vars)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def doc_template(name: str) -> str | None:
|
|
31
|
+
"""Template text for a named living doc (e.g. 'DECISIONS'), or None."""
|
|
32
|
+
p = TEMPLATES / "docs" / f"{name}.md"
|
|
33
|
+
return p.read_text(encoding="utf-8") if p.exists() else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def procedure_text() -> str:
|
|
37
|
+
return template_text("GAMEPLAN-PROCEDURE.md")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def skill_dirs() -> list[Path]:
|
|
41
|
+
if not SKILLS.exists():
|
|
42
|
+
return []
|
|
43
|
+
return [d for d in sorted(SKILLS.iterdir()) if d.is_dir()]
|
clauderizer/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""``clauderize`` — the human/agent command line.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
init drop Clauderizer into the current repo (idempotent)
|
|
5
|
+
status print the current gameplan digest
|
|
6
|
+
reindex rebuild the disposable graph cache from markdown
|
|
7
|
+
doctor verify the install and report drift
|
|
8
|
+
mcp launch the MCP server (stdio)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import PROCEDURE_VERSION, __version__
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .graph import index
|
|
24
|
+
from .paths import find_repo_root, resolve
|
|
25
|
+
from .rituals import status_bundle
|
|
26
|
+
from .scaffold.init import init as run_init
|
|
27
|
+
from .tools_list import TOOL_NAMES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load(root: Path | None = None):
|
|
31
|
+
paths = resolve(find_repo_root(root or Path.cwd()))
|
|
32
|
+
if not paths.config_file.exists():
|
|
33
|
+
return paths, None
|
|
34
|
+
return paths, Config.load(paths.config_file)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
38
|
+
run_cmd = args.run_cmd.split() if args.run_cmd else None
|
|
39
|
+
report = run_init(
|
|
40
|
+
Path(args.path).resolve(),
|
|
41
|
+
size=args.size,
|
|
42
|
+
profile=args.profile,
|
|
43
|
+
gameplan=args.gameplan,
|
|
44
|
+
run_cmd=run_cmd,
|
|
45
|
+
)
|
|
46
|
+
print(f"Clauderized {report.repo}")
|
|
47
|
+
print(f" size={report.size} host profile={report.host_profile}")
|
|
48
|
+
n_changed = len(report.changed)
|
|
49
|
+
print(f" {n_changed} file(s) written/updated, {len(report.actions) - n_changed} kept as-is")
|
|
50
|
+
if args.verbose:
|
|
51
|
+
for a in report.actions:
|
|
52
|
+
print(f" {a}")
|
|
53
|
+
print("\nNext: open a Claude Code session here — the SessionStart hook will show status.")
|
|
54
|
+
print("Or run `clauderize status`.")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
59
|
+
paths, config = _load()
|
|
60
|
+
if config is None:
|
|
61
|
+
print("Not a clauderized repo. Run `clauderize init`.")
|
|
62
|
+
return 1
|
|
63
|
+
bundle = status_bundle.compute(paths, config)
|
|
64
|
+
if args.json:
|
|
65
|
+
print(json.dumps(bundle, indent=2))
|
|
66
|
+
else:
|
|
67
|
+
print(status_bundle.render_digest(bundle, tools=TOOL_NAMES))
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_reindex(args: argparse.Namespace) -> int:
|
|
72
|
+
paths, config = _load()
|
|
73
|
+
if config is None:
|
|
74
|
+
print("Not a clauderized repo. Run `clauderize init`.")
|
|
75
|
+
return 1
|
|
76
|
+
graph = index.build(paths.docs)
|
|
77
|
+
index.write_cache(graph, paths.index_file, paths.docs)
|
|
78
|
+
print(f"Reindexed {len(graph.entities)} entities -> {paths.index_file}")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
83
|
+
paths, config = _load()
|
|
84
|
+
if config is None:
|
|
85
|
+
print("✗ Not a clauderized repo (no .clauderizer/config.toml). Run `clauderize init`.")
|
|
86
|
+
return 1
|
|
87
|
+
ok = True
|
|
88
|
+
|
|
89
|
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
|
90
|
+
nonlocal ok
|
|
91
|
+
mark = "✓" if condition else "✗"
|
|
92
|
+
print(f"{mark} {label}" + (f" — {detail}" if detail and not condition else ""))
|
|
93
|
+
if not condition:
|
|
94
|
+
ok = False
|
|
95
|
+
|
|
96
|
+
check("config.toml present", paths.config_file.exists())
|
|
97
|
+
check("procedure shipped", paths.procedure_file.exists())
|
|
98
|
+
check("CLAUDE.md stanza", _has_marker(paths.claude_md, "clauderizer"))
|
|
99
|
+
check(".mcp.json registers clauderizer", _mcp_registered(paths.mcp_json))
|
|
100
|
+
# Fidelity: registration present is not enough — the command must be launchable.
|
|
101
|
+
mcp_ok, mcp_detail = _command_runnable(_mcp_command(paths.mcp_json))
|
|
102
|
+
check("MCP server command runnable", mcp_ok, mcp_detail)
|
|
103
|
+
settings = paths.root / ".claude" / "settings.json"
|
|
104
|
+
check("SessionStart hook registered", _hook_registered(settings))
|
|
105
|
+
hook_ok, hook_detail = _command_runnable(_hook_command(settings))
|
|
106
|
+
check("SessionStart hook command runnable", hook_ok, hook_detail)
|
|
107
|
+
check("index cache present", paths.index_file.exists())
|
|
108
|
+
# procedure version drift (MAJOR)
|
|
109
|
+
drift = _procedure_drift(paths.procedure_file)
|
|
110
|
+
check("procedure version compatible", drift is None, drift or "")
|
|
111
|
+
if config.active_gameplan:
|
|
112
|
+
gp = paths.gameplan_dir(config.active_gameplan) / "GAMEPLAN.md"
|
|
113
|
+
check(f"active gameplan {config.active_gameplan} on disk", gp.exists())
|
|
114
|
+
print("\nOK" if ok else "\nDrift detected — re-run `clauderize init` to repair.")
|
|
115
|
+
return 0 if ok else 2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
119
|
+
from .mcp_server import main as mcp_main
|
|
120
|
+
|
|
121
|
+
return mcp_main()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- doctor helpers -----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _has_marker(path: Path, name: str) -> bool:
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return False
|
|
130
|
+
text = path.read_text(encoding="utf-8")
|
|
131
|
+
return f"<!-- {name}:start -->" in text
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _mcp_registered(mcp_json: Path) -> bool:
|
|
135
|
+
if not mcp_json.exists():
|
|
136
|
+
return False
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(mcp_json.read_text(encoding="utf-8"))
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
return False
|
|
141
|
+
return "clauderizer" in data.get("mcpServers", {})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _hook_registered(settings: Path) -> bool:
|
|
145
|
+
if not settings.exists():
|
|
146
|
+
return False
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
return False
|
|
151
|
+
for group in data.get("hooks", {}).get("SessionStart", []):
|
|
152
|
+
for h in group.get("hooks", []):
|
|
153
|
+
if "clauderizer-hook" in h.get("command", ""):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _mcp_command(mcp_json: Path) -> list[str] | None:
|
|
159
|
+
if not mcp_json.exists():
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
data = json.loads(mcp_json.read_text(encoding="utf-8"))
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
return None
|
|
165
|
+
entry = data.get("mcpServers", {}).get("clauderizer")
|
|
166
|
+
if not entry:
|
|
167
|
+
return None
|
|
168
|
+
return [entry.get("command", ""), *entry.get("args", [])]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _hook_command(settings: Path) -> list[str] | None:
|
|
172
|
+
if not settings.exists():
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return None
|
|
178
|
+
for group in data.get("hooks", {}).get("SessionStart", []):
|
|
179
|
+
for h in group.get("hooks", []):
|
|
180
|
+
cmd = h.get("command", "")
|
|
181
|
+
if "clauderizer-hook" in cmd:
|
|
182
|
+
return cmd.split()
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _command_runnable(argv: list[str] | None) -> tuple[bool, str]:
|
|
187
|
+
"""Can the configured launch command actually be executed on this machine?
|
|
188
|
+
|
|
189
|
+
Guards against a green health check on a setup that can't launch (e.g. a
|
|
190
|
+
``.mcp.json`` pointing at ``uvx`` that isn't installed, or a dev path that
|
|
191
|
+
doesn't exist) — a check that's green while the server can't start is worse
|
|
192
|
+
than no check.
|
|
193
|
+
"""
|
|
194
|
+
if not argv or not argv[0]:
|
|
195
|
+
return False, "no clauderizer command registered"
|
|
196
|
+
exe = argv[0]
|
|
197
|
+
if shutil.which(exe):
|
|
198
|
+
return True, exe
|
|
199
|
+
p = Path(exe)
|
|
200
|
+
if p.is_file() and os.access(p, os.X_OK):
|
|
201
|
+
return True, str(p)
|
|
202
|
+
return False, f"'{exe}' not found on PATH or not executable"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _procedure_drift(procedure_file: Path) -> str | None:
|
|
206
|
+
if not procedure_file.exists():
|
|
207
|
+
return "procedure file missing"
|
|
208
|
+
m = re.search(r"Procedure version\**:?\s*([0-9]+)\.([0-9]+)\.([0-9]+)",
|
|
209
|
+
procedure_file.read_text(encoding="utf-8"))
|
|
210
|
+
if not m:
|
|
211
|
+
return None
|
|
212
|
+
host_major = int(m.group(1))
|
|
213
|
+
engine_major = int(PROCEDURE_VERSION.split(".")[0])
|
|
214
|
+
if host_major != engine_major:
|
|
215
|
+
return f"host procedure v{m.group(0)} vs engine v{PROCEDURE_VERSION} (MAJOR mismatch)"
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
220
|
+
p = argparse.ArgumentParser(prog="clauderize", description="Drop-in working memory for AI agents.")
|
|
221
|
+
p.add_argument("--version", action="version", version=f"clauderizer {__version__}")
|
|
222
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
223
|
+
|
|
224
|
+
pi = sub.add_parser("init", help="drop Clauderizer into this repo")
|
|
225
|
+
pi.add_argument("path", nargs="?", default=".", help="repo path (default: cwd)")
|
|
226
|
+
pi.add_argument("--size", choices=["pet", "standard", "saas"], default="standard")
|
|
227
|
+
pi.add_argument("--profile", default="auto", help="host language profile (default: auto-detect)")
|
|
228
|
+
pi.add_argument("--gameplan", default=None, help="also create a first gameplan with this name")
|
|
229
|
+
pi.add_argument("--run-cmd", default=None,
|
|
230
|
+
help="how the repo invokes the engine (default: 'uvx --from clauderizer')")
|
|
231
|
+
pi.add_argument("-v", "--verbose", action="store_true")
|
|
232
|
+
pi.set_defaults(func=cmd_init)
|
|
233
|
+
|
|
234
|
+
ps = sub.add_parser("status", help="print current gameplan status")
|
|
235
|
+
ps.add_argument("--json", action="store_true")
|
|
236
|
+
ps.set_defaults(func=cmd_status)
|
|
237
|
+
|
|
238
|
+
pr = sub.add_parser("reindex", help="rebuild the graph cache from markdown")
|
|
239
|
+
pr.set_defaults(func=cmd_reindex)
|
|
240
|
+
|
|
241
|
+
pd = sub.add_parser("doctor", help="verify the install and report drift")
|
|
242
|
+
pd.set_defaults(func=cmd_doctor)
|
|
243
|
+
|
|
244
|
+
pm = sub.add_parser("mcp", help="launch the MCP server (stdio)")
|
|
245
|
+
pm.set_defaults(func=cmd_mcp)
|
|
246
|
+
return p
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv: list[str] | None = None) -> int:
|
|
250
|
+
parser = build_parser()
|
|
251
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
252
|
+
return args.func(args)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
raise SystemExit(main())
|
clauderizer/config.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Load and write ``.clauderizer/config.toml`` — the size/profile dial.
|
|
2
|
+
|
|
3
|
+
Reading uses stdlib ``tomllib``. Writing uses a tiny emitter (stdlib has no
|
|
4
|
+
TOML writer) that covers exactly the shapes we store: tables of strings, bools,
|
|
5
|
+
and lists of strings. Keeping the config in TOML avoids YAML's ambiguity for
|
|
6
|
+
machine-edited settings; frontmatter inside docs stays YAML per the procedure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
CONFIG_VERSION = "1"
|
|
17
|
+
|
|
18
|
+
# Default module/ritual manifests per size. Mirrors the procedure's sizing
|
|
19
|
+
# matrix, but as a real dial instead of prose advice.
|
|
20
|
+
SIZE_MANIFESTS: dict[str, dict[str, Any]] = {
|
|
21
|
+
"pet": {
|
|
22
|
+
"modules": ["VISION"],
|
|
23
|
+
"rituals": {"preflight": True, "cascade": False, "amendments": False},
|
|
24
|
+
"preflight_checks": ["clean_tree", "tests"],
|
|
25
|
+
},
|
|
26
|
+
"standard": {
|
|
27
|
+
"modules": [
|
|
28
|
+
"VISION",
|
|
29
|
+
"ARCHITECTURE",
|
|
30
|
+
"DECISIONS",
|
|
31
|
+
"INVARIANTS",
|
|
32
|
+
"TESTING",
|
|
33
|
+
"HARDENING",
|
|
34
|
+
],
|
|
35
|
+
"rituals": {"preflight": True, "cascade": True, "amendments": False},
|
|
36
|
+
"preflight_checks": [
|
|
37
|
+
"branch_base",
|
|
38
|
+
"clean_tree",
|
|
39
|
+
"tests",
|
|
40
|
+
"build",
|
|
41
|
+
"deps_spotcheck",
|
|
42
|
+
"branch_creation",
|
|
43
|
+
"cascade_hygiene",
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
"saas": {
|
|
47
|
+
"modules": [
|
|
48
|
+
"VISION",
|
|
49
|
+
"REQUIREMENTS",
|
|
50
|
+
"ARCHITECTURE",
|
|
51
|
+
"ENGINEERING-PRINCIPLES",
|
|
52
|
+
"DEPLOYMENT",
|
|
53
|
+
"DATASOURCES",
|
|
54
|
+
"SCHEMA",
|
|
55
|
+
"SECURITY",
|
|
56
|
+
"TESTING",
|
|
57
|
+
"HARDENING",
|
|
58
|
+
"INCIDENTS",
|
|
59
|
+
"DECISIONS",
|
|
60
|
+
"INVARIANTS",
|
|
61
|
+
"GLOSSARY",
|
|
62
|
+
],
|
|
63
|
+
"rituals": {"preflight": True, "cascade": True, "amendments": True},
|
|
64
|
+
"preflight_checks": [
|
|
65
|
+
"branch_base",
|
|
66
|
+
"clean_tree",
|
|
67
|
+
"tests",
|
|
68
|
+
"build",
|
|
69
|
+
"deps_spotcheck",
|
|
70
|
+
"branch_creation",
|
|
71
|
+
"cascade_hygiene",
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Config:
|
|
79
|
+
version: str = CONFIG_VERSION
|
|
80
|
+
size: str = "standard"
|
|
81
|
+
host_profile: str = "generic"
|
|
82
|
+
docs: str = "docs"
|
|
83
|
+
gameplans: str = "docs/gameplans"
|
|
84
|
+
modules: list[str] = field(default_factory=list)
|
|
85
|
+
rituals: dict[str, bool] = field(default_factory=dict)
|
|
86
|
+
preflight_checks: list[str] = field(default_factory=list)
|
|
87
|
+
active_gameplan: str | None = None
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def for_size(cls, size: str, host_profile: str = "generic") -> "Config":
|
|
91
|
+
manifest = SIZE_MANIFESTS.get(size, SIZE_MANIFESTS["standard"])
|
|
92
|
+
return cls(
|
|
93
|
+
size=size,
|
|
94
|
+
host_profile=host_profile,
|
|
95
|
+
modules=list(manifest["modules"]),
|
|
96
|
+
rituals=dict(manifest["rituals"]),
|
|
97
|
+
preflight_checks=list(manifest["preflight_checks"]),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def ritual_enabled(self, name: str) -> bool:
|
|
101
|
+
return bool(self.rituals.get(name, False))
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def load(cls, path: Path) -> "Config":
|
|
105
|
+
with path.open("rb") as fh:
|
|
106
|
+
raw = tomllib.load(fh)
|
|
107
|
+
cz = raw.get("clauderizer", {})
|
|
108
|
+
host = raw.get("host", {})
|
|
109
|
+
paths = raw.get("paths", {})
|
|
110
|
+
modules = raw.get("modules", {})
|
|
111
|
+
rituals = raw.get("rituals", {})
|
|
112
|
+
active = raw.get("active_gameplan", {})
|
|
113
|
+
return cls(
|
|
114
|
+
version=str(cz.get("version", CONFIG_VERSION)),
|
|
115
|
+
size=str(cz.get("size", "standard")),
|
|
116
|
+
host_profile=str(host.get("profile", "generic")),
|
|
117
|
+
docs=str(paths.get("docs", "docs")),
|
|
118
|
+
gameplans=str(paths.get("gameplans", "docs/gameplans")),
|
|
119
|
+
modules=list(modules.get("enabled", [])),
|
|
120
|
+
rituals={k: bool(v) for k, v in rituals.items()},
|
|
121
|
+
preflight_checks=list(cz.get("preflight_checks", [])),
|
|
122
|
+
active_gameplan=(active.get("id") or None),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def to_toml(self) -> str:
|
|
126
|
+
lines = [
|
|
127
|
+
"[clauderizer]",
|
|
128
|
+
f'version = "{self.version}"',
|
|
129
|
+
f'size = "{self.size}"',
|
|
130
|
+
_toml_kv("preflight_checks", self.preflight_checks),
|
|
131
|
+
"",
|
|
132
|
+
"[host]",
|
|
133
|
+
f'profile = "{self.host_profile}"',
|
|
134
|
+
"",
|
|
135
|
+
"[paths]",
|
|
136
|
+
f'docs = "{self.docs}"',
|
|
137
|
+
f'gameplans = "{self.gameplans}"',
|
|
138
|
+
"",
|
|
139
|
+
"[modules]",
|
|
140
|
+
_toml_kv("enabled", self.modules),
|
|
141
|
+
"",
|
|
142
|
+
"[rituals]",
|
|
143
|
+
]
|
|
144
|
+
for k, v in self.rituals.items():
|
|
145
|
+
lines.append(f"{k} = {'true' if v else 'false'}")
|
|
146
|
+
lines += ["", "[active_gameplan]", f'id = "{self.active_gameplan or ""}"', ""]
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _toml_kv(key: str, value: Any) -> str:
|
|
151
|
+
if isinstance(value, list):
|
|
152
|
+
inner = ", ".join(f'"{v}"' for v in value)
|
|
153
|
+
return f"{key} = [{inner}]"
|
|
154
|
+
if isinstance(value, bool):
|
|
155
|
+
return f"{key} = {'true' if value else 'false'}"
|
|
156
|
+
return f'{key} = "{value}"'
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def merge_missing(existing: Config, defaults: Config) -> Config:
|
|
160
|
+
"""Return ``existing`` with any empty fields filled from ``defaults``.
|
|
161
|
+
|
|
162
|
+
Used by ``init`` re-runs so user edits are never overwritten — only gaps are
|
|
163
|
+
filled in.
|
|
164
|
+
"""
|
|
165
|
+
return Config(
|
|
166
|
+
version=existing.version or defaults.version,
|
|
167
|
+
size=existing.size or defaults.size,
|
|
168
|
+
host_profile=existing.host_profile or defaults.host_profile,
|
|
169
|
+
docs=existing.docs or defaults.docs,
|
|
170
|
+
gameplans=existing.gameplans or defaults.gameplans,
|
|
171
|
+
modules=existing.modules or defaults.modules,
|
|
172
|
+
rituals=existing.rituals or defaults.rituals,
|
|
173
|
+
preflight_checks=existing.preflight_checks or defaults.preflight_checks,
|
|
174
|
+
active_gameplan=existing.active_gameplan or defaults.active_gameplan,
|
|
175
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The Project DAG: a graph of frontmatter-tracked entities.
|
|
2
|
+
|
|
3
|
+
- ``index`` scans the docs tree into an in-memory graph (cached to index.json).
|
|
4
|
+
- ``query`` answers lookup / dependents / dependencies / pin-violation questions.
|
|
5
|
+
- ``cascade`` does the post-hoc forward walk and renders a cascade report.
|
|
6
|
+
|
|
7
|
+
The cache is never authoritative. If it is stale or missing it is rebuilt from
|
|
8
|
+
the markdown — markdown always wins.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Post-hoc cascade: walk the DAG forward after a change, render a report.
|
|
2
|
+
|
|
3
|
+
Per the procedure, cascade is *judgment-based*: this engine finds and reports
|
|
4
|
+
the dependents that *might* be affected and marks each "needs review". It does
|
|
5
|
+
not pretend to decide whether each dependent is truly affected — that's the
|
|
6
|
+
agent's call, filled into the report. This is the deliberate division of labor
|
|
7
|
+
that keeps cascade honest rather than faking automation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import query
|
|
16
|
+
from .index import Graph
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_report(
|
|
20
|
+
graph: Graph,
|
|
21
|
+
entity_id: str,
|
|
22
|
+
transition: str,
|
|
23
|
+
*,
|
|
24
|
+
now: datetime | None = None,
|
|
25
|
+
phase: str | None = None,
|
|
26
|
+
) -> str:
|
|
27
|
+
now = now or datetime.now(timezone.utc)
|
|
28
|
+
ts = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
29
|
+
direct = query.dependents(graph, entity_id)
|
|
30
|
+
transitive = [d for d in query.transitive_dependents(graph, entity_id) if d not in direct]
|
|
31
|
+
violations = [v for v in query.pin_violations(graph) if v.target == entity_id]
|
|
32
|
+
|
|
33
|
+
lines = [
|
|
34
|
+
f"# Cascade Report: {entity_id} {transition}",
|
|
35
|
+
"",
|
|
36
|
+
f"> Generated: {ts}",
|
|
37
|
+
]
|
|
38
|
+
if phase:
|
|
39
|
+
lines.append(f"> Phase: {phase}")
|
|
40
|
+
lines += [
|
|
41
|
+
"",
|
|
42
|
+
"## Trigger",
|
|
43
|
+
"",
|
|
44
|
+
f"`{entity_id}` — {transition}",
|
|
45
|
+
"",
|
|
46
|
+
"## Affected entities",
|
|
47
|
+
"",
|
|
48
|
+
"### Direct dependents",
|
|
49
|
+
"",
|
|
50
|
+
]
|
|
51
|
+
if direct:
|
|
52
|
+
for d in direct:
|
|
53
|
+
ent = graph.get(d)
|
|
54
|
+
status = ent.status if ent else "?"
|
|
55
|
+
lines.append(f"- **{d}** (status: {status}) — checked: _needs review_")
|
|
56
|
+
else:
|
|
57
|
+
lines.append("- _(none)_")
|
|
58
|
+
lines += ["", "### Transitive dependents", ""]
|
|
59
|
+
if transitive:
|
|
60
|
+
for d in transitive:
|
|
61
|
+
lines.append(f"- **{d}** — flagged via upstream; checked: _needs review_")
|
|
62
|
+
else:
|
|
63
|
+
lines.append("- _(none)_")
|
|
64
|
+
|
|
65
|
+
if violations:
|
|
66
|
+
lines += ["", "### Semver pin violations", ""]
|
|
67
|
+
for v in violations:
|
|
68
|
+
lines.append(f"- **{v.dependent}** pins `{v.constraint}` — {v.reason}")
|
|
69
|
+
|
|
70
|
+
lines += [
|
|
71
|
+
"",
|
|
72
|
+
"## Updates applied",
|
|
73
|
+
"",
|
|
74
|
+
"_(fill in concrete edits made to each affected entity, or 'no change needed')_",
|
|
75
|
+
"",
|
|
76
|
+
"## Updates deferred",
|
|
77
|
+
"",
|
|
78
|
+
"_(anything flagged but not yet acted on, with reason + follow-up)_",
|
|
79
|
+
"",
|
|
80
|
+
]
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def report_filename(entity_id: str, now: datetime | None = None) -> str:
|
|
85
|
+
now = now or datetime.now(timezone.utc)
|
|
86
|
+
safe = entity_id.replace("/", "-").replace(" ", "-")
|
|
87
|
+
return f"{now.strftime('%Y-%m-%d')}-{safe}.md"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run(
|
|
91
|
+
graph: Graph,
|
|
92
|
+
entity_id: str,
|
|
93
|
+
transition: str,
|
|
94
|
+
reports_dir: Path,
|
|
95
|
+
*,
|
|
96
|
+
dry_run: bool = False,
|
|
97
|
+
now: datetime | None = None,
|
|
98
|
+
phase: str | None = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""Render and (unless ``dry_run``) write a cascade report.
|
|
101
|
+
|
|
102
|
+
Returns a structured result describing what was found and where it went.
|
|
103
|
+
"""
|
|
104
|
+
now = now or datetime.now(timezone.utc)
|
|
105
|
+
report_md = render_report(graph, entity_id, transition, now=now, phase=phase)
|
|
106
|
+
direct = query.dependents(graph, entity_id)
|
|
107
|
+
transitive = [d for d in query.transitive_dependents(graph, entity_id) if d not in direct]
|
|
108
|
+
path = reports_dir / report_filename(entity_id, now)
|
|
109
|
+
written = False
|
|
110
|
+
if not dry_run:
|
|
111
|
+
reports_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
path.write_text(report_md, encoding="utf-8")
|
|
113
|
+
written = True
|
|
114
|
+
return {
|
|
115
|
+
"ok": True,
|
|
116
|
+
"entity_id": entity_id,
|
|
117
|
+
"transition": transition,
|
|
118
|
+
"direct": direct,
|
|
119
|
+
"transitive": transitive,
|
|
120
|
+
"report_md": report_md,
|
|
121
|
+
"report_path": str(path),
|
|
122
|
+
"written": written,
|
|
123
|
+
"summary": (
|
|
124
|
+
f"cascade {entity_id} {transition}: "
|
|
125
|
+
f"{len(direct)} direct, {len(transitive)} transitive dependents"
|
|
126
|
+
),
|
|
127
|
+
}
|