cli-enforcement 0.1.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 (43) hide show
  1. cli_enforcement/__init__.py +10 -0
  2. cli_enforcement/cli.py +58 -0
  3. cli_enforcement/deploy.py +393 -0
  4. cli_enforcement/engine/__init__.py +0 -0
  5. cli_enforcement/engine/achievements.py +215 -0
  6. cli_enforcement/engine/bash_gate.py +456 -0
  7. cli_enforcement/engine/cascade_manager.py +745 -0
  8. cli_enforcement/engine/check_dismissal.py +182 -0
  9. cli_enforcement/engine/config_change.py +104 -0
  10. cli_enforcement/engine/enforce_check.py +645 -0
  11. cli_enforcement/engine/enforcement_cli.py +470 -0
  12. cli_enforcement/engine/enforcement_logger.py +209 -0
  13. cli_enforcement/engine/enforcement_unlock.py +171 -0
  14. cli_enforcement/engine/flush_reads.py +56 -0
  15. cli_enforcement/engine/instructions_loaded.py +92 -0
  16. cli_enforcement/engine/integrity_check.py +293 -0
  17. cli_enforcement/engine/message_generator.py +334 -0
  18. cli_enforcement/engine/message_generator_v2.py +150 -0
  19. cli_enforcement/engine/permission_check.py +137 -0
  20. cli_enforcement/engine/points_config.yaml +40 -0
  21. cli_enforcement/engine/post_bash_check.py +236 -0
  22. cli_enforcement/engine/post_compact.py +110 -0
  23. cli_enforcement/engine/post_edit_validate.py +543 -0
  24. cli_enforcement/engine/pre_compact.py +83 -0
  25. cli_enforcement/engine/project_integrity.py +217 -0
  26. cli_enforcement/engine/record_edit.py +205 -0
  27. cli_enforcement/engine/record_read.py +403 -0
  28. cli_enforcement/engine/session_end.py +278 -0
  29. cli_enforcement/engine/session_init.py +238 -0
  30. cli_enforcement/engine/state_manager.py +2250 -0
  31. cli_enforcement/engine/stop_check.py +233 -0
  32. cli_enforcement/engine/stop_failure.py +57 -0
  33. cli_enforcement/engine/subagent_start.py +90 -0
  34. cli_enforcement/engine/subagent_stop.py +106 -0
  35. cli_enforcement/engine/task_completed.py +53 -0
  36. cli_enforcement/engine/tool_failure.py +97 -0
  37. cli_enforcement/engine/workflow_server.py +1820 -0
  38. cli_enforcement/fleet.py +125 -0
  39. cli_enforcement-0.1.0.dist-info/METADATA +76 -0
  40. cli_enforcement-0.1.0.dist-info/RECORD +43 -0
  41. cli_enforcement-0.1.0.dist-info/WHEEL +4 -0
  42. cli_enforcement-0.1.0.dist-info/entry_points.txt +2 -0
  43. cli_enforcement-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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"
cli_enforcement/cli.py ADDED
@@ -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
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Achievement System - Milestone & Progression Tracking
4
+ ====================================================
5
+
6
+ Tracks unlocked achievements, milestones, and progression metrics.
7
+ Integrates with state_manager to award bonuses for achievements.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Dict, List, Any, Optional
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+
19
+ ACHIEVEMENT_DEFS = {
20
+ "first_clean_edit": {
21
+ "name": "First Clean Edit",
22
+ "description": "Your first edit with no violations",
23
+ "icon": "🎯",
24
+ "bonus_points": 10,
25
+ },
26
+ "five_edit_streak": {
27
+ "name": "5-Edit Streak",
28
+ "description": "5 consecutive clean edits",
29
+ "icon": "🔥",
30
+ "bonus_points": 25,
31
+ },
32
+ "ten_edit_streak": {
33
+ "name": "10-Edit Streak",
34
+ "description": "10 consecutive clean edits",
35
+ "icon": "👑",
36
+ "bonus_points": 50,
37
+ },
38
+ "perfect_session": {
39
+ "name": "Perfect Session",
40
+ "description": "Complete session with zero violations",
41
+ "icon": "⭐",
42
+ "bonus_points": 100,
43
+ },
44
+ "fifty_edits": {
45
+ "name": "50 Edits",
46
+ "description": "Completed 50 edits (milestone)",
47
+ "icon": "💎",
48
+ "bonus_points": 75,
49
+ },
50
+ "hundred_edits": {
51
+ "name": "100 Edits",
52
+ "description": "Completed 100 edits (mastery)",
53
+ "icon": "👑",
54
+ "bonus_points": 150,
55
+ },
56
+ "thousand_points": {
57
+ "name": "1000 Points",
58
+ "description": "Earned 1000 total points",
59
+ "icon": "🏆",
60
+ "bonus_points": 0, # Meta-achievement
61
+ },
62
+ "clean_recovery": {
63
+ "name": "Clean Recovery",
64
+ "description": "Recovered from negative points to 500+",
65
+ "icon": "💪",
66
+ "bonus_points": 100,
67
+ },
68
+ }
69
+
70
+
71
+ class AchievementTracker:
72
+ """Track and manage achievements."""
73
+
74
+ def __init__(self, claude_dir: Path):
75
+ """Initialize achievement tracker."""
76
+ self.claude_dir = Path(claude_dir)
77
+ self.achievements_file = self.claude_dir / ".achievements.json"
78
+ self.achievements = self._load_achievements()
79
+
80
+ def _load_achievements(self) -> Dict[str, Any]:
81
+ """Load achievements from file."""
82
+ if self.achievements_file.exists():
83
+ try:
84
+ return json.loads(self.achievements_file.read_text())
85
+ except (json.JSONDecodeError, IOError):
86
+ pass
87
+ return {"unlocked": [], "history": []}
88
+
89
+ def _save_achievements(self):
90
+ """Save achievements to file."""
91
+ self.achievements_file.write_text(json.dumps(self.achievements, indent=2))
92
+
93
+ def unlock(self, achievement_id: str) -> bool:
94
+ """
95
+ Unlock an achievement.
96
+ Returns True if newly unlocked, False if already unlocked.
97
+ """
98
+ if achievement_id in self.achievements["unlocked"]:
99
+ return False # Already unlocked
100
+
101
+ if achievement_id not in ACHIEVEMENT_DEFS:
102
+ return False # Invalid achievement
103
+
104
+ # Unlock it
105
+ self.achievements["unlocked"].append(achievement_id)
106
+ self.achievements["history"].append({
107
+ "id": achievement_id,
108
+ "unlocked_at": datetime.now().isoformat(),
109
+ })
110
+ self._save_achievements()
111
+
112
+ return True
113
+
114
+ def check_unlock_triggers(self, stats: Dict[str, Any]) -> List[str]:
115
+ """
116
+ Check what achievements should be unlocked based on current stats.
117
+ Returns list of newly unlocked achievement IDs.
118
+ """
119
+ unlocked = []
120
+
121
+ # First clean edit
122
+ if "first_clean_edit" not in self.achievements["unlocked"]:
123
+ if stats.get("all_time_clean_edits", 0) >= 1:
124
+ if self.unlock("first_clean_edit"):
125
+ unlocked.append("first_clean_edit")
126
+
127
+ # Streak achievements
128
+ current_streak = stats.get("current_streak", 0)
129
+ if "five_edit_streak" not in self.achievements["unlocked"]:
130
+ if current_streak >= 5:
131
+ if self.unlock("five_edit_streak"):
132
+ unlocked.append("five_edit_streak")
133
+
134
+ if "ten_edit_streak" not in self.achievements["unlocked"]:
135
+ if current_streak >= 10:
136
+ if self.unlock("ten_edit_streak"):
137
+ unlocked.append("ten_edit_streak")
138
+
139
+ # Edit count milestones
140
+ total_edits = stats.get("all_time_clean_edits", 0)
141
+ if "fifty_edits" not in self.achievements["unlocked"]:
142
+ if total_edits >= 50:
143
+ if self.unlock("fifty_edits"):
144
+ unlocked.append("fifty_edits")
145
+
146
+ if "hundred_edits" not in self.achievements["unlocked"]:
147
+ if total_edits >= 100:
148
+ if self.unlock("hundred_edits"):
149
+ unlocked.append("hundred_edits")
150
+
151
+ # Perfect session (no violations this session)
152
+ if "perfect_session" not in self.achievements["unlocked"]:
153
+ if stats.get("session_violations", 0) == 0 and stats.get("clean_edits", 0) >= 3:
154
+ if self.unlock("perfect_session"):
155
+ unlocked.append("perfect_session")
156
+
157
+ # Points milestone
158
+ if "thousand_points" not in self.achievements["unlocked"]:
159
+ if stats.get("all_time_points", 0) >= 1000:
160
+ if self.unlock("thousand_points"):
161
+ unlocked.append("thousand_points")
162
+
163
+ # Clean recovery
164
+ if "clean_recovery" not in self.achievements["unlocked"]:
165
+ was_negative = stats.get("was_negative_last_session", False)
166
+ if was_negative and stats.get("current_points", 0) >= 500:
167
+ if self.unlock("clean_recovery"):
168
+ unlocked.append("clean_recovery")
169
+
170
+ return unlocked
171
+
172
+ def get_achievement_bonus(self, achievement_id: str) -> int:
173
+ """Get bonus points for an achievement."""
174
+ return ACHIEVEMENT_DEFS.get(achievement_id, {}).get("bonus_points", 0)
175
+
176
+ def get_achievement_message(self, achievement_id: str) -> str:
177
+ """Get announcement message for an achievement."""
178
+ if achievement_id not in ACHIEVEMENT_DEFS:
179
+ return ""
180
+
181
+ ach = ACHIEVEMENT_DEFS[achievement_id]
182
+ icon = ach.get("icon", "🎯")
183
+ name = ach.get("name", "Unknown")
184
+ bonus = ach.get("bonus_points", 0)
185
+
186
+ if bonus > 0:
187
+ return f"{icon} ACHIEVEMENT UNLOCKED: {name}! +{bonus} bonus points!"
188
+ else:
189
+ return f"{icon} ACHIEVEMENT UNLOCKED: {name}!"
190
+
191
+ def is_unlocked(self, achievement_id: str) -> bool:
192
+ """Check if achievement is unlocked."""
193
+ return achievement_id in self.achievements.get("unlocked", [])
194
+
195
+ def get_unlocked_count(self) -> int:
196
+ """Get count of unlocked achievements."""
197
+ return len(self.achievements.get("unlocked", []))
198
+
199
+ def get_all_unlocked(self) -> List[str]:
200
+ """Get all unlocked achievement IDs."""
201
+ return self.achievements.get("unlocked", [])
202
+
203
+
204
+ if __name__ == "__main__":
205
+ # Test
206
+ tracker = AchievementTracker(Path.cwd() / ".claude")
207
+
208
+ # Simulate unlocking achievements
209
+ tracker.unlock("first_clean_edit")
210
+ tracker.unlock("five_edit_streak")
211
+
212
+ print("Unlocked:", tracker.get_all_unlocked())
213
+ print("Count:", tracker.get_unlocked_count())
214
+ print("Bonus for first_clean_edit:", tracker.get_achievement_bonus("first_clean_edit"))
215
+ print("Message:", tracker.get_achievement_message("five_edit_streak"))