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 +5 -0
- adda/audit.py +155 -0
- adda/cli.py +454 -0
- adda/compress.py +52 -0
- adda/diff.py +55 -0
- adda/evaluate.py +152 -0
- adda/hook.py +85 -0
- adda/modulemap.py +65 -0
- adda/okf.py +194 -0
- adda/rehydrate.py +24 -0
- adda/sentinel.py +77 -0
- adda/sync.py +261 -0
- adda/templates/adda/API_CONTRACTS.md +9 -0
- adda/templates/adda/ARCHITECTURE.md +22 -0
- adda/templates/adda/DECISIONS/0001-record-architecture-decisions.md +17 -0
- adda/templates/adda/DOMAIN_MODEL.md +10 -0
- adda/templates/adda/PROMPT_BASE/SYSTEM.md +14 -0
- adda/templates/adda/STATE/CURRENT.md +10 -0
- adda/templates/adda/VERSION.md +10 -0
- adda-0.4.0.dist-info/METADATA +349 -0
- adda-0.4.0.dist-info/RECORD +24 -0
- adda-0.4.0.dist-info/WHEEL +4 -0
- adda-0.4.0.dist-info/entry_points.txt +2 -0
- adda-0.4.0.dist-info/licenses/LICENSE +21 -0
adda/__init__.py
ADDED
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}"}
|