diff --git a/AGENTS.md b/AGENTS.md index 7fe38872..3c5fdf26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,11 @@ roam-code is a local codebase intelligence CLI for developers and AI coding agents. It pre-indexes symbols, call graphs, dependencies, architecture, and git history into -a local SQLite DB. **267 commands · 243 MCP tools (16 in the default `core` preset) · 28 languages · 100% local · zero API keys.** +a local SQLite DB. **268 commands · 243 MCP tools (16 in the default `core` preset) · 28 languages · 100% local · zero API keys.** -Authoritative counts (AST-derived, env-independent): `command_count: 267 · canonical_count: 260 · category_count: 7 · mcp tools registered: 243 · mcp tools in core preset: 16`. The `roam surface --json` envelope additionally exposes `mcp_tool_count_by_preset` for per-preset counts. +Authoritative counts (AST-derived, env-independent): `command_count: 268 · canonical_count: 261 · category_count: 7 · mcp tools registered: 243 · mcp tools in core preset: 16`. The `roam surface --json` envelope additionally exposes `mcp_tool_count_by_preset` for per-preset counts. **Package:** `roam-code` on PyPI. Entry point: `roam.cli:cli`. @@ -151,7 +151,7 @@ roam health ``` src/roam/ - cli.py # Click CLI entry point — LazyGroup, _COMMANDS dict, _CATEGORIES. 267 command names (260 canonical + 7 aliases). + cli.py # Click CLI entry point — LazyGroup, _COMMANDS dict, _CATEGORIES. 268 command names (261 canonical + 7 aliases). mcp_server.py # FastMCP server (16 tools in core preset; 243 in `full`) + `roam mcp` CLI command mcp_extras/ # MCP-native enhancements: sampling, watcher, session, progress, completions sampling.py # Sampling-driven result compression (summarize=True) via Context.sample @@ -711,7 +711,7 @@ Index-aware text search (added on top of grep / refs): - `roam delete-check [--source working|staged|pr|head] [--ci]` — gates the diff on surviving references; exits 5 on BREAK-RISK with `--ci`. - `roam history-grep [--polarity]` — git pickaxe (-S/-G) with author/date and introduced/removed annotation. -Run `roam --help` for the 5-verb core; `roam --help-all` for all 267 command names; `roam surface --json` for the machine-readable inventory. Use `roam --json ` for structured output. +Run `roam --help` for the 5-verb core; `roam --help-all` for all 268 command names; `roam surface --json` for the machine-readable inventory. Use `roam --json ` for structured output. Use `roam --sarif health` for CI integration (SARIF 2.1.0). ## Compiler tooling (2026-06-02 wave) diff --git a/README.md b/README.md index a04642ab..e7a1091b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Credential-free · 100% local by default (opt-in `metrics-push` is the only outbound surface) · tamper-evident `ChangeEvidence` packets · Apache 2.0 · runs entirely on your machine -267 commands · 243 MCP tools (16 in the default `core` preset) · 28 languages +268 commands · 243 MCP tools (16 in the default `core` preset) · 28 languages ![roam terminal demo](docs/assets/roam-terminal-demo.gif) @@ -378,7 +378,7 @@ Full release notes in [CHANGELOG.md](CHANGELOG.md). ## Core commands -**Lead with the 5 verbs.** The [5 core commands](#core-commands) cover ~80% of agent workflows: `understand`, `context`, `retrieve`, `preflight`, `critique`. The remaining ~262 commands are detail surface for specialised workflows (taint, fleet, cga, oracle, eval, …) — they're called by agents on demand, not memorised. This is intentional design; under the hood the canonical surface is **267 commands (260 canonical + 7 aliases) organised into 7 categories** (aliases for muscle memory: `math` → `algo`, `churn` → `weather`, `digest` / `snapshot` / `trend` → `trends`, `onboard` → `understand`, `refs` → `uses`), but you don't need to know that to start. +**Lead with the 5 verbs.** The [5 core commands](#core-commands) cover ~80% of agent workflows: `understand`, `context`, `retrieve`, `preflight`, `critique`. The remaining ~263 commands are detail surface for specialised workflows (taint, fleet, cga, oracle, eval, …) — they're called by agents on demand, not memorised. This is intentional design; under the hood the canonical surface is **268 commands (261 canonical + 7 aliases) organised into 7 categories** (aliases for muscle memory: `math` → `algo`, `churn` → `weather`, `digest` / `snapshot` / `trend` → `trends`, `onboard` → `understand`, `refs` → `uses`), but you don't need to know that to start. | Verb | What it does | @@ -393,7 +393,7 @@ The full surface spans **7 categories** — Getting Started, Daily Workflow, Cod
-Full command reference — canonical command list (all 260) +Full command reference — canonical command list (all 261) The complete, always-current list with flags and examples lives in the [Command Reference](https://roam-code.com/docs/command-reference). diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 85af345c..0cebcc81 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -324,7 +324,7 @@ Any roam command can be passed via the `commands` input. Common choices: | `breaking` | Detect breaking API changes | | `conventions` | Naming convention violations | -Run `roam --help` for all 267 commands. +Run `roam --help` for all 268 commands. ## Exit Codes diff --git a/llms-install.md b/llms-install.md index 356b7d58..dadd2068 100644 --- a/llms-install.md +++ b/llms-install.md @@ -4,7 +4,7 @@ roam-code is the local CLI that runs pre-change gates before every agent edit and compiles tamper-evident, content-hashed evidence packets after every change. 100% local, no API keys, no telemetry. -267 commands, 243 MCP tools, 28 languages, 100% local, zero API keys. +268 commands, 243 MCP tools, 28 languages, 100% local, zero API keys. ## Cross-references @@ -262,5 +262,5 @@ and `roam surface --json` for the machine-readable inventory. -Run `roam --help` for all 267 commands (+ alias pairs). +Run `roam --help` for all 268 commands (+ alias pairs). diff --git a/skills/roam/SKILL.md b/skills/roam/SKILL.md index 7a7a4d48..86a0267a 100644 --- a/skills/roam/SKILL.md +++ b/skills/roam/SKILL.md @@ -196,7 +196,7 @@ roam minimap --update # update sentinel block in CLAUDE.md ## Discovering More Commands -This skill covers the most common commands, but roam has 267 commands. +This skill covers the most common commands, but roam has 268 commands. To explore what's available: ```bash diff --git a/src/roam/cli.py b/src/roam/cli.py index d891a4c6..ea44f34d 100644 --- a/src/roam/cli.py +++ b/src/roam/cli.py @@ -224,6 +224,7 @@ def _deprecation_record(name: str) -> dict | None: "permit": ("roam.commands.cmd_permit", "permit_cmd"), "postmortem": ("roam.commands.cmd_postmortem", "postmortem_cmd"), "pr-replay": ("roam.commands.cmd_pr_replay", "pr_replay_cmd"), + "service-report": ("roam.commands.cmd_service_report", "service_report_cmd"), "article-12-check": ("roam.commands.cmd_article_12_check", "article_12_check_cmd"), "capabilities": ("roam.commands.cmd_capabilities", "capabilities_cmd"), "skill-generate": ("roam.commands.cmd_skill_generate", "skill_generate_cmd"), @@ -492,6 +493,7 @@ def _deprecation_record(name: str) -> dict | None: "permit", "postmortem", "pr-replay", + "service-report", "guard", # Roam Guard family (Wave 11-20): the PR-gating surface. "guard-pr", diff --git a/src/roam/commands/cmd_service_report.py b/src/roam/commands/cmd_service_report.py new file mode 100644 index 00000000..39999fc5 --- /dev/null +++ b/src/roam/commands/cmd_service_report.py @@ -0,0 +1,1165 @@ +"""``roam service-report`` — one-command service-engagement deliverables. + +Turns Roam's four services-report *templates* into filled, buyer-facing +deliverables, exactly mirroring how ``roam pr-replay`` productises +``roam postmortem``. Each ``--type`` runs the right existing Roam +primitives against the repo, aggregates their JSON envelopes, and emits +a narrative Markdown report ready to hand to a client. + +Four report types, all share the same engine: + +* ``--type due-diligence`` — codebase-health / M&A technical diligence. + Runs ``health``, ``bus-factor``, ``complexity``, ``dead``, ``clones``, + ``smells``, ``test-pyramid``, ``sbom``, ``supply-chain``, ``vulns``, + ``architecture-drift``. +* ``--type ai-readiness`` — AI adoption readiness. Runs ``ai-readiness``, + ``ai-ratio``, ``agent-score``, ``mode``. +* ``--type reachability-triage`` — the security wedge: reachable-vs-noise. + Runs ``sbom``, ``supply-chain``, ``vulns``, ``vuln-reach``, ``taint``, + ``secrets``. +* ``--type post-incident`` — replay a commit/incident range with + ``postmortem`` + ``audit-trail-verify`` audit-trail framing. + +Usage:: + + # Codebase due-diligence report to stdout + roam service-report --type due-diligence + + # Client-branded reachability triage written to a file + PDF + roam service-report --type reachability-triage --client "Acme Inc" \ + --output acme-triage.md --pdf acme-triage.pdf + + # Post-incident replay over an explicit incident window + roam service-report --type post-incident --range v1.0..main --output incident.md + +Output formats: Markdown by default; ``roam --json service-report`` +returns the full envelope (summary + sections + report_markdown). +SARIF is deliberately NOT emitted — service-report outputs are +invocation-scoped buyer-facing report envelopes composed from the +individual commands' aggregations, not per-location violations. The composed +subcommands emit their own ``--sarif`` when applicable; this command +rolls them up into a narrative report (same rationale as +``cmd_pr_replay``). + +Reuses ``cmd_pr_replay``'s render/output/PDF/ledger infrastructure where +it makes sense (``_render_pdf``, ``_git_head_sha``, ``_is_safe_commit_range``, +``_run_postmortem``) — the two commands are siblings in the paid-audit +family. + +Wording discipline (W184 / W203): every report says "maps to / supports +evidence for" and never "certifies / guaranteed / compliant" (the +disclaimer "does not certify" is the one allowed negation). See +``tests/_helpers/wording_lint.py``. +""" + +from __future__ import annotations + +import json as _json +from datetime import datetime, timezone +from pathlib import Path + +import click +from click.testing import CliRunner + +from roam.capability import roam_capability + +# Reuse pr-replay's render/output infrastructure — genuine sibling reuse, +# not duplication. ``_render_pdf`` is a generic markdown→PDF renderer; +# ``_git_head_sha`` / ``_is_safe_commit_range`` / ``_run_postmortem`` are +# the same helpers the paid-audit family already relies on. +from roam.commands.cmd_pr_replay import ( + _git_head_sha, + _is_safe_commit_range, + _render_pdf, + _run_postmortem, +) +from roam.commands.resolve import ensure_index +from roam.exit_codes import EXIT_SUCCESS +from roam.output.formatter import json_envelope, to_json +from roam.runs.helpers import auto_log + +# --------------------------------------------------------------------------- +# Report-type registry — single source of truth for what each type means. +# --------------------------------------------------------------------------- + +_REPORT_TYPES: dict[str, dict] = { + "due-diligence": { + "label": "Codebase Due Diligence", + "title": "Codebase Due Diligence Report", + "purpose_line": ( + "Technical due-diligence pass over the target codebase: health, " + "key-person risk, complexity, dead code, duplication, test signal, " + "architecture drift, and security / supply-chain posture — the " + "engineering evidence an acquirer or investor needs before signing." + ), + "engagement_price": "$3,000–$7,500", + "lead_commands": [ + "health", + "bus-factor", + "complexity", + "dead", + "clones", + "smells", + "test-pyramid", + "sbom", + "supply-chain", + "vulns", + "architecture-drift", + ], + }, + "ai-readiness": { + "label": "AI Adoption Readiness Audit", + "title": "AI Adoption Readiness Audit", + "purpose_line": ( + "Pre-rollout readiness review: how ready is this codebase for " + "agent-driven and AI-assisted development? Scores structural " + "readiness dimensions, measures the existing AI footprint, and " + "reports the governance gates that should be in place before " + "agents touch production code." + ), + "engagement_price": "$1,500–$4,000", + "lead_commands": ["ai-readiness", "ai-ratio", "agent-score", "mode"], + }, + "reachability-triage": { + "label": "Security Reachability Triage", + "title": "Security Reachability Triage", + "purpose_line": ( + "Scanner-noise reduction sweep: of everything the scanners flag, " + "what is actually reachable from a production entry point? " + "Reachability analysis against the call graph separates the " + "findings that warrant fix work this sprint from the noise." + ), + "engagement_price": "$2,500–$6,000", + "lead_commands": [ + "sbom", + "supply-chain", + "vulns", + "vuln-reach", + "taint", + "secrets", + ], + }, + "post-incident": { + "label": "Post-Incident Replay", + "title": "Post-Incident Replay Report", + "purpose_line": ( + "Replay a suspected incident window with the current detector set " + "and the signed audit trail: which findings would have surfaced " + "pre-merge, and does the change history verify end-to-end? Turns a " + "postmortem into a durable prevention artifact." + ), + "engagement_price": "$1,500–$4,000", + "lead_commands": ["postmortem", "audit-trail-verify"], + }, +} + + +# --------------------------------------------------------------------------- +# Primitive invocation — run ``roam --json `` in-process, return the +# parsed envelope. Mirror of ``cmd_pr_replay._run_postmortem``: never +# raises, returns ``{}`` on any failure so a renderer can still emit an +# honest "not available" section rather than crashing on the buyer. +# --------------------------------------------------------------------------- + + +def _run_roam_json(args: list[str]) -> dict: + """Invoke ``roam --json `` in-process and return the parsed envelope. + + Returns ``{}`` on any failure (non-zero exit, empty output, unparseable + JSON). Progress-bar / auto-index chrome written to stdout before the + JSON payload is stripped by locating the first ``{`` (same defence + ``cmd_pr_replay._run_postmortem`` uses for ``roam postmortem``). + """ + from roam.cli import cli + + runner = CliRunner() + try: + result = runner.invoke(cli, ["--json", *args], catch_exceptions=True) + except Exception: # noqa: BLE001 — the report must not crash on one section + return {} + text = result.output or "" + brace = text.find("{") + if brace < 0: + return {} + try: + return _json.loads(text[brace:]) + except _json.JSONDecodeError: + return {} + + +def _summary(env: dict) -> dict: + """Return the ``summary`` sub-dict of an envelope (or ``{}``).""" + s = env.get("summary") if isinstance(env, dict) else None + return s if isinstance(s, dict) else {} + + +def _verdict(env: dict) -> str: + """Return an envelope's one-line ``summary.verdict`` (or a placeholder).""" + return str(_summary(env).get("verdict") or "not available") + + +def _g(env: dict, key: str, default=None): + """Safe ``summary[key]`` lookup with a default.""" + return _summary(env).get(key, default) + + +def _cell(value) -> str: + """Render a scalar for a Markdown table cell (escape the pipe).""" + if value is None: + return "—" + return str(value).replace("|", "/") + + +def _pct(part, whole) -> str: + """Format ``part/whole`` as an integer percentage string, guarding /0.""" + try: + part = float(part) + whole = float(whole) + except (TypeError, ValueError): + return "—" + if whole <= 0: + return "—" + return f"{part * 100 / whole:.0f}%" + + +# --------------------------------------------------------------------------- +# Shared report chrome — header, disclaimer banner, "not covered", footer. +# Every renderer reuses these so the banner and wording discipline stay +# single-sourced (W184 / W203 clean). +# --------------------------------------------------------------------------- + +# The disclaimer banner. "does not certify" is the one allowed negation +# (the wording lint permits a forbidden stem inside a negation window). +_DISCLAIMER_BANNER = ( + "> **Engineering evidence, not an attestation.** This report maps to / " + "supports evidence for the engineering review below. It does not certify " + "compliance, replace a professional audit, and its findings depend on " + "call-graph quality and the declared entry-point inventory. Numbers are " + "generated from the repository at the index SHA above; review with the " + "relevant team before acting on them." +) + + +def _header( + *, type_meta: dict, report_type: str, client: str | None, index_sha: str | None, generated_at: str, subject: str +) -> list[str]: + """Build the shared report header block.""" + out: list[str] = [] + if client: + out.append(f"# {type_meta['title']} — {client}") + else: + out.append(f"# {type_meta['title']}") + out.append("") + meta_bits = [ + f"**Type:** {type_meta['label']}", + f"**Subject:** `{subject}`", + f"**Index SHA:** `{index_sha or 'unknown'}`", + f"**Generated:** {generated_at}", + ] + out.append(" · ".join(meta_bits) + " ") + out.append(f"**Tool:** `roam service-report --type {report_type}`") + out.append("") + out.append(_DISCLAIMER_BANNER) + out.append("") + out.append(type_meta["purpose_line"]) + out.append("") + return out + + +def _paid_framing(*, type_meta: dict, client: str | None) -> list[str]: + """Paid-engagement framing block (mirrors pr-replay's tier framing).""" + out: list[str] = [] + out.append("## About this engagement") + out.append("") + who = client or "your team" + out.append( + f"This is a **{type_meta['label']}** deliverable prepared for {who}. " + f"A full paid engagement ({type_meta['engagement_price']}) includes " + f"founder review of the findings on a call, a written remediation plan, " + f"and the raw JSON envelopes for every command run. See " + f" or contact services." + ) + out.append("") + return out + + +def _footer(*, report_type: str, generated_at: str, extra_scope: list[str]) -> list[str]: + """Shared 'what this does not cover' + disclaimer + methodology footer.""" + out: list[str] = [] + out.append("## What this report does not cover") + out.append("") + base_scope = [ + "**Semantic correctness** — whether the code does the right thing. " + "Roam surfaces structural and evidence signals; it does not replace " + "human or LLM semantic review.", + "**Legal, financial, or valuation opinion.** This is engineering evidence only.", + ] + for item in extra_scope + base_scope: + out.append(f"- {item}") + out.append("") + out.append("## Disclaimer") + out.append("") + out.append( + "Findings are generated by the open-source Roam CLI against the " + "repository at the index SHA in the header. Reachability and risk " + "depend on call-graph quality and the declared entry-point inventory; " + "static analysis can miss dynamically-constructed paths. This report " + "maps to / supports evidence for an engineering review — it does not " + "certify compliance and is not a substitute for a professional audit." + ) + out.append("") + out.append( + f"_Generated by `roam service-report --type {report_type}` on " + f"{generated_at}. Engine: the open-source Roam CLI " + f"([github.com/Cranot/roam-code](https://github.com/Cranot/roam-code))._" + ) + out.append("") + return out + + +# --------------------------------------------------------------------------- +# Type: due-diligence +# --------------------------------------------------------------------------- + + +def _gather_due_diligence() -> dict: + """Run the due-diligence primitives, return {command: envelope}.""" + return { + "health": _run_roam_json(["health"]), + "bus_factor": _run_roam_json(["bus-factor"]), + "complexity": _run_roam_json(["complexity"]), + "dead": _run_roam_json(["dead"]), + "clones": _run_roam_json(["clones"]), + "smells": _run_roam_json(["smells"]), + "test_pyramid": _run_roam_json(["test-pyramid"]), + "sbom": _run_roam_json(["sbom"]), + "supply_chain": _run_roam_json(["supply-chain"]), + "vulns": _run_roam_json(["vulns"]), + "arch_drift": _run_roam_json(["architecture-drift"]), + } + + +def _render_due_diligence(*, env: dict, meta: dict) -> str: + """Render the due-diligence report (pure — no I/O).""" + health = env.get("health", {}) + bus = env.get("bus_factor", {}) + cx = env.get("complexity", {}) + dead = env.get("dead", {}) + clones = env.get("clones", {}) + smells = env.get("smells", {}) + pyramid = env.get("test_pyramid", {}) + sbom = env.get("sbom", {}) + supply = env.get("supply_chain", {}) + vulns = env.get("vulns", {}) + drift = env.get("arch_drift", {}) + + out: list[str] = _header(**meta) + + # Executive summary — synthesize a conservative verdict from health. + score = _g(health, "health_score") + out.append("## 1. Executive summary") + out.append("") + if isinstance(score, (int, float)): + if score >= 75: + band = "STRONG — investable with routine follow-up" + elif score >= 55: + band = "CAUTIONARY — investable with remediation" + else: + band = "NEEDS REMEDIATION — material engineering risk" + out.append(f"**Verdict: {band} (health {score}/100).**") + else: + out.append("**Verdict: see sections below (health score unavailable).**") + out.append("") + out.append( + "The sections below are generated directly from the repository. Each " + "cites the Roam command that produced it so every number is reproducible." + ) + out.append("") + out.append(f"- Codebase health: {_verdict(health)}") + out.append(f"- Key-person risk: {_verdict(bus)}") + out.append(f"- Duplication: {_verdict(clones)}") + out.append(f"- Dead code: {_verdict(dead)}") + out.append("") + + # Health + out.append("## 2. Codebase health (`roam health`)") + out.append("") + out.append("| Metric | Value |") + out.append("|---|---|") + out.append(f"| Overall health | {_cell(_g(health, 'health_score'))} / 100 |") + out.append(f"| Total cycles | {_cell(_g(health, 'cycles_total', _g(health, 'total_cycles')))} |") + out.append(f"| Actionable cycles | {_cell(_g(health, 'cycles_actionable', _g(health, 'actionable_cycles')))} |") + out.append(f"| God components | {_cell(_g(health, 'god_components'))} |") + out.append(f"| Tangle ratio | {_cell(_g(health, 'tangle_ratio'))} |") + out.append("") + + # Bus factor + out.append("## 3. Key-person / bus-factor risk (`roam bus-factor`)") + out.append("") + out.append(f"{_verdict(bus)}") + out.append("") + out.append(f"- High-risk modules: **{_cell(_g(bus, 'high_risk'))}**") + out.append(f"- Single-owner modules: **{_cell(_g(bus, 'solo_authored_count', _g(bus, 'concentrated')))}**") + out.append(f"- Directories analyzed: {_cell(_g(bus, 'directories_analyzed'))}") + out.append("") + + # Complexity + smells + out.append("## 4. Complexity & maintainability (`roam complexity`, `roam smells`)") + out.append("") + out.append("| Signal | Value |") + out.append("|---|---|") + out.append(f"| Average cognitive complexity | {_cell(_g(cx, 'average_complexity'))} |") + out.append(f"| P90 complexity | {_cell(_g(cx, 'p90_complexity'))} |") + out.append(f"| Critical-complexity symbols | {_cell(_g(cx, 'critical_count'))} |") + out.append(f"| Symbols analyzed | {_cell(_g(cx, 'total_analyzed'))} |") + out.append(f"| Total code smells | {_cell(_g(smells, 'total_smells'))} |") + out.append(f"| Files with smells | {_cell(_g(smells, 'files_affected'))} |") + out.append("") + + # Dead + clones + out.append("## 5. Dead code & duplication (`roam dead`, `roam clones`)") + out.append("") + out.append(f"- Dead code: {_verdict(dead)}") + out.append( + f" - Files affected: {_cell(_g(dead, 'files_affected'))}, " + f"estimated remediation: {_cell(_g(dead, 'total_effort_hours'))} hours" + ) + out.append(f"- Duplication: {_verdict(clones)}") + reducible = _g(clones, "estimated_reducible_lines") + if reducible is not None: + out.append(f" - Estimated reducible lines: **{_cell(reducible)}**") + out.append("") + + # Test signal + out.append("## 6. Test signal (`roam test-pyramid`)") + out.append("") + out.append(f"{_verdict(pyramid)}") + out.append("") + out.append( + f"- Test files: {_cell(_g(pyramid, 'total'))} " + f"(unit {_cell(_g(pyramid, 'unit'))}, integration {_cell(_g(pyramid, 'integration'))}, " + f"e2e {_cell(_g(pyramid, 'e2e'))})" + ) + out.append("") + + # Architecture drift + out.append("## 7. Architecture drift (`roam architecture-drift`)") + out.append("") + out.append(f"{_verdict(drift)}") + out.append("") + + # Security & supply chain + out.append("## 8. Security & supply chain (`roam vulns`, `roam sbom`, `roam supply-chain`)") + out.append("") + out.append("| Source | Signal |") + out.append("|---|---|") + out.append(f"| `roam vulns` | {_cell(_verdict(vulns))} |") + out.append( + f"| `roam sbom` | {_cell(_g(sbom, 'reachable_count'))} reachable of " + f"{_cell(_g(sbom, 'total_dependencies'))} deps, {_cell(_g(sbom, 'phantom_count'))} phantom |" + ) + out.append( + f"| `roam supply-chain` | risk {_cell(_g(supply, 'risk_score'))}/100, " + f"pin coverage {_cell(_g(supply, 'pin_coverage_pct'))}% |" + ) + out.append("") + + # Remediation themes + out.append("## 9. Remediation themes") + out.append("") + out.append( + "The highest-leverage items surface from sections 2–8 above: break the " + "actionable cycles, address single-owner concentration in the modules " + "named by `roam bus-factor`, and reduce the duplication `roam clones` " + "quantifies. A paid engagement turns these into a costed, sequenced " + "remediation plan." + ) + out.append("") + + out.extend(_paid_framing(type_meta=meta["type_meta"], client=meta["client"])) + out.extend( + _footer( + report_type="due-diligence", + generated_at=meta["generated_at"], + extra_scope=[ + "**Penetration testing.** Section 8 surfaces structural and reachability signals, not exploit paths.", + "**Runtime performance profiling.** Complexity is static; it is not a benchmark run.", + ], + ) + ) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# Type: ai-readiness +# --------------------------------------------------------------------------- + + +def _gather_ai_readiness() -> dict: + return { + "readiness": _run_roam_json(["ai-readiness"]), + "ai_ratio": _run_roam_json(["ai-ratio"]), + "agent_score": _run_roam_json(["agent-score"]), + "mode": _run_roam_json(["mode"]), + } + + +def _render_ai_readiness(*, env: dict, meta: dict) -> str: + readiness = env.get("readiness", {}) + ratio = env.get("ai_ratio", {}) + agents = env.get("agent_score", {}) + mode = env.get("mode", {}) + + out: list[str] = _header(**meta) + + score = _g(readiness, "score") + label = _g(readiness, "label") + out.append("## 1. Executive summary") + out.append("") + if score is not None: + out.append(f"**Readiness verdict: {_cell(score)}/100 — {_cell(label)}.**") + else: + out.append("**Readiness verdict: see dimensions below (score unavailable).**") + out.append("") + out.append( + "Readiness is scored across structural dimensions that predict how " + "safely agents can operate in this codebase, alongside the existing AI " + "footprint and the governance posture already in place." + ) + out.append("") + + # Readiness dimensions + out.append("## 2. Readiness dimensions (`roam ai-readiness`)") + out.append("") + dims = readiness.get("dimensions") if isinstance(readiness, dict) else None + if isinstance(dims, list) and dims: + out.append("| Dimension | Score | Weight | Contribution |") + out.append("|---|---:|---:|---:|") + for d in dims: + if not isinstance(d, dict): + continue + out.append( + f"| {_cell(d.get('label') or d.get('name'))} | {_cell(d.get('score'))} | " + f"{_cell(d.get('weight'))} | {_cell(d.get('contribution'))} |" + ) + out.append("") + else: + out.append(f"_{_verdict(readiness)}_") + out.append("") + + # AI footprint + out.append("## 3. Existing AI footprint (`roam ai-ratio`)") + out.append("") + out.append(f"{_verdict(ratio)}") + out.append("") + out.append( + f"- Estimated AI-generated share: **{_pct(_g(ratio, 'ai_ratio'), 1)}** " + f"(confidence: {_cell(_g(ratio, 'confidence'))}) across " + f"{_cell(_g(ratio, 'commits_analyzed'))} commits." + ) + out.append("") + + # Agent activity + out.append("## 4. Agent activity (`roam agent-score`)") + out.append("") + out.append(f"{_verdict(agents)}") + out.append("") + out.append(f"- Agents scored: **{_cell(_g(agents, 'agents_scored', _g(agents, 'count')))}**") + out.append("") + + # Governance posture + out.append("## 5. Governance posture (`roam mode`)") + out.append("") + out.append("| Gate | Status |") + out.append("|---|---|") + out.append(f"| Active mode | {_cell(_g(mode, 'active_mode'))} |") + out.append(f"| Allowed commands | {_cell(_g(mode, 'allowed_count'))} |") + out.append(f"| Policy source | {_cell(_g(mode, 'policy_source'))} |") + out.append(f"| Persisted | {_cell(_g(mode, 'persisted'))} |") + out.append("") + + # Recommendations + out.append("## 6. Recommendations") + out.append("") + recs = readiness.get("recommendations") if isinstance(readiness, dict) else None + if isinstance(recs, list) and recs: + for r in recs[:10]: + out.append(f"- {_cell(r)}") + else: + out.append("- No structured recommendations surfaced; see the dimension scores above.") + out.append("") + + # Phased rollout + out.append("## 7. Suggested phased rollout") + out.append("") + out.append("| Phase | Scope |") + out.append("|---|---|") + out.append("| 1 | Declare an active mode (`roam mode safe_edit`); enforce `roam preflight` pre-commit |") + out.append("| 2 | Agent edits in the lowest-blast-radius, best-tested zones only |") + out.append("| 3 | Expand to broader zones under senior review as the readiness score improves |") + out.append("") + + out.extend(_paid_framing(type_meta=meta["type_meta"], client=meta["client"])) + out.extend( + _footer( + report_type="ai-readiness", + generated_at=meta["generated_at"], + extra_scope=[ + "**Team practices & risk appetite.** Readiness scores structural " + "signals; the rollout decision also depends on team maturity.", + ], + ) + ) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# Type: reachability-triage +# --------------------------------------------------------------------------- + + +def _gather_reachability_triage() -> dict: + return { + "sbom": _run_roam_json(["sbom"]), + "supply_chain": _run_roam_json(["supply-chain"]), + "vulns": _run_roam_json(["vulns"]), + "vuln_reach": _run_roam_json(["vuln-reach"]), + "taint": _run_roam_json(["taint"]), + "secrets": _run_roam_json(["secrets"]), + } + + +def _render_reachability_triage(*, env: dict, meta: dict) -> str: + sbom = env.get("sbom", {}) + supply = env.get("supply_chain", {}) + vulns = env.get("vulns", {}) + vuln_reach = env.get("vuln_reach", {}) + taint = env.get("taint", {}) + secrets = env.get("secrets", {}) + + out: list[str] = _header(**meta) + + # Executive summary — the reachability wedge. + total_deps = _g(sbom, "total_dependencies") + reachable_deps = _g(sbom, "reachable_count") + taint_findings = _g(taint, "findings", 0) + secret_findings = _g(secrets, "total_findings", 0) + reach_vulns = _g(vuln_reach, "reachable_count", 0) + + out.append("## 1. Executive summary") + out.append("") + out.append( + "**The wedge: separate what is reachable from scanner noise.** This " + "sweep runs the scanners, then filters every finding against the call " + "graph — only findings reachable from a production entry point warrant " + "fix work this sprint." + ) + out.append("") + if isinstance(total_deps, (int, float)) and isinstance(reachable_deps, (int, float)): + out.append( + f"- Dependency reachability: **{_cell(reachable_deps)} of " + f"{_cell(total_deps)}** dependencies reachable " + f"({_pct(reachable_deps, total_deps)}); the rest are not reachable " + f"from the analysed entry points." + ) + out.append(f"- Reachable known vulnerabilities: **{_cell(reach_vulns)}**") + out.append(f"- Taint flows: **{_cell(taint_findings)}**") + out.append(f"- Active secrets: **{_cell(secret_findings)}**") + out.append("") + + # Reachable vulns + out.append("## 2. Known vulnerabilities (`roam vulns`, `roam vuln-reach`)") + out.append("") + out.append(f"- `roam vulns`: {_verdict(vulns)}") + out.append(f"- `roam vuln-reach`: {_verdict(vuln_reach)}") + out.append("") + if not (_g(vulns, "total") or _g(vuln_reach, "total_vulns")): + out.append( + "> No scanner report is ingested for this run. Ingest one with " + "`roam vulns --import-file ` (npm-audit, pip-audit, " + "trivy, or osv) then `roam vuln-map` to populate reachability — the " + "reachable-vs-raw reduction is the headline number for a paid engagement." + ) + out.append("") + + # Dependency reachability (the SBOM signal) + out.append("## 3. Dependency reachability (`roam sbom`)") + out.append("") + out.append("| Signal | Value |") + out.append("|---|---|") + out.append(f"| Total dependencies | {_cell(_g(sbom, 'total_dependencies'))} |") + out.append(f"| Reachable | {_cell(_g(sbom, 'reachable_count'))} |") + out.append(f"| Reachable (direct) | {_cell(_g(sbom, 'reachable_direct_count'))} |") + out.append(f"| Phantom (declared, not imported) | {_cell(_g(sbom, 'phantom_count'))} |") + out.append("") + out.append(f"_{_verdict(sbom)}_") + out.append("") + + # Taint exposure + out.append("## 4. Taint exposure (`roam taint`)") + out.append("") + out.append(f"{_verdict(taint)}") + out.append("") + out.append( + f"- Findings: **{_cell(_g(taint, 'findings'))}** across " + f"{_cell(_g(taint, 'rules'))} rule(s); risk score {_cell(_g(taint, 'risk_score'))}." + ) + out.append("") + + # Secrets + out.append("## 5. Secrets (`roam secrets`)") + out.append("") + out.append(f"{_verdict(secrets)}") + out.append("") + out.append(f"- Active secret findings: **{_cell(_g(secrets, 'total_findings'))}**") + out.append("") + + # Supply chain + out.append("## 6. Supply chain (`roam supply-chain`)") + out.append("") + out.append(f"{_verdict(supply)}") + out.append("") + out.append( + f"- Risk score: {_cell(_g(supply, 'risk_score'))}/100; " + f"pin coverage {_cell(_g(supply, 'pin_coverage_pct'))}%; " + f"unpinned {_cell(_g(supply, 'unpinned_count'))} of " + f"{_cell(_g(supply, 'total_dependencies'))}." + ) + out.append("") + + # Fix order + out.append("## 7. Recommended fix order") + out.append("") + out.append( + "1. Any reachable known vulnerability (section 2) — patch first.\n" + "2. Active secrets (section 5) — rotate, then scrub history.\n" + "3. Reachable taint flows (section 4) — sanitize the source→sink path.\n" + "4. Supply-chain pinning (section 6) — pin the unpinned direct deps.\n" + "5. Defer non-reachable findings; document why in the next scanner baseline." + ) + out.append("") + + out.extend(_paid_framing(type_meta=meta["type_meta"], client=meta["client"])) + out.extend( + _footer( + report_type="reachability-triage", + generated_at=meta["generated_at"], + extra_scope=[ + "**A penetration test or threat model.** Non-reachable findings " + "may still be exploitable via paths the static graph misses — " + "review with the security team before deprioritizing.", + ], + ) + ) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# Type: post-incident +# --------------------------------------------------------------------------- + + +def _gather_post_incident(commit_range: str) -> dict: + """Replay a range with postmortem + verify the audit trail.""" + postmortem = _run_postmortem(commit_range, limit=100) + return { + "postmortem": postmortem if isinstance(postmortem, dict) else {}, + "audit_trail": _run_roam_json(["audit-trail-verify"]), + } + + +def _render_post_incident(*, env: dict, meta: dict, commit_range: str) -> str: + postmortem = env.get("postmortem", {}) + trail = env.get("audit_trail", {}) + pm_summary = postmortem.get("summary") if isinstance(postmortem, dict) else {} + pm_summary = pm_summary if isinstance(pm_summary, dict) else {} + commits = postmortem.get("commits") if isinstance(postmortem, dict) else [] + commits = commits if isinstance(commits, list) else [] + + out: list[str] = _header(**meta) + + scanned = pm_summary.get("commits_scanned", len(commits)) + with_findings = pm_summary.get("commits_with_findings", 0) + + out.append("## 1. Incident window") + out.append("") + out.append(f"- Replayed range: `{commit_range}`") + out.append(f"- Commits replayed: **{_cell(scanned)}**") + out.append(f"- Commits that would have surfaced findings pre-merge: **{_cell(with_findings)}**") + out.append("") + + # Detector replay + out.append("## 2. Detector replay (`roam postmortem`)") + out.append("") + out.append( + "Each commit's outgoing diff is replayed against the current detector " + "set, as if it were a pull request — which findings would have " + "surfaced before the change merged?" + ) + out.append("") + flagged = [ + c for c in commits if isinstance(c, dict) and (int(c.get("high", 0) or 0) + int(c.get("medium", 0) or 0)) > 0 + ] + if flagged: + out.append("| Date | SHA | Subject | High | Medium | Top hits |") + out.append("|---|---|---|---:|---:|---|") + for c in flagged[:20]: + subject = (str(c.get("subject") or "")).replace("|", "/")[:60] + kinds = ", ".join(c.get("kinds") or []) + out.append( + f"| {_cell(c.get('date'))} | `{_cell(c.get('short_sha'))}` | {subject} | " + f"{_cell(c.get('high', 0))} | {_cell(c.get('medium', 0))} | {kinds or '-'} |" + ) + out.append("") + else: + out.append( + "_No commit in this window would have been flagged by the current " + "detector set. That is a clean-window observation, not proof of " + "absence — widen the range or confirm the detector covers the " + "incident class._" + ) + out.append("") + + # Audit trail + out.append("## 3. Audit-trail integrity (`roam audit-trail-verify`)") + out.append("") + out.append(f"{_verdict(trail)}") + out.append("") + out.append("| Signal | Value |") + out.append("|---|---|") + out.append(f"| Chain valid | {_cell(_g(trail, 'chain_valid'))} |") + out.append(f"| Chain tier | {_cell(_g(trail, 'chain_tier'))} |") + out.append(f"| Records | {_cell(_g(trail, 'total_records'))} |") + out.append(f"| Unsigned events | {_cell(_g(trail, 'unsigned_events'))} |") + out.append("") + out.append( + "A verified chain means the run ledger for this window has not been " + "tampered with — the attribution below rests on a signed record. A " + 'commit with no run record is itself a finding ("shipped without ' + 'ledger coverage").' + ) + out.append("") + + # Prevention + out.append("## 4. Prevention artifact") + out.append("") + out.append( + "For each detector class that surfaced in section 2, author a rule " + "under `.roam/rules/` that fails on the incident-introducing change if " + "reapplied, then wire it into `roam preflight` / `roam critique` so the " + "same class of change is blocked pre-merge. The durable output of a " + "post-incident engagement is that rule, not just the narrative." + ) + out.append("") + + out.extend(_paid_framing(type_meta=meta["type_meta"], client=meta["client"])) + out.extend( + _footer( + report_type="post-incident", + generated_at=meta["generated_at"], + extra_scope=[ + "**Full root-cause analysis.** Not every cause is a single " + "commit, and not every prevention is expressible as a static rule.", + "**Config-only / infra-only / third-party incidents.** This " + "replay covers code-change causes tracked in git history.", + ], + ) + ) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# Dispatch table. +# --------------------------------------------------------------------------- + +_GATHER = { + "due-diligence": lambda commit_range: _gather_due_diligence(), + "ai-readiness": lambda commit_range: _gather_ai_readiness(), + "reachability-triage": lambda commit_range: _gather_reachability_triage(), + "post-incident": lambda commit_range: _gather_post_incident(commit_range), +} + + +def _render(report_type: str, *, env: dict, meta: dict, commit_range: str) -> str: + if report_type == "due-diligence": + return _render_due_diligence(env=env, meta=meta) + if report_type == "ai-readiness": + return _render_ai_readiness(env=env, meta=meta) + if report_type == "reachability-triage": + return _render_reachability_triage(env=env, meta=meta) + if report_type == "post-incident": + return _render_post_incident(env=env, meta=meta, commit_range=commit_range) + raise ValueError(f"unknown report type: {report_type}") + + +def _headline(report_type: str, env: dict) -> str: + """One-line headline for the engagement ledger + envelope summary.""" + if report_type == "due-diligence": + return _verdict(env.get("health", {})) + if report_type == "ai-readiness": + return _verdict(env.get("readiness", {})) + if report_type == "reachability-triage": + return _verdict(env.get("sbom", {})) + if report_type == "post-incident": + pm = env.get("postmortem", {}) + return _verdict(pm) + return "not available" + + +# --------------------------------------------------------------------------- +# Engagement ledger — append-only JSONL next to .roam/index.db. Same file +# ``cmd_pr_replay`` writes to; the ``kind`` discriminator distinguishes +# service-report rows from pr-replay rows. Flat schema, additive only. +# --------------------------------------------------------------------------- + + +def _record_engagement( + *, + report_type: str, + client: str | None, + subject: str, + headline: str, + output_path: str, + generated_at: str, +) -> Path | None: + """Append one service-report record to ``.roam/engagements.jsonl``. + + Returns the ledger path on success, ``None`` on failure (never raises — + telemetry must not break a buyer-facing run). + """ + try: + ledger_dir = Path(".roam") + ledger_dir.mkdir(exist_ok=True) + ledger = ledger_dir / "engagements.jsonl" + record = { + "ledger_schema": 1, + "kind": "service-report", + "report_type": report_type, + "client": client, + "subject": subject, + "headline": headline, + "output_path": output_path, + "generated_at": generated_at, + } + with ledger.open("a", encoding="utf-8") as f: + f.write(_json.dumps(record) + "\n") + return ledger + except OSError: + return None + + +# --------------------------------------------------------------------------- +# CLI entry point. +# --------------------------------------------------------------------------- + + +@roam_capability( + category="review", + summary="Generate a one-command service-engagement report (due-diligence, AI-readiness, reachability-triage, post-incident).", + inputs=["report_type"], + outputs=["narrative_report", "sections"], + examples=[ + "roam service-report --type due-diligence", + "roam service-report --type reachability-triage --client 'Acme Inc' --output triage.md", + "roam service-report --type post-incident --range v1.0..main --output incident.md", + ], + tags=["audit", "review", "services", "demo"], + ai_safe=True, + requires_index=True, + since="13.5", +) +@click.command(name="service-report") +@click.option( + "--type", + "report_type", + type=click.Choice(list(_REPORT_TYPES.keys()), case_sensitive=False), + required=True, + help=( + "Report type. ``due-diligence`` (codebase health / M&A), " + "``ai-readiness`` (AI adoption readiness), ``reachability-triage`` " + "(security noise-reduction), or ``post-incident`` (detector + " + "audit-trail replay of a commit range)." + ), +) +@click.option( + "--client", + default=None, + help="Client name to inject into the report header (paid framing).", +) +@click.option( + "--range", + "commit_range", + default=None, + help=( + "Commit range for ``--type post-incident`` (e.g. ``v1.0..main``, " + "``HEAD~30..HEAD``). Ignored by the other report types. Defaults to " + "``HEAD~20..HEAD`` when unset." + ), +) +@click.option( + "--output", + "output_path", + default=None, + type=click.Path(dir_okay=False, writable=True), + help="Write the Markdown report to PATH instead of stdout.", +) +@click.option( + "--pdf", + "pdf_path", + default=None, + type=click.Path(dir_okay=False, writable=True), + help=( + "Also write a PDF render of the report to PATH (requires ``pandoc`` on " + "PATH, or ``reportlab`` as a fallback). Implies --output if unset; the " + "Markdown source is written next to the PDF as ``.md``." + ), +) +@click.option( + "--track-engagement/--no-track-engagement", + default=True, + show_default=True, + help=( + "When --output is set, append a one-line JSONL record to " + "``.roam/engagements.jsonl`` (report type, client, subject, headline, " + "output path, timestamp) so the operator has a single-file ledger of " + "every delivered report." + ), +) +@click.pass_context +def service_report_cmd( + ctx, + report_type: str, + client: str | None, + commit_range: str | None, + output_path: str | None, + pdf_path: str | None, + track_engagement: bool, +): + """Generate a one-command service-engagement report. + + Runs the right existing Roam primitives for the chosen ``--type``, + aggregates their JSON envelopes, and emits a buyer-facing narrative + report — the productised form of the templates under + ``templates/services-reports/``. Sibling of ``roam pr-replay``. + + \b + Examples: + roam service-report --type due-diligence + roam service-report --type reachability-triage --client "Acme Inc" --output triage.md + roam service-report --type post-incident --range v1.0..main --output incident.md + + \b + Output: Markdown by default; ``roam --json service-report`` returns the + full envelope (summary + sections + report_markdown). + """ + json_mode = ctx.obj.get("json") if ctx.obj else False + report_type = report_type.lower() + type_meta = _REPORT_TYPES[report_type] + ensure_index() + + # Post-incident is the only type that consumes a commit range. Validate + # it the same way pr-replay validates --range (reject argv-injection + # shapes) and default to a recent window. + if report_type == "post-incident": + if commit_range is None: + commit_range = "HEAD~20..HEAD" + elif not _is_safe_commit_range(commit_range): + raise click.UsageError( + f"--range value must not start with '-' (got {commit_range!r}); " + "use a git revspec like 'HEAD~30..HEAD', 'v1.0..main', or a branch name." + ) + else: + commit_range = commit_range or "" + + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + index_sha = _git_head_sha() + subject = client or "target repository" + + # Gather (best-effort — a single failing section returns {} and the + # renderer emits an honest "not available" line rather than crashing). + try: + env = _GATHER[report_type](commit_range) + except Exception: # noqa: BLE001 — the report must survive a bad section + env = {} + + meta = { + "type_meta": type_meta, + "report_type": report_type, + "client": client, + "index_sha": index_sha, + "generated_at": generated_at, + "subject": subject, + } + report_md = _render(report_type, env=env, meta=meta, commit_range=commit_range) + headline = _headline(report_type, env) + + # --pdf without --output writes the markdown sibling next to the PDF. + if pdf_path and not output_path: + output_path = str(Path(pdf_path).with_suffix(".md")) + + if output_path: + Path(output_path).write_text(report_md, encoding="utf-8") + if not json_mode: + click.echo(f"Wrote {len(report_md):,} bytes to {output_path}") + + pdf_backend = None + if pdf_path: + ok, info = _render_pdf(report_md, Path(pdf_path)) + if ok: + pdf_backend = info + if not json_mode: + click.echo(f"Wrote PDF to {pdf_path} (backend: {info})") + else: + click.echo(f"WARNING: PDF render failed — {info}", err=True) + + engagement_record = None + if track_engagement and output_path: + engagement_record = _record_engagement( + report_type=report_type, + client=client, + subject=subject, + headline=headline, + output_path=output_path, + generated_at=generated_at, + ) + if engagement_record and not json_mode: + click.echo(f"Logged engagement to {engagement_record}") + + if json_mode: + envelope = json_envelope( + "service-report", + summary={ + "verdict": headline, + "report_type": report_type, + "client": client, + "subject": subject, + "commit_range": commit_range or None, + "index_sha": index_sha, + "generated_at": generated_at, + "output_path": output_path, + "pdf_path": pdf_path, + "pdf_backend": pdf_backend, + "engagement_logged_to": str(engagement_record) if engagement_record else None, + "sections_present": sorted(k for k, v in env.items() if v), + }, + report_markdown=report_md, + sections=env, + ) + _target = (f"{report_type}:{commit_range}" if commit_range else report_type)[:80] + try: + auto_log(envelope, action="service-report", target=_target) + except Exception as _exc: # noqa: BLE001 — telemetry must not break the run + # Telemetry failure must not break the report — surface lineage + # so a dropped engagement-log record has a traceable cause. + from roam.observability import log_swallowed + + log_swallowed("cmd_service_report:auto_log", _exc) + click.echo(to_json(envelope)) + _ = EXIT_SUCCESS + return + + if not output_path: + click.echo(report_md) + + _ = EXIT_SUCCESS + return diff --git a/src/roam/competitor_site_data.py b/src/roam/competitor_site_data.py index 29955ae7..e0149e9c 100644 --- a/src/roam/competitor_site_data.py +++ b/src/roam/competitor_site_data.py @@ -1361,7 +1361,7 @@ "relationship": "self", "peer": True, "graph": "PageRank + Tarjan + Louvain + layers", - "note": "Graph algorithms (PageRank, SCC, Louvain, Fiedler) on tree-sitter ASTs fused with git history in SQLite. 243 MCP tools, 267 CLI commands. 19 Python idiom detectors (v12.7+). 54 algo detectors (12.40). Audit-trail commands: permit / postmortem / article-12-check.", + "note": "Graph algorithms (PageRank, SCC, Louvain, Fiedler) on tree-sitter ASTs fused with git history in SQLite. 243 MCP tools, 268 CLI commands. 19 Python idiom detectors (v12.7+). 54 algo detectors (12.40). Audit-trail commands: permit / postmortem / article-12-check.", "version_evaluated": "12.48", "repo_url": "https://github.com/Cranot/roam-code", }, diff --git a/templates/distribution/landing-page/docs/agent-contract.html b/templates/distribution/landing-page/docs/agent-contract.html index 183981c3..4ee6aebf 100644 --- a/templates/distribution/landing-page/docs/agent-contract.html +++ b/templates/distribution/landing-page/docs/agent-contract.html @@ -98,7 +98,7 @@

Surface at a glance

- + diff --git a/templates/distribution/landing-page/docs/integration-tutorials.html b/templates/distribution/landing-page/docs/integration-tutorials.html index e373e7a5..13291816 100644 --- a/templates/distribution/landing-page/docs/integration-tutorials.html +++ b/templates/distribution/landing-page/docs/integration-tutorials.html @@ -79,7 +79,7 @@

Integration Tutorials

Scope: MCP host setup (5 platforms), CI integration (5 platforms via roam ci-setup), gateway integration via the McpDecisionReceipt schema and HMAC-chained run ledger, and the OSCAL v1.2 control-mapping projection. Non-goal: hosted Roam deployment; private-deployment pilots are scoped separately by SOW.

- Surface scale on v13.4: 267 commands, 243 MCP tools (15 in the + Surface scale on v13.4: 268 commands, 243 MCP tools (15 in the default core preset, opt into full via ROAM_MCP_PRESET), 28 indexed languages. All local; no API keys. diff --git a/templates/distribution/landing-page/llms.txt b/templates/distribution/landing-page/llms.txt index 9f1ae22e..1cc81ec1 100644 --- a/templates/distribution/landing-page/llms.txt +++ b/templates/distribution/landing-page/llms.txt @@ -5,7 +5,7 @@ roam-code (`pip install roam-code`, Python 3.10+) is a local code-graph engine. It parses your repo once, stores structural facts in a local SQLite graph (symbols, dependencies, call graphs, architecture layers, git history, -runtime traces), and exposes the graph through 267 CLI commands and 243 MCP +runtime traces), and exposes the graph through 268 CLI commands and 243 MCP tools (16 in the default `core` preset) across 28 languages. AI agents (Claude Code, Cursor, Codex, Aider, your own) call it before, during, and after every change. diff --git a/templates/distribution/landing-page/pricing.html b/templates/distribution/landing-page/pricing.html index 6a37d432..32e0dfe2 100644 --- a/templates/distribution/landing-page/pricing.html +++ b/templates/distribution/landing-page/pricing.html @@ -168,7 +168,7 @@

The buying path

- + diff --git a/templates/distribution/landing-page/setup.html b/templates/distribution/landing-page/setup.html index 7dab0a40..9ed4ab15 100644 --- a/templates/distribution/landing-page/setup.html +++ b/templates/distribution/landing-page/setup.html @@ -94,7 +94,7 @@

1. Install the CLI

(incremental, near-instant — only changed files re-parse).

- Discover everything: roam --help (267 commands + Discover everything: roam --help (268 commands grouped by what an agent asks) or roam tour (guided walk through the five core verbs). Full reference at /docs/command-reference. @@ -154,7 +154,7 @@

2. Wire it into your editor

Browse the source - 267 commands live in src/roam/commands/, registered in cli.py. Apache 2.0. + 268 commands live in src/roam/commands/, registered in cli.py. Apache 2.0.
Need help? diff --git a/tests/test_service_report.py b/tests/test_service_report.py new file mode 100644 index 00000000..9950aad6 --- /dev/null +++ b/tests/test_service_report.py @@ -0,0 +1,553 @@ +"""Tests for ``roam service-report`` — the productised service-engagement command. + +``service-report`` is the sibling of ``roam pr-replay``: it turns the four +services-report templates (due-diligence, AI-readiness, reachability-triage, +post-incident) into one-command deliverables. Each ``--type`` runs the right +existing Roam primitives, aggregates their JSON envelopes, and emits a +buyer-facing Markdown narrative. + +Tests focus on: +* Each ``--type`` renders a distinct, well-formed report (fast, via stubbed + primitives — the real subprocess path is exercised by one slow test). +* Every report carries the honest disclaimer banner and is wording-guard + clean (no ``certifies`` / ``guaranteed`` / ``compliant`` outside a + negation window — the W184 / W203 discipline). +* The JSON envelope is parseable and carries the expected keys. +* ``--output`` writes the markdown; ``--type`` is required. +* The engagement ledger appends, not overwrites. + +The primitive calls (``_run_roam_json`` / ``_run_postmortem``) are stubbed +with canned envelopes shaped like the real command output captured from +roam-code, so the render/dispatch/envelope path is tested deterministically +and fast. One ``@pytest.mark.slow`` test runs the real end-to-end path. +""" + +from __future__ import annotations + +import json as _json + +import pytest +from click.testing import CliRunner + +from tests._helpers.wording_lint import scan_for_overclaims + +# --------------------------------------------------------------------------- +# Canned envelopes — shaped like the real ``roam --json `` output. +# --------------------------------------------------------------------------- + +_CANNED: dict[str, dict] = { + "health": { + "summary": { + "verdict": "Fair codebase (75/100) — 47 critical, 9 warnings", + "health_score": 75, + "cycles_total": 10, + "cycles_actionable": 2, + "god_components": 50, + "tangle_ratio": 0.0, + } + }, + "bus-factor": { + "summary": { + "verdict": "bus factor 1 (min), 58 high-risk, 63 single-owner modules", + "high_risk": 58, + "solo_authored_count": 63, + "directories_analyzed": 63, + } + }, + "complexity": { + "summary": { + "verdict": "avg complexity 3.7, 867 critical, 781 high", + "average_complexity": 3.7, + "p90_complexity": 9.0, + "critical_count": 867, + "total_analyzed": 25537, + } + }, + "dead": { + "summary": { + "verdict": "579 dead export(s): 31 safe, 384 review, 164 intentional", + "files_affected": 162, + "total_effort_hours": 1225.6, + } + }, + "clones": { + "summary": { + "verdict": "115 clone clusters found (646 functions, 79% avg similarity)", + "estimated_reducible_lines": 36681, + } + }, + "smells": { + "summary": { + "verdict": "Needs refactoring: 4367 smells (113 critical, 2508 warning) in 957 files", + "total_smells": 4367, + "files_affected": 957, + } + }, + "test-pyramid": { + "summary": { + "verdict": "MOSTLY-UNSTRUCTURED — 1130 of 1145 test files have no kind hint", + "total": 1145, + "unit": 0, + "integration": 5, + "e2e": 4, + } + }, + "sbom": { + "summary": { + "verdict": "4 reachable (4 direct, 0 heuristic), 19 phantom", + "total_dependencies": 23, + "reachable_count": 4, + "reachable_direct_count": 4, + "phantom_count": 19, + } + }, + "supply-chain": { + "summary": { + "verdict": "Supply chain risky (36/100) -- 0 unpinned dependencies", + "risk_score": 36, + "pin_coverage_pct": 0.0, + "unpinned_count": 0, + "total_dependencies": 23, + } + }, + "vulns": { + "summary": { + "verdict": "no vulnerability scan available (vulnerabilities table is empty)", + "total": 0, + } + }, + "vuln-reach": { + "summary": { + "verdict": "No vulnerabilities ingested. Run vuln-map first.", + "reachable_count": 0, + "total_vulns": 0, + } + }, + "taint": { + "summary": { + "verdict": "No taint findings across 22 rule(s)", + "findings": 0, + "rules": 22, + "risk_score": 0, + } + }, + "secrets": { + "summary": { + "verdict": "No secrets found", + "total_findings": 0, + } + }, + "architecture-drift": { + "summary": { + "verdict": "Need at least 2 snapshots within window — found 1", + "state": "insufficient_snapshots", + } + }, + "ai-readiness": { + "summary": { + "verdict": "AI Readiness 57/100 -- FAIR", + "score": 57, + "label": "FAIR", + }, + "dimensions": [ + { + "label": "Naming consistency", + "name": "naming_consistency", + "score": 100, + "weight": 15, + "contribution": 15.0, + }, + {"label": "Module coupling", "name": "module_coupling", "score": 100, "weight": 20, "contribution": 20.0}, + {"label": "Dead code noise", "name": "dead_code_noise", "score": 0, "weight": 15, "contribution": 0.0}, + { + "label": "Test signal strength", + "name": "test_signal_strength", + "score": 9, + "weight": 20, + "contribution": 1.8, + }, + ], + "recommendations": [ + "Remove 469 dead exports to reduce agent confusion", + "Increase test coverage mapping (currently 9%)", + "Break 10 dependency cycles", + ], + }, + "ai-ratio": { + "summary": { + "verdict": "~59% estimated AI-generated code (confidence: HIGH)", + "ai_ratio": 0.59, + "confidence": "HIGH", + "commits_analyzed": 406, + } + }, + "agent-score": { + "summary": { + "verdict": "Scored 16 agents; top: recheck 99.6/100 over 2 runs", + "agents_scored": 16, + "count": 16, + } + }, + "mode": { + "summary": { + "verdict": "active mode: safe_edit (13 allowed commands)", + "active_mode": "safe_edit", + "allowed_count": 13, + "policy_source": "default+constitution", + "persisted": False, + } + }, + "audit-trail-verify": { + "summary": { + "verdict": "chain valid (9 records)", + "chain_valid": True, + "chain_tier": "CHAIN_VERIFIED", + "total_records": 9, + "unsigned_events": 0, + } + }, +} + +_CANNED_POSTMORTEM = { + "summary": { + "verdict": "11 of 20 commits would have surfaced findings", + "commits_scanned": 20, + "commits_with_findings": 11, + }, + "commits": [ + { + "date": "2026-05-21", + "short_sha": "4b8c61c9", + "subject": "refactor: make fallback chains loud", + "high": 0, + "medium": 12, + "kinds": ["impact x12"], + }, + { + "date": "2026-05-22", + "short_sha": "83f5c44c", + "subject": "fix: dogfood-v2 defects", + "high": 1, + "medium": 3, + "kinds": ["impact x2", "intent x1"], + }, + { + "date": "2026-05-21", + "short_sha": "2852c675", + "subject": "chore(release): v13.4", + "high": 0, + "medium": 0, + "kinds": [], + }, + ], +} + + +@pytest.fixture +def stub_primitives(monkeypatch): + """Stub the primitive-invocation helpers with canned envelopes. + + Makes the render/dispatch/envelope path deterministic and fast — no + subprocess, no index build. ``ensure_index`` is neutralised so the + command doesn't try to (re)build an index during the render test. + """ + from roam.commands import cmd_service_report as mod + + def _fake_run_roam_json(args): + return _CANNED.get(args[0], {}) + + def _fake_run_postmortem(commit_range, *, limit=100): + return _CANNED_POSTMORTEM + + monkeypatch.setattr(mod, "_run_roam_json", _fake_run_roam_json) + monkeypatch.setattr(mod, "_run_postmortem", _fake_run_postmortem) + monkeypatch.setattr(mod, "ensure_index", lambda *a, **k: None) + return mod + + +def _invoke(*args: str, json_mode: bool = False) -> tuple[int, str]: + from roam.cli import cli + + runner = CliRunner() + cli_args = (["--json"] if json_mode else []) + ["service-report", *args] + result = runner.invoke(cli, cli_args, catch_exceptions=False) + return result.exit_code, result.output + + +ALL_TYPES = ["due-diligence", "ai-readiness", "reachability-triage", "post-incident"] + + +# --------------------------------------------------------------------------- +# Smoke tests — every type produces a valid, banner-carrying report. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("rtype", ALL_TYPES) +def test_each_type_renders_a_report(stub_primitives, rtype): + code, out = _invoke("--type", rtype) + assert code == 0, f"{rtype} exited non-zero: {out[:300]}" + # Title line + assert out.startswith("# "), f"{rtype}: report must start with an H1 title" + # Honest disclaimer banner + assert "Engineering evidence, not an attestation." in out + assert "does not certify" in out + # Tool attribution line + assert f"roam service-report --type {rtype}" in out + # "What this does not cover" section present + assert "## What this report does not cover" in out + # Paid framing present + assert "About this engagement" in out + + +@pytest.mark.parametrize("rtype", ALL_TYPES) +def test_each_type_is_wording_guard_clean(stub_primitives, rtype): + """No compliance-overclaim wording outside a negation window (W184/W203).""" + code, out = _invoke("--type", rtype) + assert code == 0 + violations = scan_for_overclaims(out) + assert not violations, f"{rtype} report has wording-guard violations: {violations}" + + +def test_due_diligence_surfaces_health_verdict(stub_primitives): + code, out = _invoke("--type", "due-diligence") + assert code == 0 + assert "Codebase Due Diligence Report" in out + assert "health 75/100" in out + assert "roam health" in out + assert "roam bus-factor" in out + # Real numbers from the canned health/clones envelopes land in the report + assert "36681" in out # estimated reducible lines from clones + + +def test_reachability_triage_leads_with_the_wedge(stub_primitives): + code, out = _invoke("--type", "reachability-triage") + assert code == 0 + assert "Security Reachability Triage" in out + assert "reachable from scanner noise" in out + # Dependency reachability signal (4 of 23) present + assert "4 of 23" in out or ("Reachable | 4" in out and "Total dependencies | 23" in out) + + +def test_ai_readiness_renders_dimension_table(stub_primitives): + code, out = _invoke("--type", "ai-readiness") + assert code == 0 + assert "AI Adoption Readiness Audit" in out + assert "57/100" in out + # Dimension rows land + assert "Naming consistency" in out + assert "Module coupling" in out + # Recommendations land + assert "Break 10 dependency cycles" in out + + +def test_post_incident_replays_the_range(stub_primitives): + code, out = _invoke("--type", "post-incident", "--range", "HEAD~20..HEAD") + assert code == 0 + assert "Post-Incident Replay Report" in out + assert "HEAD~20..HEAD" in out + assert "Commits replayed: **20**" in out + # A flagged commit from the canned postmortem lands in the table + assert "4b8c61c9" in out + # Audit-trail integrity section present + assert "chain valid (9 records)" in out + assert "CHAIN_VERIFIED" in out + + +# --------------------------------------------------------------------------- +# JSON envelope +# --------------------------------------------------------------------------- + + +def test_json_envelope_is_well_formed(stub_primitives): + code, out = _invoke("--type", "reachability-triage", json_mode=True) + assert code == 0 + envelope = _json.loads(out[out.find("{") :]) + assert envelope["command"] == "service-report" + assert "summary" in envelope + summary = envelope["summary"] + assert summary["report_type"] == "reachability-triage" + assert "verdict" in summary + assert "generated_at" in summary + assert "sections_present" in summary + # Body + assert isinstance(envelope.get("sections"), dict) + assert isinstance(envelope.get("report_markdown"), str) + assert envelope["report_markdown"].startswith("# ") + + +def test_json_envelope_sections_present_lists_run_commands(stub_primitives): + code, out = _invoke("--type", "due-diligence", json_mode=True) + assert code == 0 + envelope = _json.loads(out[out.find("{") :]) + present = envelope["summary"]["sections_present"] + assert "health" in present + assert "clones" in present + + +# --------------------------------------------------------------------------- +# --output / --type required +# --------------------------------------------------------------------------- + + +def test_output_writes_markdown_to_file(stub_primitives, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + target = tmp_path / "triage.md" + code, out = _invoke("--type", "reachability-triage", "--output", str(target)) + assert code == 0 + assert target.exists() + body = target.read_text(encoding="utf-8") + assert body.startswith("# Security Reachability Triage") + assert "Wrote" in out + + +def test_type_is_required(): + """Missing --type is a clean Click usage error (exit 2), not a traceback.""" + from roam.cli import cli + + runner = CliRunner() + result = runner.invoke(cli, ["service-report"], catch_exceptions=False) + assert result.exit_code == 2 + assert "Traceback (most recent call last)" not in (result.output or "") + + +def test_post_incident_rejects_argv_injection_range(stub_primitives): + """A --range beginning with '-' is rejected at the CLI boundary.""" + code, out = _invoke("--type", "post-incident", "--range", "--upload-pack=evil") + assert code != 0 + assert "must not start with" in out + + +# --------------------------------------------------------------------------- +# Engagement ledger +# --------------------------------------------------------------------------- + + +def test_engagement_ledger_records_service_report(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + from roam.commands.cmd_service_report import _record_engagement + + rec = _record_engagement( + report_type="due-diligence", + client="Acme Inc", + subject="Acme Inc", + headline="Fair codebase (75/100)", + output_path=str(tmp_path / "dd.md"), + generated_at="2026-07-07 03:31 UTC", + ) + assert rec is not None + ledger = tmp_path / ".roam" / "engagements.jsonl" + assert ledger.exists() + record = _json.loads(ledger.read_text(encoding="utf-8").strip()) + assert record["kind"] == "service-report" + assert record["report_type"] == "due-diligence" + assert record["client"] == "Acme Inc" + assert record["ledger_schema"] == 1 + + +def test_engagement_ledger_appends_not_overwrites(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + from roam.commands.cmd_service_report import _record_engagement + + _record_engagement( + report_type="due-diligence", + client="Acme Inc", + subject="Acme", + headline="h1", + output_path="a.md", + generated_at="2026-07-07 10:00 UTC", + ) + _record_engagement( + report_type="reachability-triage", + client="Beta Corp", + subject="Beta", + headline="h2", + output_path="b.md", + generated_at="2026-07-07 11:00 UTC", + ) + ledger = tmp_path / ".roam" / "engagements.jsonl" + lines = ledger.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + assert _json.loads(lines[0])["client"] == "Acme Inc" + assert _json.loads(lines[1])["report_type"] == "reachability-triage" + + +def test_no_track_engagement_skips_ledger(stub_primitives, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + output = tmp_path / "r.md" + code, out = _invoke( + "--type", + "reachability-triage", + "--output", + str(output), + "--no-track-engagement", + ) + assert code == 0 + ledger = tmp_path / ".roam" / "engagements.jsonl" + if ledger.exists(): + assert ledger.read_text(encoding="utf-8").strip() == "" + + +# --------------------------------------------------------------------------- +# Report-type registry contract +# --------------------------------------------------------------------------- + + +def test_report_types_registry_contract(): + from roam.commands.cmd_service_report import _REPORT_TYPES + + assert set(_REPORT_TYPES.keys()) == { + "due-diligence", + "ai-readiness", + "reachability-triage", + "post-incident", + } + required = {"label", "title", "purpose_line", "engagement_price", "lead_commands"} + for rtype, meta in _REPORT_TYPES.items(): + missing = required - meta.keys() + assert not missing, f"type '{rtype}' missing keys: {missing}" + assert isinstance(meta["lead_commands"], list) and meta["lead_commands"] + + +# --------------------------------------------------------------------------- +# Pure-render unit tests — no CLI, no stubbing needed. +# --------------------------------------------------------------------------- + + +def test_render_survives_empty_envelopes(): + """A gather that returns {} for every section still renders a report.""" + from roam.commands.cmd_service_report import _REPORT_TYPES, _render + + for rtype in ALL_TYPES: + meta = { + "type_meta": _REPORT_TYPES[rtype], + "report_type": rtype, + "client": None, + "index_sha": None, + "generated_at": "2026-07-07 00:00 UTC", + "subject": "target repository", + } + md = _render(rtype, env={}, meta=meta, commit_range="HEAD~20..HEAD") + assert md.startswith("# ") + assert "does not certify" in md + # Even the empty-data path is wording-guard clean. + assert not scan_for_overclaims(md) + + +# --------------------------------------------------------------------------- +# Slow: real end-to-end path (actual primitive subprocesses). +# --------------------------------------------------------------------------- + + +@pytest.mark.slow +def test_reachability_triage_real_end_to_end(): + """The real path: actual `roam` primitives run against the repo. + + reachability-triage is the fastest real type (no heavy graph walk), + so it's the one we exercise end-to-end. Asserts a well-formed, + wording-clean report — proof the stubbed tests match reality. + """ + code, out = _invoke("--type", "reachability-triage") + assert code == 0, out[:400] + assert out.startswith("# Security Reachability Triage") + assert "Dependency reachability" in out + assert not scan_for_overclaims(out)
SurfaceCountNotes
CLI commands267260 canonical + 7 aliases.
CLI commands268261 canonical + 7 aliases.
MCP tools registered243Full registry — pick a subset via preset.
MCP tools in core preset16Default for new agent integrations.
Languages28Tier-1 extractors plus tree-sitter fallback.
commercial
267 CLI commands268 CLI commands Yes Yes Yes