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/README.md +172 -0
- genesis_cli/__init__.py +0 -0
- genesis_cli/adopt.py +75 -0
- genesis_cli/agents_md.py +76 -0
- genesis_cli/baseline.py +114 -0
- genesis_cli/gates.py +50 -0
- genesis_cli/init.py +158 -0
- genesis_cli/main.py +228 -0
- genesis_cli/report.py +251 -0
- genesis_cli/review.py +151 -0
- genesis_cli/sarif.py +137 -0
- genesis_cli-0.1.0.dist-info/METADATA +198 -0
- genesis_cli-0.1.0.dist-info/RECORD +16 -0
- genesis_cli-0.1.0.dist-info/WHEEL +4 -0
- genesis_cli-0.1.0.dist-info/entry_points.txt +2 -0
- genesis_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
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)
|