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.
- cli_enforcement-0.1.0/.gitignore +6 -0
- cli_enforcement-0.1.0/LICENSE +21 -0
- cli_enforcement-0.1.0/PKG-INFO +76 -0
- cli_enforcement-0.1.0/README.md +43 -0
- cli_enforcement-0.1.0/pyproject.toml +24 -0
- cli_enforcement-0.1.0/src/cli_enforcement/__init__.py +10 -0
- cli_enforcement-0.1.0/src/cli_enforcement/cli.py +58 -0
- cli_enforcement-0.1.0/src/cli_enforcement/deploy.py +393 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/__init__.py +0 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/achievements.py +215 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/bash_gate.py +456 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/cascade_manager.py +745 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/check_dismissal.py +182 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/config_change.py +104 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/enforce_check.py +645 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/enforcement_cli.py +470 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/enforcement_logger.py +209 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/enforcement_unlock.py +171 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/flush_reads.py +56 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/instructions_loaded.py +92 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/integrity_check.py +293 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/message_generator.py +334 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/message_generator_v2.py +150 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/permission_check.py +137 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/points_config.yaml +40 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/post_bash_check.py +236 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/post_compact.py +110 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/post_edit_validate.py +543 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/pre_compact.py +83 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/project_integrity.py +217 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/record_edit.py +205 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/record_read.py +403 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/session_end.py +278 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/session_init.py +238 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/state_manager.py +2250 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/stop_check.py +233 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/stop_failure.py +57 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/subagent_start.py +90 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/subagent_stop.py +106 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/task_completed.py +53 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/tool_failure.py +97 -0
- cli_enforcement-0.1.0/src/cli_enforcement/engine/workflow_server.py +1820 -0
- cli_enforcement-0.1.0/src/cli_enforcement/fleet.py +125 -0
|
@@ -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)")
|
|
File without changes
|