cli-enforcement 0.1.0__tar.gz

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 (43) hide show
  1. cli_enforcement-0.1.0/.gitignore +6 -0
  2. cli_enforcement-0.1.0/LICENSE +21 -0
  3. cli_enforcement-0.1.0/PKG-INFO +76 -0
  4. cli_enforcement-0.1.0/README.md +43 -0
  5. cli_enforcement-0.1.0/pyproject.toml +24 -0
  6. cli_enforcement-0.1.0/src/cli_enforcement/__init__.py +10 -0
  7. cli_enforcement-0.1.0/src/cli_enforcement/cli.py +58 -0
  8. cli_enforcement-0.1.0/src/cli_enforcement/deploy.py +393 -0
  9. cli_enforcement-0.1.0/src/cli_enforcement/engine/__init__.py +0 -0
  10. cli_enforcement-0.1.0/src/cli_enforcement/engine/achievements.py +215 -0
  11. cli_enforcement-0.1.0/src/cli_enforcement/engine/bash_gate.py +456 -0
  12. cli_enforcement-0.1.0/src/cli_enforcement/engine/cascade_manager.py +745 -0
  13. cli_enforcement-0.1.0/src/cli_enforcement/engine/check_dismissal.py +182 -0
  14. cli_enforcement-0.1.0/src/cli_enforcement/engine/config_change.py +104 -0
  15. cli_enforcement-0.1.0/src/cli_enforcement/engine/enforce_check.py +645 -0
  16. cli_enforcement-0.1.0/src/cli_enforcement/engine/enforcement_cli.py +470 -0
  17. cli_enforcement-0.1.0/src/cli_enforcement/engine/enforcement_logger.py +209 -0
  18. cli_enforcement-0.1.0/src/cli_enforcement/engine/enforcement_unlock.py +171 -0
  19. cli_enforcement-0.1.0/src/cli_enforcement/engine/flush_reads.py +56 -0
  20. cli_enforcement-0.1.0/src/cli_enforcement/engine/instructions_loaded.py +92 -0
  21. cli_enforcement-0.1.0/src/cli_enforcement/engine/integrity_check.py +293 -0
  22. cli_enforcement-0.1.0/src/cli_enforcement/engine/message_generator.py +334 -0
  23. cli_enforcement-0.1.0/src/cli_enforcement/engine/message_generator_v2.py +150 -0
  24. cli_enforcement-0.1.0/src/cli_enforcement/engine/permission_check.py +137 -0
  25. cli_enforcement-0.1.0/src/cli_enforcement/engine/points_config.yaml +40 -0
  26. cli_enforcement-0.1.0/src/cli_enforcement/engine/post_bash_check.py +236 -0
  27. cli_enforcement-0.1.0/src/cli_enforcement/engine/post_compact.py +110 -0
  28. cli_enforcement-0.1.0/src/cli_enforcement/engine/post_edit_validate.py +543 -0
  29. cli_enforcement-0.1.0/src/cli_enforcement/engine/pre_compact.py +83 -0
  30. cli_enforcement-0.1.0/src/cli_enforcement/engine/project_integrity.py +217 -0
  31. cli_enforcement-0.1.0/src/cli_enforcement/engine/record_edit.py +205 -0
  32. cli_enforcement-0.1.0/src/cli_enforcement/engine/record_read.py +403 -0
  33. cli_enforcement-0.1.0/src/cli_enforcement/engine/session_end.py +278 -0
  34. cli_enforcement-0.1.0/src/cli_enforcement/engine/session_init.py +238 -0
  35. cli_enforcement-0.1.0/src/cli_enforcement/engine/state_manager.py +2250 -0
  36. cli_enforcement-0.1.0/src/cli_enforcement/engine/stop_check.py +233 -0
  37. cli_enforcement-0.1.0/src/cli_enforcement/engine/stop_failure.py +57 -0
  38. cli_enforcement-0.1.0/src/cli_enforcement/engine/subagent_start.py +90 -0
  39. cli_enforcement-0.1.0/src/cli_enforcement/engine/subagent_stop.py +106 -0
  40. cli_enforcement-0.1.0/src/cli_enforcement/engine/task_completed.py +53 -0
  41. cli_enforcement-0.1.0/src/cli_enforcement/engine/tool_failure.py +97 -0
  42. cli_enforcement-0.1.0/src/cli_enforcement/engine/workflow_server.py +1820 -0
  43. cli_enforcement-0.1.0/src/cli_enforcement/fleet.py +125 -0
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Sorrell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-enforcement
3
+ Version: 0.1.0
4
+ Summary: Model-agnostic, hook-level behavioral enforcement engine for AI coding agents. Deploys onto any model using cli-wikia's per-model knowledge.
5
+ Project-URL: Homepage, https://github.com/Alexander-Sorrell-IT/CLI-Enforcement
6
+ Author-email: Alexander Sorrell <codehunterextreme@gmail.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Alexander Sorrell
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: agent,ai,claude,copilot,deepseek,enforcement,gemini,hooks
30
+ Requires-Python: >=3.8
31
+ Requires-Dist: cli-wikia>=0.10.0
32
+ Description-Content-Type: text/markdown
33
+
34
+ # CLI Enforcement
35
+
36
+ Model-agnostic, **hook-level behavioral enforcement** for AI coding agents. The
37
+ enforcement engine (state manager, points/tiers, anti-hallucination, sandbox,
38
+ cascade) is platform-neutral; *where and how* it installs onto a given model is
39
+ supplied dynamically by **[cli-wikia](https://pypi.org/project/cli-wikia/)** —
40
+ so one engine deploys onto Claude, Gemini, Copilot, DeepSeek, Antigravity, …
41
+ with no per-platform template.
42
+
43
+ ## How it works
44
+
45
+ ```
46
+ cli-enforcement deploy <model>
47
+
48
+ ├─ asks cli-wikia: config_root? hook_events? instruction_file?
49
+
50
+ ├─ maps enforcement STAGES (pre-tool, post-tool, stop, …)
51
+ │ to that model's REAL event names (PreToolUse / BeforeTool / …)
52
+
53
+ └─ copies the engine into <config_root>/mcp/ and writes the hook registry
54
+ ```
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install cli-enforcement # pulls in cli-wikia
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ cli-enforcement deploy claude # dry-run: show the plan
66
+ cli-enforcement deploy claude --write # actually install into ./.claude/
67
+ cli-enforcement deploy gemini --write # same engine, wired to Gemini's events
68
+ cli-enforcement status # what's deployed here
69
+ cli-enforcement remove claude --write # remove the engine
70
+ ```
71
+
72
+ Dry-run by default — nothing is written without `--write`.
73
+
74
+ ## License
75
+
76
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,43 @@
1
+ # CLI Enforcement
2
+
3
+ Model-agnostic, **hook-level behavioral enforcement** for AI coding agents. The
4
+ enforcement engine (state manager, points/tiers, anti-hallucination, sandbox,
5
+ cascade) is platform-neutral; *where and how* it installs onto a given model is
6
+ supplied dynamically by **[cli-wikia](https://pypi.org/project/cli-wikia/)** —
7
+ so one engine deploys onto Claude, Gemini, Copilot, DeepSeek, Antigravity, …
8
+ with no per-platform template.
9
+
10
+ ## How it works
11
+
12
+ ```
13
+ cli-enforcement deploy <model>
14
+
15
+ ├─ asks cli-wikia: config_root? hook_events? instruction_file?
16
+
17
+ ├─ maps enforcement STAGES (pre-tool, post-tool, stop, …)
18
+ │ to that model's REAL event names (PreToolUse / BeforeTool / …)
19
+
20
+ └─ copies the engine into <config_root>/mcp/ and writes the hook registry
21
+ ```
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install cli-enforcement # pulls in cli-wikia
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ cli-enforcement deploy claude # dry-run: show the plan
33
+ cli-enforcement deploy claude --write # actually install into ./.claude/
34
+ cli-enforcement deploy gemini --write # same engine, wired to Gemini's events
35
+ cli-enforcement status # what's deployed here
36
+ cli-enforcement remove claude --write # remove the engine
37
+ ```
38
+
39
+ Dry-run by default — nothing is written without `--write`.
40
+
41
+ ## License
42
+
43
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cli-enforcement"
7
+ version = "0.1.0"
8
+ description = "Model-agnostic, hook-level behavioral enforcement engine for AI coding agents. Deploys onto any model using cli-wikia's per-model knowledge."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Alexander Sorrell", email = "codehunterextreme@gmail.com" }]
13
+ keywords = ["ai", "enforcement", "hooks", "claude", "gemini", "copilot", "deepseek", "agent"]
14
+ dependencies = ["cli-wikia>=0.10.0"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/Alexander-Sorrell-IT/CLI-Enforcement"
18
+
19
+ [project.scripts]
20
+ cli-enforcement = "cli_enforcement.cli:main"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/cli_enforcement"]
24
+ artifacts = ["src/cli_enforcement/engine/*.py", "src/cli_enforcement/engine/*.yaml"]
@@ -0,0 +1,10 @@
1
+ """cli-enforcement: model-agnostic hook-level enforcement for AI coding agents.
2
+
3
+ The enforcement *engine* (state manager, points/tiers, the hook scripts) is
4
+ platform-neutral. WHERE and HOW it deploys onto a given model — config dir, hook
5
+ event names, instructions file — is supplied dynamically by cli-wikia. So one
6
+ engine deploys onto Claude, Gemini, Copilot, DeepSeek, Antigravity, … with no
7
+ per-platform template.
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,58 @@
1
+ """cli-enforcement command. Deploy the enforcement engine onto any model."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+ from . import __version__
7
+ from . import deploy as D
8
+
9
+
10
+ def build_parser():
11
+ p = argparse.ArgumentParser(
12
+ prog="cli-enforcement",
13
+ description="Model-agnostic hook-level enforcement for AI coding agents "
14
+ "(deploys onto any model via cli-wikia).",
15
+ )
16
+ p.add_argument("--version", action="version", version=f"cli-enforcement {__version__}")
17
+ sub = p.add_subparsers(dest="cmd", required=True)
18
+
19
+ s = sub.add_parser("deploy", help="deploy the enforcement engine for a model (dry-run unless --write)")
20
+ s.add_argument("model", help="target model (claude, gemini, copilot, deepseek, …)")
21
+ s.add_argument("--dir", help="project directory to deploy into (default: current dir)")
22
+ s.add_argument("--write", action="store_true", help="actually write files (default: dry-run)")
23
+ s.set_defaults(func=D.cmd_deploy)
24
+
25
+ s = sub.add_parser("sync", help="re-apply a model after a wikia update (gates/wiring change, points engine stays constant)")
26
+ s.add_argument("model")
27
+ s.add_argument("--dir")
28
+ s.add_argument("--write", action="store_true", help="apply the changes")
29
+ s.set_defaults(func=D.cmd_sync)
30
+
31
+ s = sub.add_parser("status", help="show which models have the engine deployed here")
32
+ s.add_argument("--dir")
33
+ s.set_defaults(func=D.cmd_status)
34
+
35
+ from . import fleet as F
36
+
37
+ s = sub.add_parser("fleet", help="wire enforcement into a fleetcode config + make it hardware-aware")
38
+ s.add_argument("config", help="path to a fleetcode config.json")
39
+ s.add_argument("--per-team-points", action="store_true", help="isolate points per team (default: shared 'we' pool)")
40
+ s.add_argument("--write", action="store_true", help="write config + deploy into existing team dirs")
41
+ s.set_defaults(func=F.cmd_fleet)
42
+
43
+ s = sub.add_parser("remove", help="remove the deployed engine (dry-run unless --write)")
44
+ s.add_argument("model")
45
+ s.add_argument("--dir")
46
+ s.add_argument("--write", action="store_true")
47
+ s.set_defaults(func=D.cmd_remove)
48
+
49
+ return p
50
+
51
+
52
+ def main(argv=None):
53
+ args = build_parser().parse_args(argv)
54
+ args.func(args)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,393 @@
1
+ """The dynamic deployer.
2
+
3
+ It does NOT hardcode per-platform templates. It carries one platform-neutral
4
+ enforcement wiring (STAGES — which engine script runs at which lifecycle stage,
5
+ taken verbatim from the canonical Claude template) and, for whatever model you
6
+ target, asks cli-wikia for that model's real facts:
7
+
8
+ - config_root e.g. .claude / .gemini / .github (where files go)
9
+ - hook_events e.g. PreToolUse / BeforeTool / … (the real event names)
10
+ - instruction_file e.g. CLAUDE.md / GEMINI.md / AGENTS.md
11
+
12
+ then maps each enforcement STAGE to the matching real event name and writes the
13
+ hook registry + copies the engine in. Dry-run by default.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import sys
22
+ from importlib import resources
23
+
24
+ # Canonical enforcement wiring: stage -> engine scripts. Lifted directly from the
25
+ # real Claude template's settings.json (the proven mapping), expressed as
26
+ # platform-neutral STAGES instead of one platform's event names.
27
+ # (stage, canonical_event, scripts). canonical_event is the exact Claude event
28
+ # name; for other platforms it's matched semantically via STAGE_PATTERNS.
29
+ STAGES = [
30
+ ("session_start", "SessionStart", ["session_init.py"]),
31
+ ("user_prompt", "UserPromptSubmit", ["flush_reads.py", "enforcement_unlock.py", "check_dismissal.py"]),
32
+ ("pre_tool", "PreToolUse", ["enforce_check.py", "bash_gate.py"]),
33
+ ("post_tool", "PostToolUse", ["record_read.py", "record_edit.py", "post_bash_check.py"]),
34
+ ("tool_failure", "PostToolUseFailure", ["tool_failure.py"]),
35
+ ("stop", "Stop", ["stop_check.py"]),
36
+ ("session_end", "SessionEnd", ["session_end.py"]),
37
+ ("permission", "PermissionRequest", ["permission_check.py"]),
38
+ ("subagent_start", "SubagentStart", ["subagent_start.py"]),
39
+ ("subagent_stop", "SubagentStop", ["subagent_stop.py"]),
40
+ ("pre_compact", "PreCompact", ["pre_compact.py"]),
41
+ ("post_compact", "PostCompact", ["post_compact.py"]),
42
+ ("instructions_loaded", "InstructionsLoaded", ["instructions_loaded.py"]),
43
+ ("config_change", "ConfigChange", ["config_change.py"]),
44
+ ("stop_failure", "StopFailure", ["stop_failure.py"]),
45
+ ("task_completed", "TaskCompleted", ["task_completed.py"]),
46
+ ]
47
+
48
+ # How to recognize each stage among a platform's real event names (case-insensitive).
49
+ STAGE_PATTERNS = {
50
+ "session_start": r"session.*start|^start",
51
+ "user_prompt": r"prompt|before.*agent|user.*submit",
52
+ "pre_tool": r"(pre|before).?tool(?!.*select)",
53
+ "post_tool": r"(post|after).?tool",
54
+ "tool_failure": r"tool.*fail",
55
+ "stop": r"^stop$|after.*agent|turn.*end",
56
+ "session_end": r"session.*end",
57
+ "permission": r"permission",
58
+ "subagent_start": r"subagent.*start",
59
+ "subagent_stop": r"subagent.*stop",
60
+ "pre_compact": r"(pre).?(compact|compress)",
61
+ "post_compact": r"(post).?(compact|compress)",
62
+ "instructions_loaded": r"instruction",
63
+ "config_change": r"config.*change",
64
+ "stop_failure": r"stop.*fail",
65
+ "task_completed": r"task.*complet",
66
+ }
67
+
68
+
69
+ def _wikia():
70
+ """cli-wikia is the knowledge layer; import lazily so errors are clear."""
71
+ try:
72
+ from cli_wikia import hooks as H
73
+
74
+ return H
75
+ except ImportError:
76
+ sys.exit("cli-enforcement needs cli-wikia installed (pip install cli-wikia).")
77
+
78
+
79
+ def engine_files():
80
+ """The vendored, platform-neutral engine scripts shipped in this package."""
81
+ root = resources.files("cli_enforcement") / "engine"
82
+ return [p for p in root.iterdir() if p.name.endswith(".py") and p.name != "__init__.py"]
83
+
84
+
85
+ def points_config_text():
86
+ """The CONSTANT points engine config (same for every model)."""
87
+ return (resources.files("cli_enforcement") / "engine" / "points_config.yaml").read_text(
88
+ encoding="utf-8"
89
+ )
90
+
91
+
92
+ def build_kb_gate_components(model, topics, root):
93
+ """The DYNAMIC application: turn the model's cli-wikia topics into KB-gate
94
+ components. Each topic the AI must understand (by reading the wikia page)
95
+ before editing this model's config. New wikia pages -> new gates, so the
96
+ enforcement application tracks the model's features automatically.
97
+
98
+ The points ENGINE is unchanged; only this mapping changes per model."""
99
+ comps = []
100
+ for t in topics:
101
+ cid = "kb_" + re.sub(r"[^a-z0-9]+", "_", t.lower()).strip("_")
102
+ comps.append({
103
+ "id": cid,
104
+ "topic": t,
105
+ "display_name": f"{model}: {t}",
106
+ "understanding": f"wikia read {model} {t}",
107
+ })
108
+ return comps
109
+
110
+
111
+ def render_project_config(model, root, comps):
112
+ """Render project_config.yaml text (KB gates from wikia). Hand-rendered to
113
+ keep cli-enforcement dependency-light; matches the engine's expected schema."""
114
+ lines = [
115
+ f"# GENERATED by cli-enforcement from cli-wikia's knowledge of '{model}'.",
116
+ "# The points ENGINE (points_config.yaml) is constant; these KB-gate",
117
+ "# components are the per-model APPLICATION and are re-derived on `sync`.",
118
+ "project:",
119
+ f' name: "{model} (enforced)"',
120
+ ' subdirectory: ""',
121
+ f' description: "Enforcement applied to {model}; gates derived from cli-wikia."',
122
+ "",
123
+ "# KB gates: the AI must read each wikia page (understanding) before it may",
124
+ f"# edit files under {root}/ that rely on that {model} feature.",
125
+ "components:",
126
+ ]
127
+ for c in comps:
128
+ lines += [
129
+ f' - id: {c["id"]}',
130
+ f' file_pattern: "{root}/**"',
131
+ f' display_name: "{c["display_name"]}"',
132
+ f' description: "Read & understand: {c["understanding"]}"',
133
+ ]
134
+ lines.append("")
135
+ return "\n".join(lines)
136
+
137
+
138
+ def map_stages_to_events(events):
139
+ """Match each enforcement stage to one of the platform's real event names.
140
+ Prefers the exact canonical event when the platform has it (so Claude stays
141
+ verbatim), falling back to semantic matching otherwise (Gemini, etc.).
142
+ Returns {stage: event_name} and the list of stages with no matching event."""
143
+ eset = set(events)
144
+ mapping, unmatched = {}, []
145
+ for stage, canonical, _scripts in STAGES:
146
+ if canonical in eset:
147
+ mapping[stage] = canonical
148
+ continue
149
+ pat = re.compile(STAGE_PATTERNS[stage], re.I)
150
+ hit = next((e for e in events if pat.search(e)), None)
151
+ if hit:
152
+ mapping[stage] = hit
153
+ else:
154
+ unmatched.append(stage)
155
+ return mapping, unmatched
156
+
157
+
158
+ def build_registry(model, root, events):
159
+ """Build the platform's settings.json `hooks` block by wiring each matched
160
+ stage's engine scripts to that platform's real event name."""
161
+ mapping, unmatched = map_stages_to_events(events)
162
+ scripts_by_stage = {stage: scr for stage, _canon, scr in STAGES}
163
+ # Claude hooks resolve via $CLAUDE_PROJECT_DIR; other tools run hooks from the
164
+ # project root, so a path relative to it works and the engine resolves the
165
+ # rest from cwd / $ENFORCEMENT_PROJECT_DIR.
166
+ prefix = "$CLAUDE_PROJECT_DIR/" if model == "claude" else ""
167
+ hooks = {}
168
+ for stage, event in mapping.items():
169
+ handlers = [
170
+ {"type": "command", "command": f'python3 "{prefix}{root}/mcp/{s}"'}
171
+ for s in scripts_by_stage[stage]
172
+ ]
173
+ hooks.setdefault(event, []).append({"hooks": handlers})
174
+ return hooks, mapping, unmatched
175
+
176
+
177
+ def merge_hooks(existing, new):
178
+ """Merge enforcement hooks into existing per-event arrays WITHOUT clobbering
179
+ other tools' hooks (e.g. fleetcode's mailbox hook on UserPromptSubmit).
180
+ Appends new handler groups; skips any whose command is already present so
181
+ re-deploys are idempotent."""
182
+ out = {k: list(v) for k, v in existing.items()}
183
+ for event, groups in new.items():
184
+ cur = out.setdefault(event, [])
185
+ present = {h.get("command") for g in cur for h in g.get("hooks", [])}
186
+ for g in groups:
187
+ if {h.get("command") for h in g.get("hooks", [])} & present:
188
+ continue
189
+ cur.append(g)
190
+ return out
191
+
192
+
193
+ def _script_summary(name):
194
+ """First meaningful docstring line of an engine script (its purpose)."""
195
+ try:
196
+ text = (resources.files("cli_enforcement") / "engine" / name).read_text(encoding="utf-8")
197
+ m = re.search(r'"""(.*?)"""', text, re.S)
198
+ if m:
199
+ for line in m.group(1).strip().splitlines():
200
+ if line.strip():
201
+ return line.strip()
202
+ except OSError:
203
+ pass
204
+ return ""
205
+
206
+
207
+ def render_hooks_doc(model, root, mapping):
208
+ """Document every enforcement hook active for this model."""
209
+ lines = [
210
+ f"# Enforcement Hooks — {model}",
211
+ "",
212
+ "Each hook fires automatically at a lifecycle event, **outside the model's",
213
+ "control**, and runs the listed engine scripts. This is generated from the",
214
+ "deployed wiring; events come from cli-wikia's knowledge of this model.",
215
+ "",
216
+ "| Stage | Event (this model) | Scripts | What it enforces |",
217
+ "|-------|--------------------|---------|------------------|",
218
+ ]
219
+ for stage, _canon, scripts in STAGES:
220
+ event = mapping.get(stage, "— _(not exposed by this model)_")
221
+ purpose = "; ".join(s for s in (_script_summary(x) for x in scripts) if s)[:140]
222
+ lines.append(f"| `{stage}` | `{event}` | {', '.join(scripts)} | {purpose} |")
223
+ lines += ["", "> Wiring is re-derived on `cli-enforcement sync` when the model changes."]
224
+ return "\n".join(lines) + "\n"
225
+
226
+
227
+ def render_points_doc(root):
228
+ """User-editable scoring reference — shows the values and where to edit them."""
229
+ cfg = points_config_text()
230
+ return (
231
+ f"# What's Worth Points — {root}\n\n"
232
+ "This is the **scoring** the enforcement engine uses. To change what any\n"
233
+ f"action is worth, **edit `{root}/config/points_config.yaml`** (the source of\n"
234
+ "truth below). The engine reads it live; no code changes needed.\n\n"
235
+ "Principle: earn points for reading-before-editing, clean edits, and using the\n"
236
+ "cheapest model; lose them for hallucination, skipping docs, or bypassing gates.\n"
237
+ "Drop below the edit threshold and editing is blocked until you earn it back.\n\n"
238
+ "```yaml\n" + cfg + "```\n\n"
239
+ "> The scoring is **constant across every model** — only *how* it's applied\n"
240
+ "> (which gates/hooks) changes per model. Messages on every gain/loss are\n"
241
+ '> framed as "we" so mistakes are shared problems to solve, never blame.\n'
242
+ )
243
+
244
+
245
+ def cmd_deploy(args):
246
+ H = _wikia()
247
+ model = args.model
248
+ target = os.path.abspath(args.dir or ".")
249
+ root = H.config_root(model)
250
+ events = H.hook_events(model)
251
+ instr = H.instruction_file(model)
252
+ if not root:
253
+ sys.exit(f"cli-wikia couldn't determine a config dir for '{model}'. "
254
+ f"(Is it a known model? try: wikia models)")
255
+ if not events:
256
+ print(f"⚠ cli-wikia lists no hook events for '{model}' — wiring may be partial.")
257
+
258
+ import cli_wikia.cli as W
259
+
260
+ topics = W.topics(model)
261
+ comps = build_kb_gate_components(model, topics, root)
262
+
263
+ hooks, mapping, unmatched = build_registry(model, root, events)
264
+ settings_path = os.path.join(target, root, "settings.json")
265
+ engine_dst = os.path.join(target, root, "mcp")
266
+ config_dst = os.path.join(target, root, "config")
267
+ files = engine_files()
268
+
269
+ print(f"model: {model}")
270
+ print(f"config root: {root}/ (from cli-wikia)")
271
+ print(f"instructions: {instr}")
272
+ print(f"engine: {len(files)} scripts -> {root}/mcp/ (constant)")
273
+ print(f"points engine: {root}/config/points_config.yaml (CONSTANT — same for every model)")
274
+ print(f"KB gates: {len(comps)} -> {root}/config/project_config.yaml (DYNAMIC — from cli-wikia topics)")
275
+ print(f"wired {len(mapping)}/{len(STAGES)} enforcement stages to real events:")
276
+ for stage, event in mapping.items():
277
+ print(f" {stage:18} -> {event}")
278
+ if unmatched:
279
+ print(f" no matching event for: {', '.join(unmatched)}"
280
+ f" (this platform doesn't expose those lifecycle points)")
281
+
282
+ if not args.write:
283
+ print("\n[dry-run] nothing written. Re-run with --write to deploy.")
284
+ print(f"--- {len(comps)} KB-gate components (first 6) ---")
285
+ for c in comps[:6]:
286
+ print(f" {c['id']:22} <- read: {c['understanding']}")
287
+ return
288
+
289
+ os.makedirs(engine_dst, exist_ok=True)
290
+ for f in files:
291
+ with open(os.path.join(engine_dst, f.name), "w", encoding="utf-8") as out:
292
+ out.write(f.read_text(encoding="utf-8"))
293
+ os.makedirs(config_dst, exist_ok=True)
294
+ with open(os.path.join(config_dst, "points_config.yaml"), "w", encoding="utf-8") as fh:
295
+ fh.write(points_config_text()) # constant engine
296
+ with open(os.path.join(config_dst, "project_config.yaml"), "w", encoding="utf-8") as fh:
297
+ fh.write(render_project_config(model, root, comps)) # dynamic application
298
+ docs_dst = os.path.join(target, root, "docs")
299
+ os.makedirs(docs_dst, exist_ok=True)
300
+ with open(os.path.join(docs_dst, "HOOKS.md"), "w", encoding="utf-8") as fh:
301
+ fh.write(render_hooks_doc(model, root, mapping)) # all-hooks document
302
+ with open(os.path.join(docs_dst, "POINTS.md"), "w", encoding="utf-8") as fh:
303
+ fh.write(render_points_doc(root)) # editable scoring reference
304
+ current = {}
305
+ if os.path.exists(settings_path):
306
+ with open(settings_path, encoding="utf-8") as fh:
307
+ current = json.load(fh)
308
+ # MERGE (don't overwrite) — preserves fleetcode's mailbox hook etc.
309
+ current["hooks"] = merge_hooks(current.get("hooks", {}), hooks)
310
+ os.makedirs(os.path.dirname(settings_path), exist_ok=True)
311
+ with open(settings_path, "w", encoding="utf-8") as fh:
312
+ json.dump(current, fh, indent=2)
313
+ print(f"\n✓ deployed enforcement for {model} into {target}/{root}/")
314
+ print(f" {len(files)} engine scripts + constant points engine + "
315
+ f"{len(comps)} wikia-derived KB gates + {len(mapping)} wired events.")
316
+
317
+
318
+ def cmd_sync(args):
319
+ """Re-derive the per-model APPLICATION from current cli-wikia and show what
320
+ changed (KB gates / hook wiring) because the model's features changed. The
321
+ points ENGINE (points_config.yaml) is never touched."""
322
+ H = _wikia()
323
+ import cli_wikia.cli as W
324
+
325
+ model = args.model
326
+ target = os.path.abspath(args.dir or ".")
327
+ root = H.config_root(model)
328
+ if not root:
329
+ sys.exit(f"unknown model '{model}'.")
330
+ pc = os.path.join(target, root, "config", "project_config.yaml")
331
+ if not os.path.exists(pc):
332
+ sys.exit(f"{model} isn't deployed in {target} — run `cli-enforcement deploy {model}` first.")
333
+
334
+ deployed_ids = set(re.findall(r"^\s*- id:\s*(\S+)", open(pc, encoding="utf-8").read(), re.M))
335
+ topics = W.topics(model)
336
+ comps = build_kb_gate_components(model, topics, root)
337
+ new_ids = {c["id"] for c in comps}
338
+ added = sorted(new_ids - deployed_ids)
339
+ removed = sorted(deployed_ids - new_ids)
340
+
341
+ print(f"{model}: {len(new_ids)} KB gates from current wikia (was {len(deployed_ids)} deployed)")
342
+ for a in added:
343
+ print(f" + {a} (model gained this — new gate)")
344
+ for r in removed:
345
+ print(f" - {r} (no longer in wikia — gate dropped)")
346
+ if not added and not removed:
347
+ print(" in sync — nothing changed.")
348
+ return
349
+ if not args.write:
350
+ print("\n[dry-run] points engine stays constant; only the application changes. "
351
+ "Re-run with --write to apply.")
352
+ return
353
+ events = H.hook_events(model)
354
+ hooks, _, _ = build_registry(model, root, events)
355
+ with open(pc, "w", encoding="utf-8") as fh:
356
+ fh.write(render_project_config(model, root, comps))
357
+ settings_path = os.path.join(target, root, "settings.json")
358
+ current = {}
359
+ if os.path.exists(settings_path):
360
+ with open(settings_path, encoding="utf-8") as fh:
361
+ current = json.load(fh)
362
+ current.setdefault("hooks", {}).update(hooks)
363
+ with open(settings_path, "w", encoding="utf-8") as fh:
364
+ json.dump(current, fh, indent=2)
365
+ print(f"\n✓ re-applied {model}: {len(new_ids)} gates, {len(hooks)} wired events. "
366
+ f"points_config.yaml untouched.")
367
+
368
+
369
+ def cmd_status(args):
370
+ H = _wikia()
371
+ target = os.path.abspath(args.dir or ".")
372
+ for model in (__import__("cli_wikia").MODELS):
373
+ root = H.config_root(model)
374
+ if not root:
375
+ continue
376
+ deployed = os.path.isdir(os.path.join(target, root, "mcp"))
377
+ print(f"{model:12} root={root:16} deployed_here={'yes' if deployed else 'no'}")
378
+
379
+
380
+ def cmd_remove(args):
381
+ H = _wikia()
382
+ model = args.model
383
+ target = os.path.abspath(args.dir or ".")
384
+ root = H.config_root(model)
385
+ mcp = os.path.join(target, root, "mcp") if root else None
386
+ if not mcp or not os.path.isdir(mcp):
387
+ print(f"{model}: no enforcement engine found in {target}")
388
+ return
389
+ if not args.write:
390
+ print(f"[dry-run] would remove {mcp} (re-run with --write)")
391
+ return
392
+ shutil.rmtree(mcp)
393
+ print(f"removed enforcement engine from {mcp} (settings.json left intact)")