repocodex 0.0.1__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.
- repocodex/__init__.py +6 -0
- repocodex/__main__.py +8 -0
- repocodex/cli.py +189 -0
- repocodex/commands/__init__.py +3 -0
- repocodex/commands/advisory.py +95 -0
- repocodex/commands/audit.py +153 -0
- repocodex/commands/bootstrap.py +152 -0
- repocodex/commands/context.py +28 -0
- repocodex/commands/install.py +194 -0
- repocodex/commands/reconcile.py +72 -0
- repocodex/commands/relocate.py +131 -0
- repocodex/commands/repair.py +125 -0
- repocodex/commands/validate.py +321 -0
- repocodex/commands/write.py +161 -0
- repocodex/config.py +162 -0
- repocodex/data/action/repocodex.yml +91 -0
- repocodex/data/hooks/pre-commit +23 -0
- repocodex/data/plugin/hooks/claude-pre-commit +2 -0
- repocodex/data/plugin/hooks/cursor-pre-commit +4 -0
- repocodex/data/plugin/hooks/pre-commit +23 -0
- repocodex/data/plugin/mcp.json +8 -0
- repocodex/data/plugin/plugin.json +7 -0
- repocodex/data/plugin/skills/repocodex-coding/SKILL.md +70 -0
- repocodex/data/plugin/skills/repocodex-review/SKILL.md +36 -0
- repocodex/data/rules/claude/CLAUDE.md +3 -0
- repocodex/data/rules/cursor/repocodex.mdc +6 -0
- repocodex/data/skills/repocodex-coding/SKILL.md +70 -0
- repocodex/data/skills/repocodex-review/SKILL.md +36 -0
- repocodex/engine/__init__.py +3 -0
- repocodex/engine/ack.py +34 -0
- repocodex/engine/blocking.py +13 -0
- repocodex/engine/code_impact.py +51 -0
- repocodex/engine/contradiction.py +76 -0
- repocodex/engine/dilution.py +69 -0
- repocodex/engine/gate.py +329 -0
- repocodex/engine/impact.py +84 -0
- repocodex/engine/liveness.py +203 -0
- repocodex/engine/match.py +232 -0
- repocodex/engine/ratchet.py +289 -0
- repocodex/engine/relocate.py +139 -0
- repocodex/mcp_server.py +126 -0
- repocodex/metrics.py +73 -0
- repocodex/retrieval.py +166 -0
- repocodex/schema.py +381 -0
- repocodex/store/__init__.py +3 -0
- repocodex/store/bundle.py +267 -0
- repocodex/store/reverse_index.py +250 -0
- repocodex/tools/__init__.py +3 -0
- repocodex/tools/git.py +76 -0
- repocodex/tools/ripgrep.py +102 -0
- repocodex-0.0.1.dist-info/METADATA +127 -0
- repocodex-0.0.1.dist-info/RECORD +56 -0
- repocodex-0.0.1.dist-info/WHEEL +5 -0
- repocodex-0.0.1.dist-info/entry_points.txt +2 -0
- repocodex-0.0.1.dist-info/licenses/LICENSE +21 -0
- repocodex-0.0.1.dist-info/top_level.txt +1 -0
repocodex/__init__.py
ADDED
repocodex/__main__.py
ADDED
repocodex/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Typer CLI for RepoCodex. Commands print JSON envelopes and exit non-zero on failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from repocodex.commands.advisory import advisory as run_advisory
|
|
12
|
+
from repocodex.commands.audit import audit as run_audit
|
|
13
|
+
from repocodex.commands.bootstrap import bootstrap as run_bootstrap
|
|
14
|
+
from repocodex.commands.context import context_for
|
|
15
|
+
from repocodex.commands.install import install as run_install
|
|
16
|
+
from repocodex.commands.reconcile import apply_anchor_patch, reconcile_memory
|
|
17
|
+
from repocodex.commands.relocate import relocate_memory
|
|
18
|
+
from repocodex.commands.repair import repair as run_repair
|
|
19
|
+
from repocodex.commands.validate import validate as run_validate
|
|
20
|
+
from repocodex.commands.write import write_memory
|
|
21
|
+
from repocodex.config import EngineVersionMismatch
|
|
22
|
+
from repocodex.schema import envelope
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, help="RepoCodex executable memory CLI")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _emit(payload: dict, exit_code: int = 0) -> None:
|
|
28
|
+
"""Print ``payload`` as JSON and exit with ``exit_code``."""
|
|
29
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
30
|
+
raise typer.Exit(exit_code)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _repo() -> Path:
|
|
34
|
+
"""Return the process working directory as the target repository."""
|
|
35
|
+
return Path.cwd()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _guarded(fn):
|
|
39
|
+
"""Run ``fn`` and emit an engine-version-mismatch envelope on pin failure."""
|
|
40
|
+
try:
|
|
41
|
+
return fn()
|
|
42
|
+
except EngineVersionMismatch as exc:
|
|
43
|
+
_emit(exc.to_json(), 1)
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("validate")
|
|
48
|
+
def validate_command(
|
|
49
|
+
diff: bool = typer.Option(False, "--diff", help="Attest anchors intersecting the diff"),
|
|
50
|
+
base: Optional[str] = typer.Option(None, "--base", help="Git diff base, e.g. origin/main...HEAD"),
|
|
51
|
+
staged: bool = typer.Option(False, "--staged"),
|
|
52
|
+
all_concepts: bool = typer.Option(False, "--all"),
|
|
53
|
+
check: bool = typer.Option(False, "--check", help="Exit 1 on deterministic blocking outcomes"),
|
|
54
|
+
hook: bool = typer.Option(False, "--hook"),
|
|
55
|
+
memory_exempt: bool = typer.Option(False, "--memory-exempt"),
|
|
56
|
+
review_ack: bool = typer.Option(False, "--review-ack", hidden=True),
|
|
57
|
+
ack_file: Optional[Path] = typer.Option(None, "--ack-file", help="Tracked review-agent acknowledgment record"),
|
|
58
|
+
apply_patches: bool = typer.Option(False, "--apply-patches"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Attest anchors on the working tree or diff. JSON includes engine_version."""
|
|
61
|
+
payload = _guarded(
|
|
62
|
+
lambda: run_validate(
|
|
63
|
+
_repo(),
|
|
64
|
+
base=base,
|
|
65
|
+
staged=staged or hook,
|
|
66
|
+
all_concepts=all_concepts or not diff,
|
|
67
|
+
memory_exempt=memory_exempt,
|
|
68
|
+
review_ack=review_ack,
|
|
69
|
+
ack_file=str(ack_file) if ack_file else None,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if apply_patches:
|
|
73
|
+
for patch in payload.get("patches") or []:
|
|
74
|
+
apply_anchor_patch(_repo(), patch)
|
|
75
|
+
payload.setdefault("applied_patches", []).append(patch)
|
|
76
|
+
code = 1 if (check or hook) and payload.get("blocking") else 0
|
|
77
|
+
_emit(payload, code)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("write")
|
|
81
|
+
def write_command(
|
|
82
|
+
concept: Optional[Path] = typer.Argument(None),
|
|
83
|
+
identity: Optional[str] = typer.Option(None, "--identity"),
|
|
84
|
+
stdin: bool = typer.Option(False, "--stdin"),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Write-gate a concept into .context/."""
|
|
87
|
+
text = None
|
|
88
|
+
if stdin:
|
|
89
|
+
text = typer.get_text_stream("stdin").read()
|
|
90
|
+
if concept is None and text is None:
|
|
91
|
+
raise typer.BadParameter("provide a concept file or --stdin")
|
|
92
|
+
payload = _guarded(lambda: write_memory(_repo(), concept or Path("."), identity=identity, stdin_text=text))
|
|
93
|
+
_emit(payload, 0 if payload.get("accepted") else 1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command("relocate")
|
|
97
|
+
def relocate_command(
|
|
98
|
+
identity: Optional[str] = typer.Argument(None),
|
|
99
|
+
mismatched: bool = typer.Option(
|
|
100
|
+
False, "--mismatched", help="Move all authored-type concepts with wrong prefixes"
|
|
101
|
+
),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Move authored concepts into the type-folder identity required by their type."""
|
|
104
|
+
if not mismatched and not identity:
|
|
105
|
+
raise typer.BadParameter("provide an identity or --mismatched")
|
|
106
|
+
payload = _guarded(lambda: relocate_memory(_repo(), identity, mismatched=mismatched))
|
|
107
|
+
_emit(payload)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("reconcile")
|
|
111
|
+
def reconcile_command(
|
|
112
|
+
concept: Optional[Path] = typer.Argument(None),
|
|
113
|
+
identity: Optional[str] = typer.Option(None, "--identity"),
|
|
114
|
+
apply_patch: Optional[str] = typer.Option(None, "--apply-patch", help="JSON patch object"),
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Repair DRIFT with gate-enforced new anchors, or apply a REANCHOR patch."""
|
|
117
|
+
repo = _repo()
|
|
118
|
+
if apply_patch:
|
|
119
|
+
patch = json.loads(apply_patch)
|
|
120
|
+
path = apply_anchor_patch(repo, patch)
|
|
121
|
+
_emit(envelope({"applied": True, "path": str(path)}))
|
|
122
|
+
if concept is None:
|
|
123
|
+
raise typer.BadParameter("provide a concept file")
|
|
124
|
+
payload = _guarded(lambda: reconcile_memory(repo, concept, identity=identity))
|
|
125
|
+
_emit(payload, 0 if payload.get("accepted") else 1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command("context")
|
|
129
|
+
def context_command(
|
|
130
|
+
paths: list[Path] = typer.Argument(..., metavar="PATHS"),
|
|
131
|
+
drafts: bool = typer.Option(False, "--drafts"),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Staged retrieval: reverse index → catalogs → bodies + one link-hop of titles."""
|
|
134
|
+
payload = _guarded(lambda: context_for(_repo(), [str(p) for p in paths], include_drafts=drafts))
|
|
135
|
+
_emit(payload)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command("repair")
|
|
139
|
+
def repair_command() -> None:
|
|
140
|
+
"""Invoke a repair agent against the current RECONCILE state."""
|
|
141
|
+
payload = _guarded(lambda: run_repair(_repo()))
|
|
142
|
+
code = 1 if payload.get("error") else 0
|
|
143
|
+
_emit(payload, code)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command("install")
|
|
147
|
+
def install_command(
|
|
148
|
+
mcp: bool = typer.Option(False, "--mcp", help="Register optional MCP wrapper"),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Install pre-commit hook, GitHub Action, skills, and optional MCP."""
|
|
151
|
+
payload = _guarded(lambda: run_install(_repo(), mcp=mcp))
|
|
152
|
+
_emit(payload, 0 if payload.get("ok", True) else 1)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command("bootstrap")
|
|
156
|
+
def bootstrap_command() -> None:
|
|
157
|
+
"""Mine history/comments/docs; keep only gate-passing drafts."""
|
|
158
|
+
_emit(_guarded(lambda: run_bootstrap(_repo())))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command("audit")
|
|
162
|
+
def audit_command(
|
|
163
|
+
sample_size: int = typer.Option(10, "--sample-size"),
|
|
164
|
+
seed: int = typer.Option(0, "--seed"),
|
|
165
|
+
findings: Optional[Path] = typer.Option(None, "--findings", help="Out-of-band screening result JSON"),
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Emit a screening payload for out-of-band review. No model is invoked."""
|
|
168
|
+
_emit(
|
|
169
|
+
_guarded(
|
|
170
|
+
lambda: run_audit(_repo(), sample_size=sample_size, seed=seed, findings_path=findings)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command("advisory")
|
|
176
|
+
def advisory_command(
|
|
177
|
+
base: Optional[str] = typer.Option(None, "--base"),
|
|
178
|
+
staged: bool = typer.Option(False, "--staged"),
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Agent-judged findings for the advisory CI check. Never affects the required verdict."""
|
|
181
|
+
_emit(_guarded(lambda: run_advisory(_repo(), base=base, staged=staged)))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command("mcp")
|
|
185
|
+
def mcp_command() -> None:
|
|
186
|
+
"""Run the optional MCP server wrapping the CLI."""
|
|
187
|
+
from repocodex.mcp_server import run_mcp
|
|
188
|
+
|
|
189
|
+
run_mcp()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Rank code-side impact and wrap optional agent judgments into a non-blocking advisory envelope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from repocodex.commands.validate import changed_files
|
|
9
|
+
from repocodex.config import load_config
|
|
10
|
+
from repocodex.engine.code_impact import rank_code_hits
|
|
11
|
+
from repocodex.schema import envelope
|
|
12
|
+
|
|
13
|
+
NOT_EVALUATED = "not_evaluated"
|
|
14
|
+
EVALUATED = "evaluated"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _category(status: str, findings: list[dict] | None = None) -> dict[str, Any]:
|
|
18
|
+
"""Build a category payload, attaching findings only when evaluated."""
|
|
19
|
+
payload: dict[str, Any] = {"status": status}
|
|
20
|
+
if status == EVALUATED:
|
|
21
|
+
payload["findings"] = findings or []
|
|
22
|
+
return payload
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def advisory(
|
|
26
|
+
repo: Path,
|
|
27
|
+
*,
|
|
28
|
+
base: str | None = None,
|
|
29
|
+
staged: bool = False,
|
|
30
|
+
judgments: dict[str, list[dict]] | None = None,
|
|
31
|
+
) -> dict:
|
|
32
|
+
"""Return a non-blocking advisory envelope for the current diff.
|
|
33
|
+
|
|
34
|
+
Skips ``.context/`` paths and ``reverse-index.md``. Judgment categories
|
|
35
|
+
without a key in ``judgments`` stay ``not_evaluated``.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Envelope with ``kind`` ``advisory``, ``code_side_impact`` (path/hits
|
|
39
|
+
rows), ``prose_versus_diff``, ``skipped_recipe_steps``, ``churn_flags``
|
|
40
|
+
(each ``status`` plus optional ``findings``), ``agent_judgment``, and
|
|
41
|
+
``required_verdict_unaffected`` always ``True``.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
config = load_config(repo)
|
|
45
|
+
files = changed_files(repo, base=base, staged=staged)
|
|
46
|
+
code_side: list[dict] = []
|
|
47
|
+
for path in files:
|
|
48
|
+
if path.startswith(".context/") or path.endswith("reverse-index.md"):
|
|
49
|
+
continue
|
|
50
|
+
symbols = Path(path).stem
|
|
51
|
+
hits = rank_code_hits(path, symbols, repo, cap=config.impact_read_cap, exclusions=config.all_exclusions)
|
|
52
|
+
if hits:
|
|
53
|
+
code_side.append({"path": path, "hits": hits})
|
|
54
|
+
|
|
55
|
+
judgments = judgments or {}
|
|
56
|
+
|
|
57
|
+
def category_for(name: str) -> dict[str, Any]:
|
|
58
|
+
if name in judgments:
|
|
59
|
+
return _category(EVALUATED, judgments[name])
|
|
60
|
+
return _category(NOT_EVALUATED)
|
|
61
|
+
|
|
62
|
+
prose = category_for("prose_versus_diff")
|
|
63
|
+
skipped = category_for("skipped_recipe_steps")
|
|
64
|
+
churn = category_for("churn_flags")
|
|
65
|
+
any_judgment = any(cat["status"] == EVALUATED for cat in (prose, skipped, churn))
|
|
66
|
+
return envelope(
|
|
67
|
+
{
|
|
68
|
+
"kind": "advisory",
|
|
69
|
+
"code_side_impact": code_side,
|
|
70
|
+
"prose_versus_diff": prose,
|
|
71
|
+
"skipped_recipe_steps": skipped,
|
|
72
|
+
"churn_flags": churn,
|
|
73
|
+
"agent_judgment": any_judgment,
|
|
74
|
+
"required_verdict_unaffected": True,
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scenario_integrity_status(root: Path) -> dict:
|
|
80
|
+
"""Report whether an OKF concept bundle exists for scenario integrity.
|
|
81
|
+
|
|
82
|
+
Never falls back to a test table.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``{"status": "unsatisfied", "reason": "no_okf_bundle"}`` when no
|
|
86
|
+
concepts are loaded, otherwise
|
|
87
|
+
``{"status": "available", "reason": "agent_read_okf"}``.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
from repocodex.store.bundle import load_concepts
|
|
91
|
+
|
|
92
|
+
concepts = load_concepts(root)
|
|
93
|
+
if not concepts:
|
|
94
|
+
return {"status": "unsatisfied", "reason": "no_okf_bundle"}
|
|
95
|
+
return {"status": "available", "reason": "agent_read_okf"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Sample stable concepts for out-of-band screening and retire orphan or expired drafts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from repocodex.config import load_config
|
|
11
|
+
from repocodex.engine.contradiction import contradiction_flags
|
|
12
|
+
from repocodex.engine.gate import _term_count
|
|
13
|
+
from repocodex.engine.match import evaluate_file, read_pinned
|
|
14
|
+
from repocodex.schema import ConceptStatus, envelope, utc_now
|
|
15
|
+
from repocodex.store.bundle import deprecate_concept, load_concepts
|
|
16
|
+
from repocodex.store.reverse_index import merged_index
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _expired(stale_after: str | None) -> bool:
|
|
20
|
+
"""Return True when ``stale_after`` parses as a UTC date already past."""
|
|
21
|
+
if not stale_after:
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
return date.fromisoformat(stale_after[:10]) < datetime.now(timezone.utc).date()
|
|
25
|
+
except ValueError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def gc(repo: Path) -> list[str]:
|
|
30
|
+
"""Deprecate orphan concepts and expired drafts.
|
|
31
|
+
|
|
32
|
+
A concept is retired when nothing inbound-links it and no anchor still
|
|
33
|
+
matches, or when it is a draft past ``stale_after``. Deprecation uses
|
|
34
|
+
reason ``gc``.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Identities that were deprecated.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
retired: list[str] = []
|
|
41
|
+
concepts = load_concepts(repo)
|
|
42
|
+
inbound: set[str] = set()
|
|
43
|
+
from repocodex.engine.impact import linked_identities
|
|
44
|
+
|
|
45
|
+
for doc in concepts:
|
|
46
|
+
inbound.update(linked_identities(doc))
|
|
47
|
+
for doc in concepts:
|
|
48
|
+
live_anchor = False
|
|
49
|
+
for anchor in doc.anchors:
|
|
50
|
+
matched = evaluate_file(anchor, repo)
|
|
51
|
+
if matched.hits_for_best() > 0:
|
|
52
|
+
live_anchor = True
|
|
53
|
+
break
|
|
54
|
+
orphan = doc.identity not in inbound and not live_anchor
|
|
55
|
+
expired_draft = doc.status == ConceptStatus.draft and _expired(doc.frontmatter.stale_after)
|
|
56
|
+
if orphan or expired_draft:
|
|
57
|
+
deprecate_concept(repo, doc.identity, reason="gc")
|
|
58
|
+
retired.append(doc.identity)
|
|
59
|
+
return retired
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def audit(
|
|
63
|
+
repo: Path,
|
|
64
|
+
*,
|
|
65
|
+
sample_size: int | None = None,
|
|
66
|
+
seed: int = 0,
|
|
67
|
+
findings_path: Path | None = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Emit a screening payload for out-of-band review; never invoke a model.
|
|
70
|
+
|
|
71
|
+
Samples stable concepts (all of them when the set is at most ``n``),
|
|
72
|
+
scores term distinctiveness, flags contradictions, and runs :func:`gc`.
|
|
73
|
+
Optional ``findings_path`` JSON (a list, or ``findings`` / ``results``)
|
|
74
|
+
becomes ``CONTRADICTION`` proposals for the attested-write path.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Envelope with ``sampled``, ``distinctiveness``, ``weak_anchors``,
|
|
78
|
+
``contradictions``, ``contradiction_proposals``, ``gc_deprecated``,
|
|
79
|
+
``screening``, ``model_invoked`` always ``False``, ``note``, and
|
|
80
|
+
``at``.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
config = load_config(repo)
|
|
84
|
+
concepts = [doc for doc in load_concepts(repo) if doc.status == ConceptStatus.stable]
|
|
85
|
+
n = sample_size or config.audit_sample_size
|
|
86
|
+
rng = random.Random(seed)
|
|
87
|
+
sample = concepts if len(concepts) <= n else rng.sample(concepts, n)
|
|
88
|
+
scored = []
|
|
89
|
+
weak = []
|
|
90
|
+
screening = []
|
|
91
|
+
for doc in sample:
|
|
92
|
+
term_counts: dict[str, int] = {}
|
|
93
|
+
for anchor in doc.anchors:
|
|
94
|
+
for term in anchor.all_of:
|
|
95
|
+
term_counts[term] = _term_count(term, config)
|
|
96
|
+
distinctive = any(
|
|
97
|
+
count < config.distinctiveness_ceiling for count in term_counts.values()
|
|
98
|
+
)
|
|
99
|
+
if not distinctive:
|
|
100
|
+
weak.append(doc.identity)
|
|
101
|
+
region = ""
|
|
102
|
+
text = read_pinned(repo, anchor.path)
|
|
103
|
+
if text:
|
|
104
|
+
matched = evaluate_file(anchor, repo, default_scope=config.scope_lines)
|
|
105
|
+
if matched.best:
|
|
106
|
+
region = matched.best.source(text.splitlines())
|
|
107
|
+
screening.append(
|
|
108
|
+
{
|
|
109
|
+
"identity": doc.identity,
|
|
110
|
+
"title": doc.frontmatter.title,
|
|
111
|
+
"body": doc.body,
|
|
112
|
+
"pinned_region": region,
|
|
113
|
+
"term_counts": term_counts,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
scored.append({"identity": doc.identity, "term_counts": term_counts})
|
|
117
|
+
contradictions = contradiction_flags(load_concepts(repo), repo)
|
|
118
|
+
retired = gc(repo)
|
|
119
|
+
proposals: list[dict] = []
|
|
120
|
+
if findings_path:
|
|
121
|
+
data = json.loads(Path(findings_path).read_text(encoding="utf-8"))
|
|
122
|
+
items = data if isinstance(data, list) else data.get("findings") or data.get("results") or []
|
|
123
|
+
for item in items:
|
|
124
|
+
identity = item.get("identity") or item.get("concept")
|
|
125
|
+
if not identity:
|
|
126
|
+
continue
|
|
127
|
+
proposals.append(
|
|
128
|
+
{
|
|
129
|
+
"kind": "CONTRADICTION",
|
|
130
|
+
"reason": "audit_screening",
|
|
131
|
+
"concept": identity,
|
|
132
|
+
"proposal": True,
|
|
133
|
+
"detail": item,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
return envelope(
|
|
137
|
+
{
|
|
138
|
+
"sampled": [doc.identity for doc in sample],
|
|
139
|
+
"distinctiveness": scored,
|
|
140
|
+
"weak_anchors": weak,
|
|
141
|
+
"contradictions": contradictions,
|
|
142
|
+
"contradiction_proposals": proposals,
|
|
143
|
+
"gc_deprecated": retired,
|
|
144
|
+
"screening": screening,
|
|
145
|
+
"model_invoked": False,
|
|
146
|
+
"note": (
|
|
147
|
+
"repocodex audit emits a screening payload for out-of-band model review; "
|
|
148
|
+
"no model is invoked inside the engine. Returned findings become CONTRADICTION "
|
|
149
|
+
"proposals resolved through the attested-write path, never automatic edits."
|
|
150
|
+
),
|
|
151
|
+
"at": utc_now(),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Mine why-comments and commit history into draft TechnicalDecision concepts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from repocodex.config import load_config
|
|
11
|
+
from repocodex.engine.gate import evaluate_write
|
|
12
|
+
from repocodex.schema import (
|
|
13
|
+
Anchor,
|
|
14
|
+
ConceptDocument,
|
|
15
|
+
ConceptFrontmatter,
|
|
16
|
+
ConceptStatus,
|
|
17
|
+
ConceptType,
|
|
18
|
+
Source,
|
|
19
|
+
Verification,
|
|
20
|
+
envelope,
|
|
21
|
+
)
|
|
22
|
+
from repocodex.store.bundle import write_concept
|
|
23
|
+
from repocodex.store.reverse_index import regenerate_all
|
|
24
|
+
from repocodex.tools.git import git_ls_files, run_git
|
|
25
|
+
|
|
26
|
+
WHY_COMMENT = re.compile(r"(?://|#|/\*)\s*why:\s*(.+)")
|
|
27
|
+
DOC_WHY = re.compile(r"(?i)^(?:why|decision|invariant)\s*[:\-]\s*(.+)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _stale_after(days: int = 30) -> str:
|
|
31
|
+
"""Return a UTC ISO date ``days`` from now for draft expiry."""
|
|
32
|
+
when = datetime.now(timezone.utc) + timedelta(days=days)
|
|
33
|
+
return when.date().isoformat()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stable_id(rel: str, note: str) -> str:
|
|
37
|
+
"""Build a deterministic ``decisions/`` identity from path and note."""
|
|
38
|
+
digest = hashlib.sha256(f"{rel}\n{note}".encode("utf-8")).hexdigest()[:12]
|
|
39
|
+
return f"decisions/{Path(rel).stem}-{digest}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _commit_for(repo: Path, rel: str) -> str | None:
|
|
43
|
+
"""Return the latest commit SHA that touched ``rel``, if any."""
|
|
44
|
+
result = run_git(["log", "-n", "1", "--pretty=%H", "--", rel], cwd=repo)
|
|
45
|
+
sha = result.stdout.strip()
|
|
46
|
+
return sha or None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _candidates_from_comments(repo: Path, files: list[str]) -> list[tuple[str, str, list[str], str | None]]:
|
|
50
|
+
"""Collect why-comments and markdown decision lines from tracked files."""
|
|
51
|
+
SKIP_SUFFIX = {".py", ".ts", ".js", ".go", ".rs", ".java", ".md"}
|
|
52
|
+
found: list[tuple[str, str, list[str], str | None]] = []
|
|
53
|
+
for rel in files:
|
|
54
|
+
if Path(rel).suffix not in SKIP_SUFFIX:
|
|
55
|
+
continue
|
|
56
|
+
path = repo / rel
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
text = path.read_text(encoding="utf-8")
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
for match in WHY_COMMENT.finditer(text):
|
|
64
|
+
note = match.group(1).strip()
|
|
65
|
+
terms = [part for part in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", note)][:3]
|
|
66
|
+
if terms:
|
|
67
|
+
found.append((rel, note, terms, _commit_for(repo, rel)))
|
|
68
|
+
if rel.endswith(".md") and not rel.startswith(".context/"):
|
|
69
|
+
for match in DOC_WHY.finditer(text):
|
|
70
|
+
note = match.group(1).strip()
|
|
71
|
+
terms = [part for part in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", note)][:3]
|
|
72
|
+
if terms:
|
|
73
|
+
found.append((rel, note, terms, _commit_for(repo, rel)))
|
|
74
|
+
return found
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _candidates_from_history(repo: Path) -> list[tuple[str, str, list[str], str | None]]:
|
|
78
|
+
"""Collect why-like subjects from the last 100 commits and their first path."""
|
|
79
|
+
log = run_git(["log", "--pretty=%H%x09%s", "-n", "100"], cwd=repo)
|
|
80
|
+
found: list[tuple[str, str, list[str], str | None]] = []
|
|
81
|
+
for line in log.stdout.splitlines():
|
|
82
|
+
if "\t" not in line:
|
|
83
|
+
continue
|
|
84
|
+
sha, subject = line.split("\t", 1)
|
|
85
|
+
lower = subject.lower()
|
|
86
|
+
if not any(token in lower for token in ("why:", "because", "decision:", "do not ")):
|
|
87
|
+
continue
|
|
88
|
+
files = run_git(["diff-tree", "--no-commit-id", "--name-only", "-r", sha], cwd=repo)
|
|
89
|
+
paths = [p for p in files.stdout.splitlines() if p.strip()]
|
|
90
|
+
if not paths:
|
|
91
|
+
continue
|
|
92
|
+
rel = paths[0]
|
|
93
|
+
terms = [part for part in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", subject)][:3]
|
|
94
|
+
if terms:
|
|
95
|
+
found.append((rel, subject.strip(), terms, sha))
|
|
96
|
+
return found
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def bootstrap(repo: Path) -> dict:
|
|
100
|
+
"""Write gate-passing draft decisions mined from comments and history.
|
|
101
|
+
|
|
102
|
+
Candidates without an evidencing commit are rejected with
|
|
103
|
+
``no_evidencing_source``. Gate failures keep the identity and
|
|
104
|
+
``tighten`` list. Successful writes regenerate the reverse index.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Envelope with ``kept`` identities, ``rejected`` rows, and
|
|
108
|
+
``status`` ``draft``.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
config = load_config(repo)
|
|
112
|
+
kept: list[str] = []
|
|
113
|
+
rejected: list[dict] = []
|
|
114
|
+
tracked = git_ls_files(repo)
|
|
115
|
+
candidates = [
|
|
116
|
+
*_candidates_from_comments(repo, tracked),
|
|
117
|
+
*_candidates_from_history(repo),
|
|
118
|
+
]
|
|
119
|
+
seen: set[str] = set()
|
|
120
|
+
for rel, note, terms, source in candidates:
|
|
121
|
+
identity = _stable_id(rel, note)
|
|
122
|
+
if identity in seen:
|
|
123
|
+
continue
|
|
124
|
+
seen.add(identity)
|
|
125
|
+
if not source:
|
|
126
|
+
rejected.append({"identity": identity, "tighten": ["no_evidencing_source"]})
|
|
127
|
+
continue
|
|
128
|
+
doc = ConceptDocument(
|
|
129
|
+
identity=identity,
|
|
130
|
+
frontmatter=ConceptFrontmatter(
|
|
131
|
+
type=ConceptType.TechnicalDecision,
|
|
132
|
+
title=note[:80],
|
|
133
|
+
status=ConceptStatus.draft,
|
|
134
|
+
stale_after=_stale_after(),
|
|
135
|
+
sources=[Source(resource=f"git://commit/{source}", title="commit", id=source)],
|
|
136
|
+
verification=Verification(
|
|
137
|
+
engine="ripgrep",
|
|
138
|
+
anchors=[Anchor(path=rel, all_of=terms)],
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
body=f"Bootstrapped from `{rel}`.\n\n{note}\n",
|
|
142
|
+
)
|
|
143
|
+
gate = evaluate_write(doc, config)
|
|
144
|
+
if not gate.accepted:
|
|
145
|
+
rejected.append({"identity": identity, "tighten": gate.tighten})
|
|
146
|
+
continue
|
|
147
|
+
write_concept(repo, doc)
|
|
148
|
+
kept.append(identity)
|
|
149
|
+
|
|
150
|
+
if kept:
|
|
151
|
+
regenerate_all(repo)
|
|
152
|
+
return envelope({"kept": kept, "rejected": rejected, "status": "draft"})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Retrieve ranked concepts for given paths and record a tokens-per-turn metric."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from repocodex import ENGINE_VERSION
|
|
8
|
+
from repocodex.metrics import record_metric
|
|
9
|
+
from repocodex.retrieval import retrieve
|
|
10
|
+
from repocodex.schema import envelope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def context_for(repo: Path, paths: list[str], *, include_drafts: bool = False) -> dict:
|
|
14
|
+
"""Return retrieved concepts for ``paths`` plus a tokens-per-turn estimate.
|
|
15
|
+
|
|
16
|
+
Drafts are omitted unless ``include_drafts``. Records a ``context`` metric
|
|
17
|
+
with ``tokens_per_turn`` (body chars / 4) and the requested paths.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Envelope merging retrieve keys ``paths``, ``concepts``, ``related``,
|
|
21
|
+
and ``catalog`` with ``tokens_per_turn`` and ``engine_version``.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
payload = retrieve(repo, paths, include_drafts=include_drafts)
|
|
25
|
+
chars = sum(len(item.get("body") or "") for item in payload.get("concepts") or [])
|
|
26
|
+
tokens_per_turn = chars / 4.0
|
|
27
|
+
record_metric(repo, "context", {"tokens_per_turn": tokens_per_turn, "paths": list(paths)})
|
|
28
|
+
return envelope({**payload, "tokens_per_turn": tokens_per_turn}, engine_version=ENGINE_VERSION)
|