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.
Files changed (56) hide show
  1. repocodex/__init__.py +6 -0
  2. repocodex/__main__.py +8 -0
  3. repocodex/cli.py +189 -0
  4. repocodex/commands/__init__.py +3 -0
  5. repocodex/commands/advisory.py +95 -0
  6. repocodex/commands/audit.py +153 -0
  7. repocodex/commands/bootstrap.py +152 -0
  8. repocodex/commands/context.py +28 -0
  9. repocodex/commands/install.py +194 -0
  10. repocodex/commands/reconcile.py +72 -0
  11. repocodex/commands/relocate.py +131 -0
  12. repocodex/commands/repair.py +125 -0
  13. repocodex/commands/validate.py +321 -0
  14. repocodex/commands/write.py +161 -0
  15. repocodex/config.py +162 -0
  16. repocodex/data/action/repocodex.yml +91 -0
  17. repocodex/data/hooks/pre-commit +23 -0
  18. repocodex/data/plugin/hooks/claude-pre-commit +2 -0
  19. repocodex/data/plugin/hooks/cursor-pre-commit +4 -0
  20. repocodex/data/plugin/hooks/pre-commit +23 -0
  21. repocodex/data/plugin/mcp.json +8 -0
  22. repocodex/data/plugin/plugin.json +7 -0
  23. repocodex/data/plugin/skills/repocodex-coding/SKILL.md +70 -0
  24. repocodex/data/plugin/skills/repocodex-review/SKILL.md +36 -0
  25. repocodex/data/rules/claude/CLAUDE.md +3 -0
  26. repocodex/data/rules/cursor/repocodex.mdc +6 -0
  27. repocodex/data/skills/repocodex-coding/SKILL.md +70 -0
  28. repocodex/data/skills/repocodex-review/SKILL.md +36 -0
  29. repocodex/engine/__init__.py +3 -0
  30. repocodex/engine/ack.py +34 -0
  31. repocodex/engine/blocking.py +13 -0
  32. repocodex/engine/code_impact.py +51 -0
  33. repocodex/engine/contradiction.py +76 -0
  34. repocodex/engine/dilution.py +69 -0
  35. repocodex/engine/gate.py +329 -0
  36. repocodex/engine/impact.py +84 -0
  37. repocodex/engine/liveness.py +203 -0
  38. repocodex/engine/match.py +232 -0
  39. repocodex/engine/ratchet.py +289 -0
  40. repocodex/engine/relocate.py +139 -0
  41. repocodex/mcp_server.py +126 -0
  42. repocodex/metrics.py +73 -0
  43. repocodex/retrieval.py +166 -0
  44. repocodex/schema.py +381 -0
  45. repocodex/store/__init__.py +3 -0
  46. repocodex/store/bundle.py +267 -0
  47. repocodex/store/reverse_index.py +250 -0
  48. repocodex/tools/__init__.py +3 -0
  49. repocodex/tools/git.py +76 -0
  50. repocodex/tools/ripgrep.py +102 -0
  51. repocodex-0.0.1.dist-info/METADATA +127 -0
  52. repocodex-0.0.1.dist-info/RECORD +56 -0
  53. repocodex-0.0.1.dist-info/WHEEL +5 -0
  54. repocodex-0.0.1.dist-info/entry_points.txt +2 -0
  55. repocodex-0.0.1.dist-info/licenses/LICENSE +21 -0
  56. repocodex-0.0.1.dist-info/top_level.txt +1 -0
repocodex/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """RepoCodex engine: repository-native executable memory for coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ENGINE_VERSION = "0.0.1"
6
+ __version__ = ENGINE_VERSION
repocodex/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run the RepoCodex CLI as `python -m repocodex`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from repocodex.cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
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,3 @@
1
+ """CLI command implementations invoked from `repocodex.cli`."""
2
+
3
+ from __future__ import annotations
@@ -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)