adda 0.4.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.
adda/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # Developed by - Vedavyas Vayalpadu - vyas4c3@gmail.com
2
+ # Coded by - Claude Code
3
+ """ADDA — anti-drift architecture memory, OKF compiler, and Context Sentinel."""
4
+
5
+ __version__ = "0.4.0"
adda/audit.py ADDED
@@ -0,0 +1,155 @@
1
+ # Developed by - Vedavyas Vayalpadu - vyas4c3@gmail.com
2
+ # Coded by - Claude Code
3
+ """adda audit — the doc-layer drift sweep `adda diff` structurally cannot do.
4
+
5
+ `diff` checks that documented module PATHS still exist. It never opens the docs
6
+ and ignores docs/ entirely, which is how this repo ran two months with an empty
7
+ docs/modules/ while `adda diff` reported "No drift" (2026-08-18). `audit` closes
8
+ that hole using MODULE_MAP.json as the routing table.
9
+
10
+ Five rules:
11
+ 1) Doc missing: mapped code exists but its doc doesn't (high severity).
12
+ 2) Map entry stale: both code and doc are gone (low severity).
13
+ 3) Code unmapped: source escaping the map entirely (medium severity).
14
+ 4) Doc orphaned: doc exists but its mapped code doesn't (low severity).
15
+ 5) Doc stale: both exist but the code's last commit is newer than the doc's
16
+ (medium severity). Git history unavailable for either side is never
17
+ treated as fresh — it is recorded in `skipped` instead.
18
+
19
+ Source roots that discovery deliberately skipped are reported in `skipped` too
20
+ (DEC-ADDA-009), so a wrong guess about what is a module costs you a printed
21
+ line rather than a whole unenforced directory.
22
+
23
+ Deterministic and offline — no model calls (ADR-0003). Any rule that cannot run
24
+ is REPORTED as skipped, never silently passed: a check that fails quietly is
25
+ worse than no check.
26
+ """
27
+
28
+ import subprocess
29
+ from pathlib import Path
30
+
31
+ from adda.modulemap import is_exempt, load_include, load_map
32
+ from adda.sync import source_roots
33
+
34
+
35
+ # Source discovery is imported, never reimplemented here. This function and
36
+ # the map generator diverged once: the map covered ts/tsx/js while this
37
+ # globbed `*.py`, so a newly added TypeScript file was absent from the map
38
+ # AND invisible to the unmapped-code rule below - it escaped enforcement
39
+ # entirely (BUG-ADDA-013).
40
+
41
+
42
+ def _git_out(repo: Path, args: list[str]):
43
+ """Run `git *args` in `repo`; return trimmed stdout, or None on any failure."""
44
+ try:
45
+ out = subprocess.run(
46
+ ["git", *args], cwd=repo, capture_output=True, text=True, timeout=10,
47
+ )
48
+ except (OSError, subprocess.SubprocessError, UnicodeDecodeError):
49
+ return None
50
+ if out.returncode != 0 or not out.stdout.strip():
51
+ return None
52
+ return out.stdout.strip()
53
+
54
+
55
+ def last_commit_sha(repo: Path, path: str):
56
+ """Full SHA of the last commit touching `path`; None if unknown.
57
+
58
+ None means "cannot tell" (not a git repo, git missing, or untracked) —
59
+ callers must treat it as skipped, never as fresh.
60
+ """
61
+ return _git_out(repo, ["log", "-1", "--format=%H", "--", path])
62
+
63
+
64
+ def _git_rc(repo: Path, args: list) -> int:
65
+ """Exit code of a git command; -1 when git could not be run at all."""
66
+ try:
67
+ out = subprocess.run(
68
+ ["git", *args], cwd=repo, capture_output=True, text=True, timeout=10,
69
+ )
70
+ except (OSError, subprocess.SubprocessError, UnicodeDecodeError):
71
+ return -1
72
+ return out.returncode
73
+
74
+
75
+ def _is_ancestor(repo: Path, older: str, newer: str):
76
+ """True / False / None. None means git could not decide, NOT 'no'."""
77
+ rc = _git_rc(repo, ["merge-base", "--is-ancestor", older, newer])
78
+ if rc == 0:
79
+ return True
80
+ if rc == 1:
81
+ return False
82
+ return None # 128, -1, shallow clone, unknown object: cannot tell
83
+
84
+
85
+ def compare_commits(repo: Path, doc_sha: str, code_sha: str) -> str:
86
+ """'stale' | 'current' | 'unknown' for a doc against its code.
87
+
88
+ ponytail: three-state on purpose. A boolean forces 'cannot tell' to
89
+ masquerade as 'not stale', which is a silent pass — the exact defect this
90
+ module exists to catch.
91
+ """
92
+ if doc_sha == code_sha:
93
+ return "current" # committed together
94
+ if _is_ancestor(repo, doc_sha, code_sha) is True:
95
+ return "stale" # doc's commit strictly precedes the code's
96
+ if _is_ancestor(repo, code_sha, doc_sha) is True:
97
+ return "current" # doc's commit follows the code's
98
+ return "unknown" # divergent, rebased, shallow, or git failed
99
+
100
+
101
+ def audit_report(repo: Path, adda_dir: Path) -> tuple[list[dict], list]:
102
+ """Return (findings, skipped). Findings are {item, issue, severity}."""
103
+ mapping, exempt = load_map(adda_dir)
104
+ findings, skipped = [], []
105
+ unknown = []
106
+
107
+ for code, doc in sorted(mapping.items()):
108
+ code_exists = (repo / code).exists()
109
+ doc_exists = (repo / doc).exists()
110
+ # 1) mapped doc was never written (or was deleted)
111
+ if code_exists and not doc_exists:
112
+ findings.append({"item": doc, "issue": "doc missing", "severity": "high"})
113
+ # 2) map entry stale: both code and doc are gone
114
+ elif not code_exists and not doc_exists:
115
+ findings.append({"item": code, "issue": "map entry stale", "severity": "low"})
116
+ # 4) doc outlived the code it documents
117
+ elif doc_exists and not code_exists:
118
+ findings.append({"item": code, "issue": "doc orphaned", "severity": "low"})
119
+ else:
120
+ # doc stale: code changed in a later commit than its doc.
121
+ code_sha, doc_sha = last_commit_sha(repo, code), last_commit_sha(repo, doc)
122
+ if code_sha is None or doc_sha is None:
123
+ unknown.append(doc)
124
+ else:
125
+ result = compare_commits(repo, doc_sha, code_sha)
126
+ if result == "stale":
127
+ findings.append({"item": doc, "issue": "doc stale", "severity": "medium"})
128
+ elif result == "unknown":
129
+ unknown.append(doc)
130
+
131
+ if unknown:
132
+ skipped.append(
133
+ f"stale-check skipped for {len(unknown)} doc(s): git history unavailable or "
134
+ f"the doc and code commits are not ordered (divergent, rebased, or shallow "
135
+ f"history)"
136
+ )
137
+
138
+ # 3) source escaping the map entirely — an explicit map means a new file is
139
+ # unmapped, and unmapped would otherwise mean silently unenforced.
140
+ mapped, excluded = source_roots(repo, load_include(adda_dir))
141
+ for src in mapped:
142
+ if src not in mapping and not is_exempt(src, exempt):
143
+ findings.append({"item": src, "issue": "code unmapped", "severity": "medium"})
144
+
145
+ # 6) roots discovery chose not to map. Not a finding — nothing is drifting —
146
+ # but never silent either: the package heuristic is a guess, and when it
147
+ # guesses wrong a whole directory of code leaves enforcement with no
148
+ # signal at all (BUG-ADDA-020). DEC-ADDA-009 keeps the guess and prints it.
149
+ for path, reason in excluded:
150
+ skipped.append(
151
+ f"not mapped: {path}/ ({reason}) - treated as examples, not a package. "
152
+ f'Add "{path}" to "include" in MODULE_MAP.json to enforce docs for it.'
153
+ )
154
+
155
+ return findings, skipped
adda/cli.py ADDED
@@ -0,0 +1,454 @@
1
+ # Developed by - Vedavyas Vayalpadu - vyas4c3@gmail.com
2
+ # Coded by - Claude Code
3
+ """ADDA command-line entrypoint (Typer)."""
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import typer
14
+
15
+ from adda import __version__
16
+ from adda.audit import audit_report
17
+ from adda.compress import compress_text
18
+ from adda.diff import diff_report
19
+ from adda.evaluate import evaluate
20
+ from adda.hook import check_staged, hook_body, staged_paths
21
+ from adda.okf import compile_okf
22
+ from adda.rehydrate import minimal_okf
23
+ from adda.sentinel import ContextSentinel, count_tokens, limit_for
24
+ from adda.sync import skeleton_markdown
25
+
26
+
27
+ def _force_utf8_output() -> None:
28
+ """Emit UTF-8 on stdout/stderr regardless of the console codepage.
29
+
30
+ BUG-ADDA-022. On Windows a REDIRECTED stdout gets the console codepage
31
+ (cp1252), so the first non-ASCII byte raises UnicodeEncodeError. ADDA's own
32
+ OKF carries `->` arrows, so `adda rehydrate . > out.json` crashed - and
33
+ stdout is the documented default for `rehydrate`, the command the whole
34
+ tool is built around. `--out` was unaffected because it writes explicit
35
+ UTF-8, which is why this survived every local run.
36
+
37
+ JSON is UTF-8 by specification, so forcing it is correct rather than merely
38
+ convenient. Guarded because a captured or already-detached stream may not
39
+ be reconfigurable.
40
+ """
41
+ for stream in (sys.stdout, sys.stderr):
42
+ reconfigure = getattr(stream, "reconfigure", None)
43
+ if reconfigure is None:
44
+ continue
45
+ try:
46
+ reconfigure(encoding="utf-8")
47
+ except (ValueError, OSError): # detached, or not a real text stream
48
+ pass
49
+
50
+
51
+ _force_utf8_output()
52
+
53
+ app = typer.Typer(
54
+ help="ADDA - anti-drift architecture memory + OKF + Context Sentinel.",
55
+ no_args_is_help=True,
56
+ add_completion=False,
57
+ )
58
+
59
+ # Seed scaffold shipped with the package (see src/adda/templates/adda/).
60
+ TEMPLATES = Path(__file__).parent / "templates" / "adda"
61
+
62
+
63
+ def _resolve_adda_dir(path: Path) -> Path:
64
+ """Accept either a project root (containing adda/) or an adda/ dir itself."""
65
+ if (path / "adda").is_dir():
66
+ return path / "adda"
67
+ if (path / "VERSION.md").is_file():
68
+ return path
69
+ raise typer.BadParameter(
70
+ f"No adda/ directory found under {path}. Run `adda init` first."
71
+ )
72
+
73
+
74
+ def _maybe_compress(text: str, compress: bool) -> str:
75
+ """Apply opt-in headroom compression, reporting savings or why it was skipped."""
76
+ if not compress:
77
+ return text
78
+ out, info = compress_text(text)
79
+ if info.get("applied"):
80
+ saved, ratio = info.get("tokens_saved"), info.get("compression_ratio")
81
+ detail = f"{saved} tokens saved" if saved is not None else "applied"
82
+ if ratio is not None:
83
+ detail += f" ({ratio:.0%} smaller)"
84
+ typer.secho(f"[compress] headroom: {detail}", fg=typer.colors.CYAN)
85
+ return out
86
+ typer.secho(
87
+ f"[compress] skipped ({info.get('reason')}) - emitting uncompressed.",
88
+ fg=typer.colors.YELLOW,
89
+ )
90
+ return text
91
+
92
+
93
+ @app.command()
94
+ def version() -> None:
95
+ """Print the ADDA version."""
96
+ typer.echo(__version__)
97
+
98
+
99
+ @app.command()
100
+ def init(
101
+ path: Path = typer.Argument(
102
+ Path("."), help="Target project directory (default: current directory)."
103
+ ),
104
+ force: bool = typer.Option(
105
+ False, "--force", help="Overwrite an existing adda/ directory."
106
+ ),
107
+ ) -> None:
108
+ """Scaffold an /adda architecture-memory directory into a target project."""
109
+ dest = path / "adda"
110
+ if dest.exists():
111
+ if not force:
112
+ typer.secho(
113
+ f"{dest} already exists. Use --force to overwrite.",
114
+ fg=typer.colors.RED,
115
+ )
116
+ raise typer.Exit(1)
117
+ if dest.exists():
118
+ # NEVER rmtree. --force means "overwrite the scaffold", not "delete
119
+ # everything accumulated here". It used to remove the whole directory,
120
+ # taking MODULE_MAP.json and STATE/checkpoints with it - and with no
121
+ # map the commit gate silently stops blocking anything, so a routine
122
+ # re-scaffold disarmed enforcement with no signal at all.
123
+ kept = [
124
+ f for f in sorted(dest.rglob("*"))
125
+ if f.is_file() and not (TEMPLATES / f.relative_to(dest)).exists()
126
+ ]
127
+ shutil.copytree(TEMPLATES, dest, dirs_exist_ok=True)
128
+ if kept:
129
+ typer.secho(
130
+ f"Kept {len(kept)} file(s) that are not part of the scaffold:",
131
+ fg=typer.colors.YELLOW,
132
+ )
133
+ for f in kept:
134
+ typer.echo(f" {f.relative_to(dest).as_posix()}")
135
+ else:
136
+ shutil.copytree(TEMPLATES, dest)
137
+ typer.secho(f"Scaffolded ADDA memory at {dest}", fg=typer.colors.GREEN)
138
+
139
+
140
+ @app.command()
141
+ def export(
142
+ path: Path = typer.Argument(
143
+ Path("."), help="Project dir containing adda/ (default: current)."
144
+ ),
145
+ okf: bool = typer.Option(
146
+ True, "--okf", help="Emit OKF JSON (default, and the only format today)."
147
+ ),
148
+ out: Optional[Path] = typer.Option(
149
+ None, "--out", "-o", help="Output path (default: <project>/okf.json)."
150
+ ),
151
+ compress: bool = typer.Option(
152
+ False, "--compress", help="Compress with headroom-ai if installed (lossy; opt-in)."
153
+ ),
154
+ ) -> None:
155
+ """Compile the /adda markdown into validated OKF JSON."""
156
+ adda_dir = _resolve_adda_dir(path)
157
+ okf_obj = compile_okf(adda_dir) # pydantic-validated by construction
158
+ target = out or (adda_dir.parent / "okf.json")
159
+ text = _maybe_compress(
160
+ json.dumps(okf_obj.model_dump(), indent=2, ensure_ascii=False), compress
161
+ )
162
+ target.write_text(text + "\n", encoding="utf-8")
163
+ typer.secho(
164
+ f"Wrote OKF -> {target} "
165
+ f"({len(okf_obj.architecture.modules)} modules, "
166
+ f"{len(okf_obj.decisions)} decisions, "
167
+ f"{len(okf_obj.constraints)} constraints)",
168
+ fg=typer.colors.GREEN,
169
+ )
170
+
171
+
172
+ @app.command()
173
+ def monitor(
174
+ tokens: Optional[int] = typer.Option(
175
+ None, "--tokens", "-t", help="Current token count to gauge."
176
+ ),
177
+ file: Optional[Path] = typer.Option(
178
+ None, "--file", "-f", help="Count tokens of this file instead of --tokens."
179
+ ),
180
+ model: Optional[str] = typer.Option(
181
+ None, "--model", "-m", help="Model id (sets default limit and counting accuracy)."
182
+ ),
183
+ limit: Optional[int] = typer.Option(
184
+ None, "--limit", "-l", help="Context window (default: model's window or 200000)."
185
+ ),
186
+ ) -> None:
187
+ """Context Sentinel: print context usage % and OK/CHECKPOINT/ALERT/FORCE."""
188
+ if (tokens is None) == (file is None):
189
+ raise typer.BadParameter("Provide exactly one of --tokens or --file.")
190
+
191
+ method = "given"
192
+ if file is not None:
193
+ tokens, method = count_tokens(file.read_text(encoding="utf-8"), model=model)
194
+
195
+ eff_limit = limit or limit_for(model)
196
+ sentinel = ContextSentinel(eff_limit)
197
+ status = sentinel.check(tokens)
198
+ color = {
199
+ "OK": typer.colors.GREEN,
200
+ "CHECKPOINT": typer.colors.YELLOW,
201
+ "ALERT": typer.colors.RED,
202
+ "FORCE": typer.colors.RED,
203
+ }[status]
204
+ typer.secho(
205
+ f"{status} {sentinel.percent(tokens):.1f}% "
206
+ f"({tokens}/{eff_limit} tokens via {method})",
207
+ fg=color,
208
+ )
209
+ if status != "OK":
210
+ typer.echo("-> Checkpoint now: run `adda checkpoint`, then `adda rehydrate` after a compaction.")
211
+
212
+
213
+ @app.command()
214
+ def rehydrate(
215
+ path: Path = typer.Argument(
216
+ Path("."), help="Project dir containing adda/ (default: current)."
217
+ ),
218
+ out: Optional[Path] = typer.Option(
219
+ None, "--out", "-o", help="Write here (default: print to stdout for piping)."
220
+ ),
221
+ compress: bool = typer.Option(
222
+ False, "--compress", help="Compress with headroom-ai if installed (lossy; opt-in)."
223
+ ),
224
+ ) -> None:
225
+ """Emit the MINIMAL OKF to restore an LLM's architecture memory after a compaction."""
226
+ adda_dir = _resolve_adda_dir(path)
227
+ data = minimal_okf(compile_okf(adda_dir))
228
+ text = _maybe_compress(json.dumps(data, indent=2, ensure_ascii=False), compress)
229
+ if out is not None:
230
+ out.parent.mkdir(parents=True, exist_ok=True)
231
+ out.write_text(text + "\n", encoding="utf-8")
232
+ typer.secho(f"Wrote minimal OKF -> {out}", fg=typer.colors.GREEN)
233
+ else:
234
+ typer.echo(text)
235
+
236
+
237
+ @app.command()
238
+ def checkpoint(
239
+ path: Path = typer.Argument(
240
+ Path("."), help="Project dir containing adda/ (default: current)."
241
+ ),
242
+ message: Optional[str] = typer.Option(
243
+ None, "--message", "-m", help="Note to attach to the snapshot."
244
+ ),
245
+ ) -> None:
246
+ """Snapshot the current STATE/ into a timestamped checkpoint entry."""
247
+ adda_dir = _resolve_adda_dir(path)
248
+ current = adda_dir / "STATE" / "CURRENT.md"
249
+ if not current.is_file():
250
+ raise typer.BadParameter(f"No STATE/CURRENT.md under {adda_dir}.")
251
+
252
+ # UTC, colon-free, sortable -> safe as a Windows filename.
253
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
254
+ snapshots = adda_dir / "STATE" / "checkpoints"
255
+ snapshots.mkdir(exist_ok=True)
256
+ dest = snapshots / f"{stamp}.md"
257
+
258
+ header = f"# Checkpoint {stamp}\n"
259
+ if message:
260
+ header += f"\nmessage: {message}\n"
261
+ dest.write_text(header + "\n" + current.read_text(encoding="utf-8"), encoding="utf-8")
262
+ typer.secho(f"Checkpoint saved -> {dest}", fg=typer.colors.GREEN)
263
+
264
+
265
+ @app.command()
266
+ def sync(
267
+ repo: Path = typer.Argument(Path("."), help="Repo root to scan."),
268
+ out: Optional[Path] = typer.Option(
269
+ None, "--out", "-o", help="Write the output here (default: stdout)."
270
+ ),
271
+ map_: bool = typer.Option(
272
+ False, "--map", help="Emit MODULE_MAP.json (code->doc routing) instead of the skeleton."
273
+ ),
274
+ ) -> None:
275
+ """Derive an ARCHITECTURE skeleton (or, with --map, the code->doc map)."""
276
+ from adda.sync import module_map_json
277
+
278
+ include = None
279
+ if map_ and out is not None and out.is_file():
280
+ # Regenerating must not silently re-drop roots the user opted back in.
281
+ try:
282
+ include = json.loads(out.read_text(encoding="utf-8")).get("include") or None
283
+ except (OSError, ValueError):
284
+ include = None # unreadable or not a map: regenerate from scratch
285
+
286
+ text = module_map_json(repo, include=include) if map_ else skeleton_markdown(repo)
287
+ label = "MODULE_MAP" if map_ else "skeleton"
288
+ if out is not None:
289
+ out.parent.mkdir(parents=True, exist_ok=True)
290
+ out.write_text(text + "\n", encoding="utf-8")
291
+ typer.secho(f"Wrote {label} -> {out}", fg=typer.colors.GREEN)
292
+ else:
293
+ typer.echo(text)
294
+
295
+
296
+ @app.command()
297
+ def diff(
298
+ path: Path = typer.Argument(
299
+ Path("."), help="Project root (contains adda/ and the code)."
300
+ ),
301
+ ) -> None:
302
+ """Detect drift between documented OKF modules and the actual repo."""
303
+ adda_dir = _resolve_adda_dir(path)
304
+ gaps = diff_report(adda_dir.parent, adda_dir)
305
+ if not gaps:
306
+ typer.secho("No drift: documented modules match the repo.", fg=typer.colors.GREEN)
307
+ return
308
+ typer.secho(f"Drift detected: {len(gaps)} gap(s)", fg=typer.colors.RED)
309
+ for g in gaps:
310
+ c = typer.colors.RED if g["severity"] == "high" else typer.colors.YELLOW
311
+ typer.secho(
312
+ f" [{g['severity']:<6}] {g['module']}: "
313
+ f"documented={g['documented']} actual={g['actual']}",
314
+ fg=c,
315
+ )
316
+ raise typer.Exit(1)
317
+
318
+
319
+ @app.command()
320
+ def audit(
321
+ path: Path = typer.Argument(
322
+ Path("."), help="Project root (contains adda/ and the code)."
323
+ ),
324
+ json_out: bool = typer.Option(False, "--json", help="Emit the report as JSON."),
325
+ ) -> None:
326
+ """Detect doc-layer drift: missing, stale, unmapped or orphaned module docs."""
327
+ adda_dir = _resolve_adda_dir(path)
328
+ try:
329
+ findings, skipped = audit_report(adda_dir.parent, adda_dir)
330
+ except FileNotFoundError as exc:
331
+ if json_out:
332
+ typer.echo(json.dumps({"error": str(exc), "findings": [], "skipped": []}, indent=2))
333
+ else:
334
+ typer.secho(str(exc), fg=typer.colors.RED)
335
+ raise typer.Exit(1)
336
+
337
+ if json_out:
338
+ typer.echo(json.dumps({"findings": findings, "skipped": skipped}, indent=2))
339
+ raise typer.Exit(1 if findings else 0)
340
+
341
+ for note in skipped:
342
+ typer.secho(f"[skipped] {note}", fg=typer.colors.YELLOW)
343
+ if not findings:
344
+ typer.secho("No doc drift: every mapped code path has a current doc.", fg=typer.colors.GREEN)
345
+ return
346
+ typer.secho(f"Doc drift detected: {len(findings)} finding(s)", fg=typer.colors.RED)
347
+ colors = {"high": typer.colors.RED, "medium": typer.colors.YELLOW, "low": typer.colors.CYAN}
348
+ for f in findings:
349
+ typer.secho(
350
+ f" [{f['severity']:<6}] {f['issue']:<14} {f['item']}",
351
+ fg=colors.get(f["severity"], typer.colors.WHITE),
352
+ )
353
+ raise typer.Exit(1)
354
+
355
+
356
+ @app.command(name="eval")
357
+ def eval_cmd(
358
+ path: Path = typer.Argument(
359
+ Path("."), help="Project dir containing adda/ (default: current)."
360
+ ),
361
+ json_out: bool = typer.Option(False, "--json", help="Emit the report as JSON."),
362
+ ) -> None:
363
+ """Score rehydration fidelity: how much architecture memory survives `rehydrate`."""
364
+ adda_dir = _resolve_adda_dir(path)
365
+ report = evaluate(compile_okf(adda_dir), adda_dir)
366
+ if json_out:
367
+ typer.echo(json.dumps(report, indent=2))
368
+ return
369
+ if report["overall_fidelity_pct"] is None:
370
+ typer.secho(
371
+ "Rehydration fidelity: n/a - every architecture file is still the "
372
+ "scaffold `adda init` wrote. There is no authored memory to score.",
373
+ fg=typer.colors.YELLOW,
374
+ )
375
+ typer.echo(" write your constraints, modules and decisions into adda/, then re-run.")
376
+ return
377
+ typer.secho(
378
+ f"Rehydration fidelity: {report['overall_fidelity_pct']}% overall, "
379
+ f"{report['load_bearing_fidelity_pct']}% load-bearing",
380
+ fg=typer.colors.GREEN,
381
+ )
382
+ if report["unauthored_template_files"]:
383
+ typer.secho(
384
+ " [partial] still unwritten scaffold: "
385
+ + ", ".join(report["unauthored_template_files"])
386
+ + " - the score counts placeholder text as memory.",
387
+ fg=typer.colors.YELLOW,
388
+ )
389
+ typer.echo(
390
+ f" facts preserved: {report['facts_preserved']}/{report['facts_total']} | "
391
+ f"payload {report['payload_reduction_pct']}% smaller "
392
+ f"({report['minimal_chars']} vs {report['full_chars']} chars)"
393
+ )
394
+ if report["dropped"]:
395
+ typer.echo(" dropped (non-load-bearing): " + ", ".join(report["dropped"]))
396
+
397
+
398
+ hook_app = typer.Typer(help="Pre-commit enforcement: code changes must carry their doc.")
399
+ app.add_typer(hook_app, name="hook")
400
+
401
+
402
+ @hook_app.command("run")
403
+ def hook_run(
404
+ path: Path = typer.Argument(Path("."), help="Repo root (default: current)."),
405
+ ) -> None:
406
+ """Block the commit when staged code is missing its staged doc."""
407
+ if os.environ.get("ADDA_SKIP"):
408
+ typer.secho("[adda] ADDA_SKIP set - doc gate bypassed.", fg=typer.colors.YELLOW)
409
+ return
410
+ try:
411
+ adda_dir = _resolve_adda_dir(path)
412
+ except typer.BadParameter:
413
+ return # no /adda here - nothing to enforce
414
+ gaps = check_staged(adda_dir.parent, adda_dir, staged_paths(adda_dir.parent))
415
+ if not gaps:
416
+ return
417
+ typer.secho(f"Commit blocked: {len(gaps)} code change(s) without their doc.", fg=typer.colors.RED)
418
+ for code, doc in gaps:
419
+ typer.secho(f" {code} -> update and stage {doc}", fg=typer.colors.RED)
420
+ typer.echo("\nUpdate the doc (bump `Last verified`, append to its Change Log), then stage it.")
421
+ typer.echo("To bypass deliberately: `git commit --no-verify`, or set ADDA_SKIP=1.")
422
+ raise typer.Exit(1)
423
+
424
+
425
+ @hook_app.command("install")
426
+ def hook_install(
427
+ path: Path = typer.Argument(Path("."), help="Repo root (default: current)."),
428
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing pre-commit hook."),
429
+ ) -> None:
430
+ """Install the pre-commit doc gate into .git/hooks/pre-commit."""
431
+ hooks = path / ".git" / "hooks"
432
+ if not hooks.is_dir():
433
+ typer.secho(f"{path} is not a git repo (no .git/hooks).", fg=typer.colors.RED)
434
+ raise typer.Exit(1)
435
+ target = hooks / "pre-commit"
436
+ if target.exists() and not force:
437
+ typer.secho(f"{target} already exists. Re-run with --force, or add this line yourself:", fg=typer.colors.RED)
438
+ typer.echo(f' exec "{sys.executable}" -m adda.cli hook run')
439
+ raise typer.Exit(1)
440
+ body = hook_body(sys.executable)
441
+ target.write_text(body, encoding="utf-8", newline="\n")
442
+ # chmod is a no-op on Windows (git's bundled sh runs the hook via its
443
+ # shebang, not the execute bit) but is required on POSIX.
444
+ target.chmod(0o755)
445
+ written = target.read_text(encoding="utf-8")
446
+ if "hook run" not in written:
447
+ typer.secho(f"Wrote {target} but the content looks wrong - install failed.", fg=typer.colors.RED)
448
+ raise typer.Exit(1)
449
+ typer.secho(f"Installed doc gate -> {target}", fg=typer.colors.GREEN)
450
+ typer.echo("Bypass when you must: `git commit --no-verify` or ADDA_SKIP=1.")
451
+
452
+
453
+ if __name__ == "__main__": # `python -m adda.cli` - used by the installed git hook
454
+ app()
adda/compress.py ADDED
@@ -0,0 +1,52 @@
1
+ # Developed by - Vedavyas Vayalpadu - vyas4c3@gmail.com
2
+ # Coded by - Claude Code
3
+ """Optional headroom-ai wrapper (spec §14).
4
+
5
+ headroom-ai is NOT a hard dependency. If it is importable, `compress_text` runs
6
+ its `compress(messages, model=...)` and returns the optimized text; otherwise it
7
+ returns the input unchanged. Either way ADDA keeps working.
8
+
9
+ ponytail: headroom compression is LOSSY (its SmartCrusher drops low-signal JSON
10
+ items), so this is wired behind an opt-in `--compress` flag only — never the
11
+ default. Default `export`/`rehydrate` always emit faithful, valid OKF. The upgrade
12
+ path, if a guaranteed-reversible payload is ever needed, is headroom's CCR mode.
13
+ """
14
+
15
+ from typing import Optional
16
+
17
+
18
+ def available() -> bool:
19
+ """True if headroom-ai can be imported."""
20
+ try:
21
+ import headroom # noqa: F401
22
+
23
+ return True
24
+ except Exception:
25
+ return False
26
+
27
+
28
+ def compress_text(text: str, model: Optional[str] = None) -> tuple[str, dict]:
29
+ """Best-effort compress `text`. Returns (text_out, info).
30
+
31
+ info["applied"] is False (and text_out == text) whenever headroom is absent
32
+ or anything goes wrong — compression never breaks the caller.
33
+ """
34
+ try:
35
+ from headroom import compress as _compress
36
+ except Exception:
37
+ return text, {"applied": False, "reason": "headroom-ai not installed"}
38
+
39
+ try:
40
+ messages = [{"role": "user", "content": text}]
41
+ # model only steers headroom's internal tokenizer; ADDA stays provider-agnostic.
42
+ result = _compress(messages, model=model or "gpt-4o")
43
+ out = result.messages[-1].get("content") if result.messages else None
44
+ if not isinstance(out, str) or not out:
45
+ return text, {"applied": False, "reason": "unexpected headroom output shape"}
46
+ return out, {
47
+ "applied": True,
48
+ "tokens_saved": getattr(result, "tokens_saved", None),
49
+ "compression_ratio": getattr(result, "compression_ratio", None),
50
+ }
51
+ except Exception as exc: # any headroom failure -> safe pass-through
52
+ return text, {"applied": False, "reason": f"headroom error: {exc}"}