genesis-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
genesis_cli/main.py ADDED
@@ -0,0 +1,228 @@
1
+ """`genesis` — the Genesis Agent Workbench CLI.
2
+
3
+ Commands:
4
+ genesis adopt [path] Audit a repo for agent-readiness: run delivery gates,
5
+ completeness + env analysis, score it, and draft
6
+ AGENTS.md. Deterministic — no API keys, no server.
7
+ genesis gates [path] Run just the delivery gates (no full analysis) and
8
+ print a per-gate table. Fast local iteration + CI.
9
+ genesis init [path] Scaffold a new/bare repo so agents start agent-ready:
10
+ AGENTS.md, .gitignore, .env.example, audit workflow,
11
+ gate config. Idempotent — never overwrites.
12
+ genesis --version Print the installed genesis-cli version.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from .adopt import run_audit
23
+ from .agents_md import render_agents_md
24
+ from .baseline import append_history, compare, render_trend
25
+ from .report import render_html, render_terminal
26
+ from .sarif import render_sarif
27
+
28
+
29
+ def _version() -> str:
30
+ """The installed genesis-cli version, single-sourced from package metadata.
31
+
32
+ Reading the *distribution* metadata keeps pyproject the one place a version
33
+ is written. Running from a source checkout without an install has no
34
+ metadata to read, so fall back rather than crash `--version`.
35
+ """
36
+ from importlib.metadata import PackageNotFoundError, version
37
+
38
+ try:
39
+ return version("genesis-cli")
40
+ except PackageNotFoundError:
41
+ return "0+unknown (genesis-cli is not installed)"
42
+
43
+
44
+ def _cmd_adopt(args: argparse.Namespace) -> int:
45
+ repo = Path(args.path).resolve()
46
+ if not repo.is_dir():
47
+ print(f"error: {repo} is not a directory", file=sys.stderr)
48
+ return 2
49
+ if args.fail_on_regression and not args.baseline:
50
+ print("error: --fail-on-regression requires --baseline", file=sys.stderr)
51
+ return 2
52
+
53
+ report = run_audit(repo)
54
+
55
+ comparison = None
56
+ if args.baseline:
57
+ # Read the baseline before any artifact is written: --baseline is often
58
+ # the previous run's report.json at the very path we are about to write.
59
+ baseline_path = Path(args.baseline)
60
+ try:
61
+ baseline = json.loads(baseline_path.read_text())
62
+ except (OSError, json.JSONDecodeError) as exc:
63
+ print(f"error: cannot read baseline {baseline_path}: {exc}",
64
+ file=sys.stderr)
65
+ return 2
66
+ comparison = compare(report, baseline)
67
+ report["baseline_comparison"] = comparison
68
+
69
+ if args.review:
70
+ from .review import run_review
71
+
72
+ print("Running AI second-reader (claude, subscription — additive only)…",
73
+ file=sys.stderr)
74
+ report["ai_review"] = run_review(repo, report)
75
+ if "error" in report["ai_review"]:
76
+ print(f"AI review unavailable: {report['ai_review']['error']}",
77
+ file=sys.stderr)
78
+
79
+ out_dir = Path(args.out) if args.out else repo / ".genesis-report"
80
+ out_dir.mkdir(parents=True, exist_ok=True)
81
+ history = append_history(out_dir, report)
82
+ (out_dir / "report.json").write_text(json.dumps(report, indent=2))
83
+ (out_dir / "report.html").write_text(render_html(report, history=history))
84
+ (out_dir / "report.sarif").write_text(json.dumps(render_sarif(report), indent=2))
85
+ agents_md = render_agents_md(report)
86
+ (out_dir / "AGENTS.md").write_text(agents_md)
87
+
88
+ if args.write:
89
+ target = repo / "AGENTS.md"
90
+ if target.exists() and not args.force:
91
+ print(f"note: {target} already exists — draft left at "
92
+ f"{out_dir / 'AGENTS.md'} (use --force to overwrite)")
93
+ else:
94
+ target.write_text(agents_md)
95
+ print(f"wrote {target}")
96
+
97
+ if args.json:
98
+ print(json.dumps(report, indent=2))
99
+ else:
100
+ print(render_terminal(report))
101
+ if trend := render_trend(history):
102
+ print(trend)
103
+ print(f"Artifacts: {out_dir}/report.html · report.json · report.sarif "
104
+ f"· AGENTS.md")
105
+
106
+ score = report["readiness"]["score"]
107
+ ci_mode = args.min_score is not None or args.fail_on_regression
108
+ if ci_mode:
109
+ failed = False
110
+ if args.min_score is not None and score < args.min_score:
111
+ print(f"FAIL: readiness {score} below --min-score {args.min_score}",
112
+ file=sys.stderr)
113
+ failed = True
114
+ if args.fail_on_regression and comparison and comparison["regressed"]:
115
+ print("FAIL: regression since baseline (score drop, new gap, or "
116
+ "new critical/high finding)", file=sys.stderr)
117
+ failed = True
118
+ return 1 if failed else 0
119
+ # Default exit code mirrors the grade: A/B/C -> 0, D/F -> 1 (CI-friendly).
120
+ return 0 if report["readiness"]["grade"] in ("A", "B", "C") else 1
121
+
122
+
123
+ def _cmd_gates(args: argparse.Namespace) -> int:
124
+ from genesis_gates import GATE_REGISTRY
125
+
126
+ from .adopt import ADOPT_GATES, run_gates
127
+ from .gates import gates_exit_code, render_gates_terminal
128
+
129
+ repo = Path(args.path).resolve()
130
+ if not repo.is_dir():
131
+ print(f"error: {repo} is not a directory", file=sys.stderr)
132
+ return 2
133
+
134
+ if args.gate:
135
+ # De-duplicate while preserving the order the user asked for.
136
+ selected = list(dict.fromkeys(args.gate))
137
+ if unknown := [g for g in selected if g not in GATE_REGISTRY]:
138
+ print(f"error: unknown gate(s): {', '.join(unknown)}. "
139
+ f"Valid gates: {', '.join(sorted(GATE_REGISTRY))}",
140
+ file=sys.stderr)
141
+ return 2
142
+ else:
143
+ selected = list(ADOPT_GATES)
144
+
145
+ package = run_gates(repo, selected)
146
+ if args.json:
147
+ print(json.dumps(package, indent=2))
148
+ else:
149
+ print(render_gates_terminal(package))
150
+ return gates_exit_code(package)
151
+
152
+
153
+ def _cmd_init(args: argparse.Namespace) -> int:
154
+ from .init import render_summary, run_init
155
+
156
+ repo = Path(args.path).resolve()
157
+ if repo.exists() and not repo.is_dir():
158
+ print(f"error: {repo} is not a directory", file=sys.stderr)
159
+ return 2
160
+ created, skipped = run_init(repo)
161
+ print(render_summary(repo, created, skipped))
162
+ return 0
163
+
164
+
165
+ def main(argv: list[str] | None = None) -> int:
166
+ parser = argparse.ArgumentParser(
167
+ prog="genesis",
168
+ description="Genesis Agent Workbench — make any repo agent-ready and "
169
+ "keep AI-generated changes governed.",
170
+ )
171
+ parser.add_argument("--version", action="version",
172
+ version=f"%(prog)s {_version()}")
173
+ sub = parser.add_subparsers(dest="command", required=True)
174
+
175
+ adopt = sub.add_parser(
176
+ "adopt", help="audit a repo for agent-readiness (score, gaps, AGENTS.md)")
177
+ adopt.add_argument("path", nargs="?", default=".", help="repo to audit (default: .)")
178
+ adopt.add_argument("--out", help="artifact directory (default: <repo>/.genesis-report)")
179
+ adopt.add_argument("--write", action="store_true",
180
+ help="write AGENTS.md into the repo root")
181
+ adopt.add_argument("--force", action="store_true",
182
+ help="with --write: overwrite an existing AGENTS.md")
183
+ adopt.add_argument("--json", action="store_true",
184
+ help="print the full JSON report to stdout")
185
+ adopt.add_argument("--review", action="store_true",
186
+ help="add the AI second-reader pass (claude CLI, "
187
+ "subscription auth; additive-only — never alters "
188
+ "the deterministic score)")
189
+ adopt.add_argument("--min-score", type=float, default=None,
190
+ help="CI mode: exit 1 if the readiness score is below "
191
+ "this threshold")
192
+ adopt.add_argument("--baseline", metavar="PATH",
193
+ help="previous report.json to diff against; adds a "
194
+ "CHANGES SINCE BASELINE section and embeds "
195
+ "baseline_comparison in report.json")
196
+ adopt.add_argument("--fail-on-regression", action="store_true",
197
+ help="CI mode (requires --baseline): exit 1 on a "
198
+ "regression — score drop > 0.5, a new "
199
+ "mandatory-flow gap, or a new critical/high "
200
+ "finding")
201
+ adopt.set_defaults(func=_cmd_adopt)
202
+
203
+ gates = sub.add_parser(
204
+ "gates", help="run just the delivery gates (no full analysis): "
205
+ "per-gate table, exit 1 if any gate failed")
206
+ gates.add_argument("path", nargs="?", default=".",
207
+ help="repo to check (default: .)")
208
+ gates.add_argument("--gate", action="append", metavar="NAME",
209
+ help="run only this gate (repeatable); default: the "
210
+ "adopt gate set")
211
+ gates.add_argument("--json", action="store_true",
212
+ help="print the evidence package as JSON")
213
+ gates.set_defaults(func=_cmd_gates)
214
+
215
+ init = sub.add_parser(
216
+ "init", help="scaffold a new/bare repo so agents start agent-ready "
217
+ "(AGENTS.md, .gitignore, .env.example, audit workflow); "
218
+ "never overwrites existing files")
219
+ init.add_argument("path", nargs="?", default=".",
220
+ help="directory to scaffold (default: .; created if missing)")
221
+ init.set_defaults(func=_cmd_init)
222
+
223
+ args = parser.parse_args(argv)
224
+ return args.func(args)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
genesis_cli/report.py ADDED
@@ -0,0 +1,251 @@
1
+ """Report renderers: terminal (plain ANSI-light) and self-contained HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+
7
+ _BAR_W = 24
8
+
9
+
10
+ def _bar(points: float, maximum: float) -> str:
11
+ filled = int(round(_BAR_W * (points / maximum))) if maximum else 0
12
+ return "█" * filled + "░" * (_BAR_W - filled)
13
+
14
+
15
+ def _baseline_lines(cmp: dict) -> list[str]:
16
+ """The CHANGES SINCE BASELINE block (report has a `baseline_comparison`)."""
17
+ delta = cmp["score_delta"]
18
+ arrow = "▲" if delta > 0 else "▼" if delta < 0 else "="
19
+ head = f" score {arrow} {delta:+.1f}"
20
+ if cmp["grade_change"]:
21
+ head += f" grade {cmp['grade_change']}"
22
+ lines = [
23
+ "CHANGES SINCE BASELINE" + (" — REGRESSION" if cmp["regressed"] else ""),
24
+ head,
25
+ f" findings: {len(cmp['new_findings'])} new, "
26
+ f"{len(cmp['resolved_findings'])} resolved · "
27
+ f"gaps: {len(cmp['new_gaps'])} new, "
28
+ f"{len(cmp['resolved_gaps'])} resolved",
29
+ ]
30
+ for f in cmp["new_findings"][:5]:
31
+ loc = f" ({f['location']})" if f.get("location") else ""
32
+ lines.append(f" + [{f.get('severity')}] {f.get('gate')}: "
33
+ f"{f.get('title')}{loc}")
34
+ for g in cmp["new_gaps"][:5]:
35
+ lines.append(f" + gap: {g.get('title')}")
36
+ for f in cmp["resolved_findings"][:5]:
37
+ lines.append(f" − resolved: {f.get('title')}")
38
+ for g in cmp["resolved_gaps"][:5]:
39
+ lines.append(f" − closed gap: {g.get('title')}")
40
+ lines.append("")
41
+ return lines
42
+
43
+
44
+ def render_terminal(report: dict) -> str:
45
+ r = report["readiness"]
46
+ lines = [
47
+ "",
48
+ f"Genesis Agent-Readiness Report — {report['repo']}",
49
+ f"Generated {report['generated_at']}",
50
+ "=" * 72,
51
+ "",
52
+ f" READINESS SCORE: {r['score']}/100 GRADE: {r['grade']}",
53
+ "",
54
+ ]
55
+ for cat in r["categories"]:
56
+ lines.append(f" {cat['key']:<14} {_bar(cat['points'], cat['max'])} "
57
+ f"{cat['points']}/{cat['max']}")
58
+ lines.append("")
59
+ if cmp := report.get("baseline_comparison"):
60
+ lines += _baseline_lines(cmp)
61
+ for cat in r["categories"]:
62
+ lines.append(f"── {cat['key']} " + "─" * (68 - len(cat["key"])))
63
+ lines += [f" {d}" for d in cat["details"][:10]]
64
+ if len(cat["details"]) > 10:
65
+ lines.append(f" … {len(cat['details']) - 10} more (see HTML/JSON report)")
66
+ lines.append("")
67
+ gaps = report["completeness"].get("gaps", [])
68
+ if gaps:
69
+ lines.append("MANDATORY-FLOW GAPS (highest-priority remediation):")
70
+ for g in gaps:
71
+ lines.append(f" • {g['title']}")
72
+ lines.append("")
73
+
74
+ review = report.get("ai_review")
75
+ if review and "error" not in review:
76
+ lines.append("AI SECOND-READER (additive — does not alter the score):")
77
+ if review.get("executive_summary"):
78
+ lines.append(f" {review['executive_summary']}")
79
+ lines.append("")
80
+ for f in review.get("additional_findings", []):
81
+ lines.append(f" + [{f['severity']}/{f['area']}] {f['title']}")
82
+ for a in review.get("annotations", [])[:6]:
83
+ lines.append(f" ~ {a['ref']}: {a['note']}")
84
+ if review.get("remediation_plan"):
85
+ lines.append(" Remediation order:")
86
+ for i, step in enumerate(review["remediation_plan"], 1):
87
+ lines.append(f" {i}. {step}")
88
+ lines.append("")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def _baseline_html(cmp: dict) -> str:
93
+ """CHANGES SINCE BASELINE section — mirrors `_baseline_lines`."""
94
+ e = html.escape
95
+ delta = cmp["score_delta"]
96
+ arrow = "▲" if delta > 0 else "▼" if delta < 0 else "="
97
+ cls = "up" if delta > 0 else "down" if delta < 0 else ""
98
+ badge = ("<span class='chip chip-bad'>REGRESSION</span>"
99
+ if cmp["regressed"] else "")
100
+ grade = (f" · grade {e(cmp['grade_change'])}" if cmp["grade_change"] else "")
101
+ rows: list[str] = []
102
+ for f in cmp["new_findings"]:
103
+ loc = f" <small>({e(f['location'])})</small>" if f.get("location") else ""
104
+ rows.append(f"<li class='base-new'>+ [{e(str(f.get('severity')))}] "
105
+ f"{e(str(f.get('gate')))}: {e(str(f.get('title')))}{loc}</li>")
106
+ rows += [f"<li class='base-new'>+ gap: {e(str(g.get('title')))}</li>"
107
+ for g in cmp["new_gaps"]]
108
+ rows += [f"<li class='base-resolved'>− resolved: {e(str(f.get('title')))}</li>"
109
+ for f in cmp["resolved_findings"]]
110
+ rows += [f"<li class='base-resolved'>− closed gap: {e(str(g.get('title')))}</li>"
111
+ for g in cmp["resolved_gaps"]]
112
+ return (
113
+ f"<section><h2>Changes since baseline {badge}</h2>"
114
+ f"<p class='delta {cls}'>score {arrow} {delta:+.1f}{grade} · "
115
+ f"{len(cmp['new_findings'])} new / {len(cmp['resolved_findings'])} "
116
+ f"resolved findings · {len(cmp['new_gaps'])} new / "
117
+ f"{len(cmp['resolved_gaps'])} closed gaps</p>"
118
+ + (f"<ul>{''.join(rows)}</ul>" if rows else "")
119
+ + "</section>"
120
+ )
121
+
122
+
123
+ def _history_html(history: list[dict] | None, max_shown: int = 12) -> str:
124
+ """Score-trend strip: one bar per audit, most recent last/highlighted."""
125
+ if not history or len(history) < 2:
126
+ return ""
127
+ e = html.escape
128
+ shown = history[-max_shown:]
129
+ bars = "".join(
130
+ f"<div class='trend-bar{' cur' if i == len(shown) - 1 else ''}' "
131
+ f"title='{e(str(h.get('generated_at', '')))} — "
132
+ f"{h.get('score')}/100 ({e(str(h.get('grade', '')))})'>"
133
+ f"<div style='height:{max(4, round(float(h.get('score', 0))))}%'></div>"
134
+ f"<span>{h.get('score')}</span></div>"
135
+ for i, h in enumerate(shown)
136
+ )
137
+ prefix = "… " if len(history) > len(shown) else ""
138
+ return (
139
+ f"<section><h2>Score history <span>{prefix}{len(history)} runs</span></h2>"
140
+ f"<div class='trend'>{bars}</div></section>"
141
+ )
142
+
143
+
144
+ def _review_html(report: dict) -> str:
145
+ review = report.get("ai_review")
146
+ if not review or "error" in review:
147
+ return ""
148
+ e = html.escape
149
+ findings = "".join(
150
+ f"<li><strong>[{e(f['severity'])}/{e(f['area'])}]</strong> "
151
+ f"{e(f['title'])}<br><small>{e(f['detail'])}</small></li>"
152
+ for f in review.get("additional_findings", [])
153
+ )
154
+ notes = "".join(
155
+ f"<li><em>{e(a['ref'])}</em> — {e(a['note'])}</li>"
156
+ for a in review.get("annotations", [])
157
+ )
158
+ plan = "".join(f"<li>{e(s)}</li>" for s in review.get("remediation_plan", []))
159
+ return (
160
+ "<section><h2>AI second-reader "
161
+ "<span style='font-size:12px;color:#9aa7b8'>additive-only — does not "
162
+ "alter the deterministic score</span></h2>"
163
+ f"<p>{e(review.get('executive_summary', ''))}</p>"
164
+ + (f"<h3>Additional findings</h3><ul>{findings}</ul>" if findings else "")
165
+ + (f"<h3>Annotations</h3><ul>{notes}</ul>" if notes else "")
166
+ + (f"<h3>Remediation order</h3><ol>{plan}</ol>" if plan else "")
167
+ + "</section>"
168
+ )
169
+
170
+
171
+ def render_html(report: dict, history: list[dict] | None = None) -> str:
172
+ r = report["readiness"]
173
+ e = html.escape
174
+
175
+ def cat_block(cat: dict) -> str:
176
+ pct = 100 * cat["points"] / cat["max"] if cat["max"] else 0
177
+ def _cls(d: str) -> str:
178
+ return "ok" if d.startswith("✓") else "bad" if d.startswith("✗") else ""
179
+
180
+ rows = "".join(
181
+ f'<li class="{_cls(d)}">{e(d)}</li>' for d in cat["details"]
182
+ )
183
+ return (
184
+ f"<section><h2>{e(cat['key'])} <span>{cat['points']}/{cat['max']}</span></h2>"
185
+ f"<div class='meter'><div style='width:{pct:.0f}%'></div></div>"
186
+ f"<ul>{rows}</ul></section>"
187
+ )
188
+
189
+ gaps = report["completeness"].get("gaps", [])
190
+ # Gaps are mandatory-flow misses — styled like high-severity findings
191
+ # (they render as `error` in the SARIF output).
192
+ gaps_html = "".join(
193
+ f"<li class='gap'><span class='sev sev-high'>required flow</span> "
194
+ f"<strong>{e(g['title'])}</strong>"
195
+ f"<br><small>{e(g['brief'])}</small></li>"
196
+ for g in gaps
197
+ )
198
+ gate_rows = "".join(
199
+ f"<tr><td>{e(str(g.get('gate')))}</td><td class='s-{e(str(g.get('status')))}'>"
200
+ f"{e(str(g.get('status')))}</td><td>{e(str(g.get('summary', ''))[:120])}</td></tr>"
201
+ for g in report["gates"]
202
+ )
203
+ grade_hue = {"A": 130, "B": 90, "C": 48, "D": 28, "F": 4}.get(r["grade"], 0)
204
+ return f"""<!doctype html><html><head><meta charset="utf-8">
205
+ <title>Genesis Readiness — {e(report['repo'])}</title>
206
+ <style>
207
+ body{{font:15px/1.5 -apple-system,Segoe UI,sans-serif;margin:0;background:#0e1116;color:#dde3ea}}
208
+ main{{max-width:880px;margin:0 auto;padding:40px 24px}}
209
+ h1{{font-size:22px}} h2{{font-size:16px;display:flex;justify-content:space-between}}
210
+ .score{{display:flex;align-items:center;gap:20px;margin:24px 0}}
211
+ .badge{{font-size:44px;font-weight:700;padding:18px 26px;border-radius:14px;
212
+ background:hsl({grade_hue} 65% 18%);color:hsl({grade_hue} 80% 65%)}}
213
+ .meter{{height:8px;background:#1c2330;border-radius:4px;overflow:hidden}}
214
+ .meter div{{height:100%;background:#3fb68b}}
215
+ section{{margin:26px 0;padding:18px;background:#151a22;border-radius:10px}}
216
+ ul{{margin:10px 0 0;padding-left:20px}} li.ok{{color:#7fd6ac}} li.bad{{color:#e08a8a}}
217
+ table{{width:100%;border-collapse:collapse;font-size:14px}}
218
+ td{{padding:6px 8px;border-top:1px solid #232b38}}
219
+ .s-passed{{color:#7fd6ac}} .s-failed{{color:#e08a8a}} .s-skipped{{color:#9aa7b8}}
220
+ small{{color:#9aa7b8}}
221
+ .chip{{font-size:11px;font-weight:600;padding:2px 8px;border-radius:9px;vertical-align:middle}}
222
+ .chip-bad{{background:#3a1d1d;color:#e08a8a}}
223
+ .delta{{margin:4px 0 10px;color:#9aa7b8}} .delta.up{{color:#7fd6ac}} .delta.down{{color:#e08a8a}}
224
+ .base-new{{color:#e08a8a}} .base-resolved{{color:#7fd6ac}}
225
+ .sev{{font-size:11px;font-weight:600;padding:1px 7px;border-radius:9px;margin-right:6px}}
226
+ .sev-high{{background:#3a1d1d;color:#e08a8a}}
227
+ li.gap{{margin:8px 0;list-style:none;border-left:3px solid #a04545;padding-left:10px}}
228
+ section ul:has(li.gap){{padding-left:4px}}
229
+ .trend{{display:flex;gap:5px;align-items:flex-end;height:84px;margin-top:12px}}
230
+ .trend-bar{{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;
231
+ height:64px;width:26px}}
232
+ .trend-bar div{{width:100%;background:#28564a;border-radius:3px 3px 0 0}}
233
+ .trend-bar.cur div{{background:#3fb68b}}
234
+ .trend-bar span{{font-size:10px;color:#9aa7b8;margin-top:3px}}
235
+ a{{color:#7fb3d6}}
236
+ </style></head><body><main>
237
+ <h1>Genesis Agent-Readiness Report</h1>
238
+ <p><code>{e(report['repo'])}</code> · {e(report['generated_at'])}</p>
239
+ <div class="score"><div class="badge">{e(r['grade'])}</div>
240
+ <div><strong style="font-size:28px">{r['score']}/100</strong><br>
241
+ <small>agent-readiness score</small></div></div>
242
+ {_baseline_html(report['baseline_comparison']) if report.get('baseline_comparison') else ''}
243
+ {_history_html(history)}
244
+ {''.join(cat_block(c) for c in r['categories'])}
245
+ {f"<section><h2>Mandatory-flow gaps</h2><ul>{gaps_html}</ul></section>" if gaps else ""}
246
+ {_review_html(report)}
247
+ <section><h2>Gate results</h2><table>{gate_rows}</table></section>
248
+ <p><small>Generated by <code>genesis adopt</code> — part of the
249
+ <a href="https://genesis.dev">Genesis</a> delivery framework
250
+ (schema: <code>readiness-report.schema.json</code>).</small></p>
251
+ </main></body></html>"""
genesis_cli/review.py ADDED
@@ -0,0 +1,151 @@
1
+ """AI second-reader for `genesis adopt --review`.
2
+
3
+ The deterministic audit is the trust anchor; this pass is **additive-only**:
4
+ it may add findings, annotate, prioritize, and summarize — it can never remove
5
+ or downgrade a deterministic finding, and it never changes the readiness score.
6
+
7
+ Runs through the local `claude` CLI in headless mode using the user's existing
8
+ subscription login (ANTHROPIC_API_KEY is explicitly unset in the child), so
9
+ `--review` keeps the audit's zero-API-key property. If the CLI is unavailable
10
+ or the call fails, the audit proceeds without the review — degraded, never
11
+ broken.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+
22
+ _TIMEOUT_S = 240
23
+ _MAX_TREE_ENTRIES = 300
24
+ _SKIP_DIRS = {".git", "node_modules", "dist", "build", "__pycache__", ".venv",
25
+ "venv", ".next", "coverage", ".genesis-work", ".genesis-report"}
26
+
27
+ _INSTRUCTIONS = """\
28
+ You are the AI second-reader for a Genesis agent-readiness audit. A
29
+ deterministic scanner already produced the findings in REPORT_JSON for the
30
+ repository whose file tree is in REPO_TREE.
31
+
32
+ Your job is ADDITIVE ONLY: find what the deterministic pass missed, annotate
33
+ what it found, and write the executive summary. You must NOT dispute scores or
34
+ suggest removing findings — if you believe a finding is a false positive, add
35
+ an annotation saying so; never omit it.
36
+
37
+ Look especially for:
38
+ - system kinds the scanner has no profile for (payments, file upload, email,
39
+ multi-tenancy, background jobs) and mandatory flows they imply that appear
40
+ unimplemented;
41
+ - architectural risks visible from the tree/report (no service boundaries,
42
+ test gaps in critical areas, framework misuse);
43
+ - anything that would make an AI coding agent unsafe or unproductive in this
44
+ repo (missing conventions, ambiguous structure, dead config).
45
+
46
+ Respond with ONLY a JSON object, no prose before or after, exactly this shape:
47
+ {
48
+ "executive_summary": "3-6 sentences a CTO reads first",
49
+ "additional_findings": [
50
+ {"title": "...", "severity": "critical|high|medium|low",
51
+ "area": "completeness|security|architecture|testing|agent-readiness",
52
+ "detail": "1-3 sentences, concrete"}
53
+ ],
54
+ "annotations": [
55
+ {"ref": "which deterministic finding/category this refers to",
56
+ "note": "context, likely-false-positive flag, or prioritization"}
57
+ ],
58
+ "remediation_plan": ["ordered, concrete first 5-8 steps"]
59
+ }
60
+ """
61
+
62
+
63
+ def claude_available() -> bool:
64
+ return shutil.which("claude") is not None
65
+
66
+
67
+ def _repo_tree(repo: Path) -> str:
68
+ lines: list[str] = []
69
+ for f in sorted(repo.rglob("*")):
70
+ if len(lines) >= _MAX_TREE_ENTRIES:
71
+ lines.append("… (tree truncated)")
72
+ break
73
+ if any(part in _SKIP_DIRS for part in f.parts):
74
+ continue
75
+ if f.is_file():
76
+ lines.append(str(f.relative_to(repo)))
77
+ return "\n".join(lines)
78
+
79
+
80
+ def _extract_json(text: str) -> dict | None:
81
+ start, end = text.find("{"), text.rfind("}")
82
+ if start == -1 or end <= start:
83
+ return None
84
+ try:
85
+ data = json.loads(text[start:end + 1])
86
+ except json.JSONDecodeError:
87
+ return None
88
+ return data if isinstance(data, dict) else None
89
+
90
+
91
+ def _sanitize(data: dict) -> dict:
92
+ """Enforce the additive-only contract and bound sizes."""
93
+ findings = []
94
+ for f in (data.get("additional_findings") or [])[:20]:
95
+ if isinstance(f, dict) and f.get("title"):
96
+ findings.append({
97
+ "title": str(f.get("title"))[:200],
98
+ "severity": str(f.get("severity", "medium")).lower(),
99
+ "area": str(f.get("area", "architecture"))[:40],
100
+ "detail": str(f.get("detail", ""))[:600],
101
+ })
102
+ annotations = [
103
+ {"ref": str(a.get("ref", ""))[:200], "note": str(a.get("note", ""))[:400]}
104
+ for a in (data.get("annotations") or [])[:20]
105
+ if isinstance(a, dict) and a.get("note")
106
+ ]
107
+ plan = [str(s)[:300] for s in (data.get("remediation_plan") or [])[:10]]
108
+ return {
109
+ "executive_summary": str(data.get("executive_summary", ""))[:2000],
110
+ "additional_findings": findings,
111
+ "annotations": annotations,
112
+ "remediation_plan": plan,
113
+ "additive_only": True,
114
+ "engine": "claude-cli (subscription)",
115
+ }
116
+
117
+
118
+ def run_review(repo: Path, report: dict) -> dict:
119
+ """Return the ai_review block, or {"error": ...} (audit proceeds either way)."""
120
+ if not claude_available():
121
+ return {"error": "claude CLI not found on PATH — install Claude Code or "
122
+ "run without --review"}
123
+ slim = {k: report[k] for k in ("readiness", "gates", "completeness", "env",
124
+ "structure") if k in report}
125
+ prompt = (
126
+ f"{_INSTRUCTIONS}\n\nREPO_TREE:\n{_repo_tree(repo)}\n\n"
127
+ f"REPORT_JSON:\n{json.dumps(slim, indent=1)[:60_000]}\n"
128
+ )
129
+ env = dict(os.environ)
130
+ env.pop("ANTHROPIC_API_KEY", None) # force subscription auth
131
+ try:
132
+ proc = subprocess.run(
133
+ ["claude", "-p", prompt, "--output-format", "json"],
134
+ capture_output=True, text=True, timeout=_TIMEOUT_S, env=env,
135
+ cwd=str(repo),
136
+ )
137
+ except subprocess.TimeoutExpired:
138
+ return {"error": f"AI review timed out after {_TIMEOUT_S}s"}
139
+ except OSError as exc:
140
+ return {"error": f"could not launch claude CLI: {exc}"}
141
+ if proc.returncode != 0:
142
+ return {"error": f"claude CLI exited {proc.returncode}: "
143
+ f"{(proc.stderr or proc.stdout)[:300]}"}
144
+
145
+ # --output-format json wraps the answer in an envelope with a `result` field.
146
+ envelope = _extract_json(proc.stdout) or {}
147
+ payload = envelope.get("result", proc.stdout)
148
+ data = _extract_json(payload if isinstance(payload, str) else "")
149
+ if data is None:
150
+ return {"error": "AI review returned unparseable output"}
151
+ return _sanitize(data)