From c0310d5fa4fafaef23be1b4ac806a1026c76846b Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 10 Jul 2026 23:12:41 +0300 Subject: [PATCH 1/2] Add reproducible RFC review dashboard workflow --- .github/workflows/docs.yml | 17 + .gitignore | 1 + Makefile | 10 + README.md | 22 +- review/build_review.py | 160 ++++ review/check_review.mjs | 14 + review/requirements.txt | 1 + review/review-data.json | 1597 +++++++++++++++++++++++++++++++++++ review/review-template.html | 720 ++++++++++++++++ review/standalone.css | 129 +++ 10 files changed, 2670 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 review/build_review.py create mode 100644 review/check_review.mjs create mode 100644 review/requirements.txt create mode 100644 review/review-data.json create mode 100644 review/review-template.html create mode 100644 review/standalone.css diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f0db136..f43035d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,6 +18,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Check required RFC draft shell: bash run: | @@ -26,6 +36,13 @@ jobs: test -s drafts/agent-surface.md grep -q '^# Agent Surface Protocol Specification' drafts/agent-surface.md + - name: Check RFC review dashboard + shell: bash + run: | + set -euo pipefail + python -m pip install --requirement review/requirements.txt + make review-check + - name: Check Markdown hygiene shell: bash run: | diff --git a/.gitignore b/.gitignore index 31bfd70..e8bdab5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target/ /generated/ .DS_Store +review/__pycache__/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8be7b9a --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +PYTHON ?= python3 + +.PHONY: review-build review-check + +review-build: + $(PYTHON) review/build_review.py + +review-check: + $(PYTHON) review/build_review.py --check + node review/check_review.mjs diff --git a/README.md b/README.md index 228874d..2436aa4 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,33 @@ Agent Surface + Agent Grant bind those pieces into safe app-specific delegation. agent-surface/ .github/ PULL_REQUEST_TEMPLATE.md - workflows/docs.yml Markdown and RFC checks. + workflows/docs.yml Markdown, RFC, and dashboard checks. drafts/ Source RFCs written in Markdown. + review/ Source data, template, and generated RFC review dashboard. LICENSE MIT license for repository source code. LICENSE-CC-BY-4.0 CC BY 4.0 summary for specifications and documents. CONTRIBUTING.md Contribution guidelines. ``` +## Interactive RFC Review Dashboard + +The standalone [RFC review dashboard](review/agent-surface-rfc-review.html) is +generated from the RFC, card data, and UI template. Do not edit the generated +HTML directly. + +```sh +python3 -m pip install -r review/requirements.txt +make review-build +make review-check +``` + +When the RFC changes, update the relevant cards in +[`review/review-data.json`](review/review-data.json), rebuild the dashboard, +and commit the RFC, review data, and generated HTML together. `review-check` +validates card fields and priorities, verifies every linked heading still +exists in the RFC, checks that the generated artifact is current, and parses +the dashboard's inline JavaScript. + ## Status The specification is experimental and subject to change. The current draft is diff --git a/review/build_review.py b/review/build_review.py new file mode 100644 index 0000000..6994b0f --- /dev/null +++ b/review/build_review.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Build the standalone Agent Surface RFC review dashboard.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import unicodedata +from collections import Counter, defaultdict +from pathlib import Path + +from markdown_it import MarkdownIt + + +ROOT = Path(__file__).resolve().parents[1] +REVIEW_DIR = Path(__file__).resolve().parent +RFC_PATH = ROOT / "drafts" / "agent-surface.md" +DATA_PATH = REVIEW_DIR / "review-data.json" +TEMPLATE_PATH = REVIEW_DIR / "review-template.html" +STYLESHEET_PATH = REVIEW_DIR / "standalone.css" +OUTPUT_PATH = REVIEW_DIR / "agent-surface-rfc-review.html" +VALID_PRIORITIES = {"P0", "P1", "P2", "P3"} +VALID_SIDES = {"left", "right"} +VALID_STATUSES = {"present", "partial", "missing"} + + +def slugify(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode() + slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-") + return slug or "section" + + +def render_rfc() -> tuple[str, dict[str, str]]: + markdown = MarkdownIt("commonmark", {"html": False, "linkify": True}) + tokens = markdown.parse(RFC_PATH.read_text(encoding="utf-8")) + occurrences: Counter[str] = Counter() + headings: defaultdict[str, list[tuple[int, str]]] = defaultdict(list) + + for index, token in enumerate(tokens): + if token.type != "heading_open": + continue + title = tokens[index + 1].content.strip() + level = int(token.tag[1:]) + occurrences[title] += 1 + anchor_id = slugify(title) + if occurrences[title] > 1: + anchor_id = f"{anchor_id}-{occurrences[title]}" + token.attrSet("id", anchor_id) + token.attrSet("data-asp-heading", title) + headings[title].append((level, anchor_id)) + + heading_ids = { + title: sorted(candidates, key=lambda candidate: candidate[0])[0][1] + for title, candidates in headings.items() + } + return markdown.renderer.render(tokens, markdown.options, {}), heading_ids + + +def load_reviews(heading_ids: dict[str, str]) -> list[dict[str, object]]: + payload = json.loads(DATA_PATH.read_text(encoding="utf-8")) + reviews = payload.get("reviews") + if not isinstance(reviews, list) or not reviews: + raise ValueError("review-data.json must contain a non-empty reviews array") + + ids = [review.get("id") for review in reviews] + if not all(isinstance(review_id, int) for review_id in ids): + raise ValueError("Every review id must be an integer") + if sorted(ids) != list(range(1, len(reviews) + 1)): + raise ValueError("Review ids must be unique and sequential, starting at 1") + + missing_headings: list[str] = [] + normalized_reviews: list[dict[str, object]] = [] + required_fields = {"id", "title", "description", "category", "side", "status", "rationale", "priority", "anchors"} + for review in sorted(reviews, key=lambda item: item["id"]): + absent = required_fields - set(review) + if absent: + raise ValueError(f"Review #{review.get('id', '?')} is missing fields: {', '.join(sorted(absent))}") + if review["side"] not in VALID_SIDES: + raise ValueError(f"Review #{review['id']} has invalid side: {review['side']}") + if review["status"] not in VALID_STATUSES: + raise ValueError(f"Review #{review['id']} has invalid status: {review['status']}") + if review["priority"] not in VALID_PRIORITIES: + raise ValueError(f"Review #{review['id']} has invalid priority: {review['priority']}") + if not all(isinstance(review[field], str) and review[field].strip() for field in required_fields - {"id", "anchors"}): + raise ValueError(f"Review #{review['id']} has an empty text field") + anchors = review["anchors"] + if not isinstance(anchors, list) or not anchors: + raise ValueError(f"Review #{review['id']} must have anchors") + anchor_headings = [] + for anchor in anchors: + heading = anchor if isinstance(anchor, str) else anchor.get("heading") if isinstance(anchor, dict) else None + if not isinstance(heading, str) or not heading.strip(): + raise ValueError(f"Review #{review['id']} has an invalid anchor") + anchor_headings.append(heading) + if len(anchor_headings) != len(set(anchor_headings)): + raise ValueError(f"Review #{review['id']} must have unique anchors") + + resolved_anchors = [] + for heading in anchor_headings: + anchor_id = heading_ids.get(heading) + if anchor_id is None: + missing_headings.append(f"#{review['id']}: {heading}") + continue + resolved_anchors.append({"heading": heading, "anchorId": anchor_id}) + normalized_reviews.append({**review, "anchors": resolved_anchors}) + + if missing_headings: + raise ValueError("Unresolved RFC headings:\n" + "\n".join(missing_headings)) + return normalized_reviews + + +def build_document() -> str: + rfc_html, heading_ids = render_rfc() + reviews = load_reviews(heading_ids) + template = TEMPLATE_PATH.read_text(encoding="utf-8") + fragment = template.replace("", rfc_html).replace( + "/*__REVIEW_DATA__*/", json.dumps(reviews, ensure_ascii=False, separators=(",", ":")) + ) + if "" in fragment or "/*__REVIEW_DATA__*/" in fragment: + raise ValueError("review template placeholders were not fully replaced") + stylesheet = STYLESHEET_PATH.read_text(encoding="utf-8") + return f""" + + + + + + +Agent Surface Protocol — Interactive RFC Review + + + +
+{fragment} +
+ + +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail when the committed dashboard is stale") + args = parser.parse_args() + document = build_document() + if args.check: + if not OUTPUT_PATH.exists() or OUTPUT_PATH.read_text(encoding="utf-8") != document: + print("Dashboard is stale; run: make review-build", file=sys.stderr) + return 1 + print(f"Dashboard is current: {OUTPUT_PATH.relative_to(ROOT)}") + return 0 + OUTPUT_PATH.write_text(document, encoding="utf-8") + print(f"Built {OUTPUT_PATH.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/review/check_review.mjs b/review/check_review.mjs new file mode 100644 index 0000000..fe96dca --- /dev/null +++ b/review/check_review.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; + +const dashboardPath = new URL("./agent-surface-rfc-review.html", import.meta.url); +const dashboard = readFileSync(dashboardPath, "utf8"); +const scripts = [...dashboard.matchAll(/ diff --git a/review/standalone.css b/review/standalone.css new file mode 100644 index 0000000..c1b5fa8 --- /dev/null +++ b/review/standalone.css @@ -0,0 +1,129 @@ +:root { + color-scheme: light dark; + --background: light-dark(rgb(250 250 248), rgb(22 23 24)); + --foreground: light-dark(rgb(28 30 32), rgb(244 245 246)); + --card: light-dark(rgb(237 238 235), rgb(36 37 38)); + --card-foreground: var(--foreground); + --muted: light-dark(rgb(232 233 230), rgb(48 49 50)); + --muted-foreground: light-dark(rgb(91 94 97), rgb(167 170 173)); + --border: light-dark(rgb(28 30 32 / 14%), rgb(244 245 246 / 14%)); + --primary: light-dark(rgb(23 105 176), rgb(126 191 248)); + --destructive: light-dark(rgb(190 51 42), rgb(248 111 101)); + --viz-series-1: var(--primary); + --viz-series-2: light-dark(rgb(183 91 29), rgb(239 155 91)); + --viz-series-3: light-dark(rgb(46 137 77), rgb(112 204 140)); + --font-size-base: 14px; + --font-size-small: 12px; + --radius-sm: 5px; + --radius-xl: 10px; + --radius-2xl: 14px; + background: var(--background); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-width: 0; + color: var(--foreground); + background: var(--background); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: var(--font-size-base); +} + +body { + padding: 16px; +} + +#widget { + width: 100%; +} + +.card { + min-width: 0; + padding: 12px; + overflow-wrap: break-word; + border-radius: var(--radius-2xl); + color: var(--card-foreground); + background: var(--card); +} + +h1, +h2, +h3, +h4, +p { + margin-block: 0; +} + +h1 { + font-size: calc(var(--font-size-base) * 1.72); + font-weight: 600; + line-height: 1.25; +} + +h2 { + font-size: calc(var(--font-size-base) * 1.43); + font-weight: 600; + line-height: 1.25; +} + +h3, +h4 { + font-size: calc(var(--font-size-base) * 1.28); + font-weight: 600; + line-height: 1.3; +} + +strong, +th { + font-weight: 600; +} + +a { + color: var(--primary); + text-underline-offset: 3px; +} + +a:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; + border-radius: var(--radius-sm); +} + +.btn { + display: inline-flex; + min-height: 28px; + align-items: center; + justify-content: center; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--foreground); + background: var(--muted); + font: inherit; + cursor: pointer; +} + +.btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +ul, +ol { + padding-inline-start: 26px; +} + +@media (max-width: 560px) { + body { + padding: 8px; + } +} From 72660ce2500b70cf66c612e530bb5629b45a740a Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 11 Jul 2026 12:01:39 +0300 Subject: [PATCH 2/2] Fix review layout after viewport changes --- review/agent-surface-rfc-review.html | 133 +++++++++++++++++++++++++-- review/review-template.html | 5 +- 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/review/agent-surface-rfc-review.html b/review/agent-surface-rfc-review.html index 0f83831..fd0aeaf 100644 --- a/review/agent-surface-rfc-review.html +++ b/review/agent-surface-rfc-review.html @@ -210,6 +210,7 @@

Abstract

Normative and Informative Sections

Unless otherwise stated, the following sections are normative:

Conventions

-

The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this -document are to be interpreted in the RFC 2119 and RFC 8174 sense when, and only -when, they appear in all capitals.

+

The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", and +"MAY" in this document are to be interpreted in the RFC 2119 and RFC 8174 sense +when, and only when, they appear in all capitals.

This is not an IETF document. The keywords are used to make interoperability and security expectations explicit for future implementers.

Terminology

@@ -743,7 +746,7 @@

Discovery

If the manifest contains sensitive tenant-specific affordances, it MUST require ordinary authenticated app access.

-

The manifest SHOULD be served with:

+

The manifest MUST be served over HTTPS and SHOULD be served with:

Content-Type: application/json
 Cache-Control: max-age=300
 
@@ -932,6 +935,7 @@

Actions

  • output_schema
  • side_effect
  • execution
  • +
  • optional capability_hint
  • idempotency for side-effecting actions
  • receipt for side-effecting actions
  • @@ -957,6 +961,12 @@

    Proposal proposal-only actions.

    A proposal-only action is a typed action whose output is a draft, suggestion, patch, review body, or other non-committed artifact.

    +

    A proposal-only action declares side_effect: false because it does not +commit domain-visible changes. However, when the application persists +proposals as drafts — as the proposal flow in this draft assumes — repeated +proposal requests can still accumulate duplicate drafts under retries and +agent loops. Applications that persist proposals SHOULD accept idempotency +keys for proposal actions and deduplicate stored drafts accordingly.

    Example:

    {
       "id": "pull_request.review.propose",
    @@ -993,6 +1003,11 @@ 

    Events

    "schema": "https://example.com/schemas/ci-failed.event.schema.json" }
    +

    Grant constraints filter events the same way they filter actions: a grant +constrained to one repository SHOULD NOT receive events about other +repositories, even when the event scope matches.

    +

    Event delivery semantics — transport, ordering, acknowledgement, and replay — +are not defined in this draft; see Open Questions.

    Risk Taxonomy

    Every action SHOULD have a standard risk label. Runtimes can map risk labels to local policy defaults.

    @@ -1006,6 +1021,15 @@

    Risk Taxonomy

    | financial_side_effect | Charges, refunds, purchases, invoices, payroll. | Always require explicit approval. | | destructive | Deletes, closes, revokes, disables, or irreversibly changes state. | Deny by default or require step-up approval. | | privileged | Changes permissions, secrets, tokens, admin settings, or access policy. | Deny by default. |

    +

    Risk labels are ordered by increasing severity from read to privileged. +The labels are not mutually exclusive properties of an action: a single action +can plausibly be described by several of them. When more than one label +applies, the action MUST carry the most severe applicable label. For example, +invoice.refund.request is both a mutation and a financial operation; it MUST +be labeled financial_side_effect, not write.

    +

    The risk label and the side_effect flag MUST be consistent: an action +labeled write or a more severe label MUST declare side_effect: true, and +an action labeled read MUST declare side_effect: false.

    Applications MAY define extension risk labels, but they SHOULD map them to the standard labels for runtime interoperability.

    Approval Semantics

    @@ -1018,6 +1042,17 @@

    Approval Seman | user_or_app | Either a runtime approval or app-side approval MAY satisfy the requirement, depending on grant caveats. | | runtime_and_app | Both runtime-side and app-side approval are required. |

    Approval records SHOULD be linked into receipts.

    +

    The runtime and user_or_app modes allow a runtime-side approval to satisfy +the requirement. In those modes the application is accepting the runtime's +assertion that a local user approval occurred. To keep this compatible with +the rule that an application MUST NOT accept a runtime's self-assertion of +authority, that acceptance MUST be an explicit grant caveat presented to the +user at consent time, not a silent default. Action requests that rely on a +runtime-side approval SHOULD carry an approval reference (for example an +approval_ref identifier, or in future profiles a signed approval object) so +the approval can be linked into receipts and audited. Applications that do not +want to accept runtime approval assertions MUST declare app or +runtime_and_app for the affected actions.

    Idempotency

    Every side-effecting action MUST support idempotency.

    Action requests SHOULD include:

    @@ -1030,6 +1065,13 @@

    Idempotency

    same normalized input do not repeat the side effect.

    If the same key is reused with different normalized input, the application SHOULD return an idempotency conflict error.

    +

    Idempotency keys are scoped to the grant and action: the application MUST +treat a request as a duplicate only when the same key is presented under the +same grant_id and action_id. On a duplicate request, the application +SHOULD return the original result and receipt reference rather than an error, +so a retrying runtime can converge on the outcome of the first attempt. +Applications SHOULD retain idempotency state at least for the remaining +lifetime of the grant and SHOULD document their retention window.

    Applications SHOULD define the input normalization procedure per action, or use the declared input schema with a canonical JSON profile. A future draft is expected to define canonicalization requirements for signed receipts and signed @@ -1052,7 +1094,7 @@

    Grant Object

    "resource_server": { "app_id": "code.example.com", "issuer": "https://code.example.com", - "surface_version": "code-review-agent-surface/0.1" + "surface_version": "2026-06-25" }, "scopes": [ "pull_request.read", @@ -1072,6 +1114,16 @@

    Grant Object

    } } +

    Numeric caveats need defined accounting. In this draft, max_actions counts +side-effecting action requests accepted by the application under the grant. +Reads and denied requests do not consume the budget, and neither do idempotent +replays: a retry deduplicated under a previously accepted idempotency key +MUST NOT consume the budget again, or lost responses and transport retries +could exhaust a grant without producing new side effects. max_cost_usd is +advisory in the MVP profile: the runtime SHOULD meter agent-side cost against +it, and applications MAY additionally meter app-side cost where actions carry +a price. When a budget caveat is exhausted, further matching requests MUST be +rejected with limit_exceeded.

    Grant Lifecycle

    discover surface
       -> verify manifest
    @@ -1097,6 +1149,11 @@ 

    M

    This is the RECOMMENDED MVP model because it fits existing OAuth/resource-server deployments.

    +

    Because this draft does not require browser-to-localhost communication, the +consent flow SHOULD support a completion mode that does not depend on a +loopback redirect — for example an OAuth device-authorization-style exchange +or an app-mediated pairing code that the runtime polls or receives over its +outbound channel.

    Pros:

    References