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.
Files changed (68) hide show
  1. clauderizer/__init__.py +12 -0
  2. clauderizer/__main__.py +4 -0
  3. clauderizer/assets.py +43 -0
  4. clauderizer/cli.py +256 -0
  5. clauderizer/config.py +175 -0
  6. clauderizer/graph/__init__.py +9 -0
  7. clauderizer/graph/cascade.py +127 -0
  8. clauderizer/graph/index.py +90 -0
  9. clauderizer/graph/query.py +78 -0
  10. clauderizer/hook/__init__.py +1 -0
  11. clauderizer/hook/sessionstart.py +36 -0
  12. clauderizer/markdown/__init__.py +10 -0
  13. clauderizer/markdown/frontmatter.py +150 -0
  14. clauderizer/markdown/sections.py +138 -0
  15. clauderizer/markdown/writer.py +100 -0
  16. clauderizer/mcp_server.py +281 -0
  17. clauderizer/model.py +150 -0
  18. clauderizer/mutations.py +404 -0
  19. clauderizer/paths.py +82 -0
  20. clauderizer/profiles/__init__.py +7 -0
  21. clauderizer/profiles/detect.py +105 -0
  22. clauderizer/profiles/generic.toml +16 -0
  23. clauderizer/profiles/go.toml +14 -0
  24. clauderizer/profiles/node.toml +14 -0
  25. clauderizer/profiles/python.toml +14 -0
  26. clauderizer/profiles/ruby.toml +14 -0
  27. clauderizer/rituals/__init__.py +9 -0
  28. clauderizer/rituals/_tables.py +74 -0
  29. clauderizer/rituals/handoff.py +112 -0
  30. clauderizer/rituals/preflight.py +161 -0
  31. clauderizer/rituals/status_bundle.py +117 -0
  32. clauderizer/scaffold/__init__.py +1 -0
  33. clauderizer/scaffold/init.py +218 -0
  34. clauderizer/skills/clauderizer-amend/SKILL.md +21 -0
  35. clauderizer/skills/clauderizer-cascade/SKILL.md +16 -0
  36. clauderizer/skills/clauderizer-close-gameplan/SKILL.md +12 -0
  37. clauderizer/skills/clauderizer-do-phase/SKILL.md +16 -0
  38. clauderizer/skills/clauderizer-new-gameplan/SKILL.md +13 -0
  39. clauderizer/skills/clauderizer-record/SKILL.md +15 -0
  40. clauderizer/templates/GAMEPLAN-PROCEDURE.md +1171 -0
  41. clauderizer/templates/claude_stanza.md +22 -0
  42. clauderizer/templates/docs/ARCHITECTURE.md +13 -0
  43. clauderizer/templates/docs/DATASOURCES.md +8 -0
  44. clauderizer/templates/docs/DECISIONS.md +8 -0
  45. clauderizer/templates/docs/DEPLOYMENT.md +9 -0
  46. clauderizer/templates/docs/ENGINEERING-PRINCIPLES.md +6 -0
  47. clauderizer/templates/docs/GLOSSARY.md +7 -0
  48. clauderizer/templates/docs/HARDENING.md +8 -0
  49. clauderizer/templates/docs/INCIDENTS.md +7 -0
  50. clauderizer/templates/docs/INVARIANTS.md +7 -0
  51. clauderizer/templates/docs/REQUIREMENTS.md +13 -0
  52. clauderizer/templates/docs/SCHEMA.md +7 -0
  53. clauderizer/templates/docs/SECURITY.md +13 -0
  54. clauderizer/templates/docs/TESTING.md +14 -0
  55. clauderizer/templates/docs/VISION.md +13 -0
  56. clauderizer/templates/frontmatter/external-service.md +13 -0
  57. clauderizer/templates/frontmatter/feature.md +15 -0
  58. clauderizer/templates/frontmatter/subsystem.md +14 -0
  59. clauderizer/templates/gameplan/CHAT-HANDOFF-INDEX.md +45 -0
  60. clauderizer/templates/gameplan/GAMEPLAN.md +44 -0
  61. clauderizer/templates/gameplan/PHASE-STATUS.md +18 -0
  62. clauderizer/templates/gameplan/handoff.md +31 -0
  63. clauderizer/tools_list.py +24 -0
  64. clauderizer-0.2.0.dist-info/METADATA +333 -0
  65. clauderizer-0.2.0.dist-info/RECORD +68 -0
  66. clauderizer-0.2.0.dist-info/WHEEL +4 -0
  67. clauderizer-0.2.0.dist-info/entry_points.txt +4 -0
  68. clauderizer-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
+ }