feat: strengthen artifact and release verification

Make artifact briefs, capability checks, screenshots, and required assets fail closed. Preserve the last good render, move heavy tests onto the covered CI path, and require exact tag, SHA, and package identity before release upload.
This commit is contained in:
Tw93
2026-08-01 23:20:59 +08:00
parent a211e7bbf9
commit e4a297390e
34 changed files with 2571 additions and 190 deletions
+3 -3
View File
@@ -27,9 +27,6 @@ jobs:
- name: Check generated Codex plugin metadata
run: python3 scripts/build_metadata.py --check
- name: Run test suite
run: python3 scripts/tests/test_build.py
- name: Build and audit skill package
run: bash scripts/package-skill.sh /tmp/kami-ci.zip
@@ -63,6 +60,9 @@ jobs:
# the fonts.
run: python3 -m pip install weasyprint pypdf pymupdf Pygments
- name: Run full test suite with render dependencies
run: python3 scripts/tests/test_build.py
- name: Verify strict page-count targets
# Only the six hard-invariant templates (resume == 2, one-pager == 1
# across CN/EN/KO) run in CI. The KO pair also exercises the Korean
+56 -9
View File
@@ -9,43 +9,90 @@ on:
description: 'Tag to attach kami.zip to (e.g. V1.4.1)'
required: true
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: true
jobs:
attach-archive:
name: build kami.zip and attach to release
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
ref: refs/tags/${{ github.event.inputs.tag || github.ref_name }}
- name: Resolve tag name
id: tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="${{ github.event.inputs.tag }}"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
TAG="$INPUT_TAG"
else
TAG="${GITHUB_REF#refs/tags/}"
TAG="$REF_NAME"
fi
if [[ ! "$TAG" =~ ^V[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: release tag must match Vx.y.z"
exit 1
fi
echo "name=$TAG" >> "$GITHUB_OUTPUT"
echo "Resolved tag: $TAG"
- name: Build kami.zip
- name: Verify release identity
id: identity
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.name }}
run: |
bash scripts/package-skill.sh dist/kami.zip
ls -lah dist/kami.zip
python3 scripts/release_gate.py --tag "$TAG"
SHA="$(git rev-parse HEAD)"
runs="$(gh run list --workflow=check.yml --commit "$SHA" --limit 20 \
--json headSha,status,conclusion)"
python3 -c 'import json,sys; sha=sys.argv[1]; runs=json.load(sys.stdin); sys.exit(0 if any(r["headSha"] == sha and r["status"] == "completed" and r["conclusion"] == "success" for r in runs) else 1)' \
"$SHA" <<<"$runs" || {
echo "ERROR: no successful completed check.yml run for $SHA"
exit 1
}
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "OK: exact release SHA passed check.yml"
- name: Build and compare kami.zip
env:
TAG: ${{ steps.tag.outputs.name }}
run: |
bash scripts/package-skill.sh /tmp/kami.zip
python3 scripts/release_gate.py \
--tag "$TAG" \
--tracked-archive dist/kami.zip \
--candidate-archive /tmp/kami.zip
ls -lah /tmp/kami.zip
- name: Ensure release exists, then attach archive
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.name }}
EXPECTED_SHA: ${{ steps.identity.outputs.sha }}
run: |
git fetch --no-tags --force origin \
"refs/tags/$TAG:refs/tags/kami-release-remote"
REMOTE_SHA="$(git rev-parse --verify \
"refs/tags/kami-release-remote^{commit}")"
if [[ "$REMOTE_SHA" != "$EXPECTED_SHA" ]]; then
echo "ERROR: remote tag moved from $EXPECTED_SHA to $REMOTE_SHA"
exit 1
fi
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG does not exist, creating placeholder"
gh release create "$TAG" --title "$TAG" --notes "Release notes pending. Edit on GitHub."
gh release create "$TAG" --verify-tag --title "$TAG" \
--notes "Release notes pending. Edit on GitHub."
fi
gh release upload "$TAG" dist/kami.zip --clobber
gh release upload "$TAG" /tmp/kami.zip --clobber
echo "OK: kami.zip attached to release $TAG"
- name: Add release reactions
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "skill-md",
"description": "Use when a user asks for a finished document whose appearance matters: a resume, one-pager, letter, portfolio, long report, slide deck, equity report, changelog, or a landing page. Kami fills a constrained parchment template and exports HTML to PDF, PNG, or editable PPTX, then verifies the result with deterministic and perceptual checks. Skip it when the user only wants the text.",
"url": "/SKILL.md",
"digest": "sha256:27fce2429686c55d05a226613142d3cfcf3e494df582d2b6f329e45e7a6db600",
"digest": "sha256:33e1bd61e07a991a4b6a423b6c6639d0063a4f658b21708c0b2f947ddf029105",
"version": "1.11.0",
"license": "MIT",
"homepage": "https://kami.tw93.fun",
+6 -2
View File
@@ -29,17 +29,21 @@
"name": "kami_templates",
"description": "List Kami document templates, browser-only templates, the diagram library, and content schema types, with the reference docs to read before filling."
},
{
"name": "kami_doctor",
"description": "Report whether this installed Kami runtime can render PDFs, run visual checks, build editable PPTX fallback decks, and resolve the expected font families. Read-only; missing capabilities are reported explicitly rather than treated as clean checks."
},
{
"name": "kami_render",
"description": "Render trusted local Kami HTML to PDF via WeasyPrint, with build-time code highlighting. Referenced file, HTTP, and HTTPS resources load with this process's permissions. Returns the PDF path and page count."
},
{
"name": "kami_check",
"description": "Run Kami's deterministic checks for a file. HTML: placeholders + markdown residue (+ content coverage when a content IR JSON is given). PDF: markdown residue + orphans + density. JSON: content IR schema validation. Returns the full report text."
"description": "Run Kami's deterministic checks for a file. HTML: placeholders + markdown residue (+ content coverage when a content IR JSON is given). PDF: markdown residue + orphans + density. JSON: content IR schema validation. Returns the legacy report plus stable rule IDs, findings, coverage status, and explicit degraded checks."
},
{
"name": "kami_screenshot",
"description": "Rasterize every PDF page to PNG for a perceptual review pass. Writes or replaces <pdf-stem>-visual/page-*.png, then returns the image paths and fixed review checklist; view every image against the checklist before shipping."
"description": "Rasterize every PDF page to PNG for a perceptual review pass. Writes or replaces <pdf-stem>-visual/page-*.png, then returns the image paths, deterministic CJK font verdict, and fixed review checklist; view every image against the checklist before shipping."
}
]
}
+8 -9
View File
@@ -38,8 +38,9 @@ Only the entries whose role is not obvious from the filename:
- `scripts/mermaid_normalize.py` - re-themes a beautiful-mermaid SVG to the Kami
palette and makes it WeasyPrint-safe. Pure Python, no Node, ships in the package.
- `scripts/mcp_server.py` - zero-dependency MCP stdio server exposing
`kami_templates` / `kami_render` / `kami_check` / `kami_screenshot`, so an
MCP-capable agent can drive render plus verify without reading `SKILL.md`. Register
`kami_templates` / `kami_doctor` / `kami_render` / `kami_check` /
`kami_screenshot`, so an MCP-capable agent can diagnose, render, and verify
without reading `SKILL.md`. Register
with `claude mcp add kami -- python3 <checkout>/scripts/mcp_server.py`.
- `scripts/site_facts.py` - public-site fact drift checks (install commands, version,
template and diagram counts across `index*.html`, `README.md`, `llms.txt`), wired
@@ -214,13 +215,11 @@ proof, not metadata proof. Claude Code: an isolated `HOME=/tmp/...` smoke with
Applies when editing `.github/workflows/*.yml` or adding a test with a heavy
dependency.
- `check.yml` has two jobs. `verify-render` installs `weasyprint` / `pypdf` /
`Pygments`; `lint-and-test` ships only `Pygments`, so a `find_spec(...) is not None`
skip-guard there silently skips the test while still printing `OK:`. A green
`lint-and-test` is not coverage: any render-dependent test must run in
`verify-render`. `PyMuPDF` is installed in neither job, so the checks that call
`require_pymupdf()` (orphans, density, resume balance) have no CI coverage at all;
they only run locally.
- `check.yml` has two jobs. `lint-and-test` runs dependency-light lint, metadata,
and package gates. `verify-render` installs `weasyprint` / `pypdf` / `PyMuPDF` /
`Pygments`, then runs the full test suite before template verification. Tests that
need an optional render dependency use the suite's explicit `SKIP:` counter and
fail when a CI-required dependency is unavailable; never turn a skip into `OK:`.
- Validate workflow edits on a feature branch (push, watch the run go green) before
merging to `main`. Local font and dependency assumptions diverge from CI more often
than expected; this project has already burned commits on `pip` cache requiring a
+3 -3
View File
@@ -135,8 +135,8 @@ Warm parchment canvas `#f5f4ed`, ink blue `#1B365D` as the sole accent, serif ca
- **Diagrams.** Eighteen inline SVG types, including a report-scale architecture board. Sequence, class, and ER can be authored from Mermaid text: [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid) renders the SVG and `scripts/mermaid_normalize.py` re-themes it to the Kami palette and makes it WeasyPrint-safe, no Node bundled.
- **Slides.** Three rendering paths: WeasyPrint HTML to PDF by default, python-pptx for editable PPTX on request, and a Marp variant in `assets/templates/marp/` for Markdown-first decks.
- **Code.** Pygments-based syntax highlighting when `Pygments` is installed; without it, PDFs still render and code stays monochrome.
- **Verification.** Deterministic quality gates: per-type content schemas validate structure before layout, a coverage check confirms every fact survives into the filled page, and a visual pass exports page images against a fixed review checklist.
- **MCP.** A zero-dependency MCP server (`scripts/mcp_server.py`) exposes render, check, and screenshot tools, so any MCP-capable agent can drive Kami as an engine without loading the full skill prompt. Render only trusted local HTML: referenced file, HTTP, and HTTPS resources load with the MCP process's permissions.
- **Verification.** Deterministic quality gates: per-type content schemas validate structure before layout, an optional structured brief records the artifact's target and acceptance boundary, a coverage check confirms every fact survives into the filled page, and a visual pass exports page images against a fixed review checklist.
- **MCP.** A zero-dependency MCP server (`scripts/mcp_server.py`) exposes capability diagnosis, render, structured check, and screenshot tools, so any MCP-capable agent can drive Kami as an engine without loading the full skill prompt. Render only trusted local HTML: referenced file, HTTP, and HTTPS resources load with the MCP process's permissions.
- **Print.** Parchment is the default canvas; an opt-in white-paper variant flips any document to a white background for home or office printers, sinking the warmth into cards and tables so the hierarchy still reads. The [one-page Kami intro](assets/demos/demo-kami-print.pdf) (Chinese) is rendered with this variant; recipe in [production.md](references/production.md).
Kami picks the right variant based on the language you write in.
@@ -174,7 +174,7 @@ One constraint set, applied past the page: it lays out deployable websites and b
</tr>
</table>
Landing pages ship as deployable multilingual sites. Illustrations are drawn by the host's own image model: where the host can generate images, like ChatGPT, it renders directly; where it cannot, like Claude or Codex, it outputs the brief for you to paste into any image model.
Landing pages ship as deployable multilingual sites. Illustrations use the host's own image generation when that capability is available; otherwise Kami outputs the same complete brief for use in an image model.
```text
Redraw this as a clean editorial diagram. Background: warm parchment (#f5f4ed), never pure white. One accent only, ink blue (#1B365D); everything else in warm gray with a yellow-brown undertone, no other colors. Thin single-line geometric strokes and simple flat icons. No gradients, no drop shadows, no 3D. Labels in a serif typeface. Generous whitespace, calm and composed, like a figure in a well-typeset report.
+52 -11
View File
@@ -61,6 +61,17 @@ Use the nearest existing template and verification path. Do not add a new templa
If a change touches `SKILL.md`, templates, scripts, references, or package inputs, decide whether `dist/kami.zip` must be refreshed before handoff. Shipped behavior is not ready until the package contains the changed files.
### Work mode
Route by the artifact's current state before loading more guidance. This is an internal branch, not a new user-facing command.
| Current task | Mode | Contract |
|---|---|---|
| New document or substantial restructuring | **New document** | Lock the execution contract, write `content.json` with `brief` + `content`, then fill and verify |
| Text replacement, translation, or factual correction in an existing artifact | **Content-only** | Preserve CSS and layout unless the new copy proves a fit defect |
| User supplies a render or screenshot and rejects how it looks | **Visual repair** | Treat the render as the current brief, lock target + preserve boundary, make the smallest fix, then verify the affected matrix |
| Standalone generated illustration, cover, social card, or redraw | **Generated asset** | Lock the semantic image brief before pixels; preserve accepted parts across iterations |
---
## Step 2 · Pick the document type
@@ -135,10 +146,10 @@ Three routes inside that file, by trigger:
Inline diagrams above are vector SVGs you assemble by hand. For a standalone raster illustration, or a redraw of a figure, photo, or screenshot in the Kami look, delegate the drawing to the host's own image generation. Never call an external image API or require a key; rendering is the host's job.
- If the running host can generate images (for example ChatGPT), apply the brief below and render the image directly.
- If it cannot (Claude, Codex, most coding agents), output the brief as text so the user can paste it into any image model.
- If the running host exposes image generation, apply the brief below and render the image directly.
- If image generation is unavailable, output the same complete brief as text. Route from observed capability, not a remembered list of host names.
Brief: warm parchment (`#f5f4ed`) background, never pure white; one accent only, ink blue (`#1B365D`); all else warm gray with a yellow-brown undertone, no other colors; thin single-line geometric strokes and simple flat icons; no gradients, drop shadows, or 3D; serif labels; generous whitespace, composed like a figure in a well-typeset report. Full brief skeleton, icon rules, and QC checklist: `references/diagrams.md` «Illustration briefs».
Brief: first state the claim the image must communicate, its destination and smallest display size, the accepted reference it should sit beside, and what must not appear. Then apply the Kami visual system: warm parchment (`#f5f4ed`) background, never pure white; one accent only, ink blue (`#1B365D`); all else warm gray with a yellow-brown undertone, no other colors; thin single-line geometric strokes and simple flat icons; no gradients, drop shadows, or 3D; serif labels; generous whitespace, composed like a figure in a well-typeset report. Full brief skeleton, icon rules, and QC checklist: `references/diagrams.md` «Illustration briefs».
Switch to this path (instead of enlarging a hand-assembled SVG) when a figure needs more detail than SVG assembly holds at the target display width, typically web-article figures at teaching depth (see `references/diagrams.md` density tiers). When one deliverable needs several generated images, drive them through a single handoff file: one line per image (slot, aspect ratio, shared style anchor, prompt, status), generate in batches of at most 5, update the status column after each batch, and check existing generated output before regenerating. The style anchor is shared by the whole batch; per-image style drift is the failure mode.
@@ -170,7 +181,7 @@ After the material check, output a structured status block before continuing. Th
Materials status:
- Logo: OK assets/client-logo.svg
- Brand colors: OK #1B365D mapped to --brand
- Product screenshot: MISSING (proceeding with kami default placeholder)
- Product screenshot: MISSING (using a pure-text layout; no placeholder image)
- UI screenshot: not required for this doc type
```
@@ -194,13 +205,34 @@ Then proceed to Step 2.6 (slides) or the layout note (all other doc types) with
### Persist the distilled content as a content IR (new documents)
When building a new document (not a text tweak on an existing one), write the distilled result to a `content.json` next to your working HTML before filling:
When building a new document (not a text tweak on an existing one), write the distilled result and the locked execution contract to a `content.json` next to your working HTML before filling:
```json
{"type": "resume", "lang": "cn", "content": { ... }}
{
"type": "resume",
"lang": "cn",
"brief": {
"audience": "Hiring manager for a senior product role",
"job": "Earn an interview by proving scope and outcomes",
"template": "resume",
"formats": ["html", "pdf"],
"page_target": 2,
"narrative": "Scope first, then evidence, then fit",
"required_facts": ["team size", "measured outcomes"],
"required_assets": [],
"acceptance_checks": ["two pages", "all atomic facts survive"],
"preserve": [],
"explicit_deviations": []
},
"content": { ... }
}
```
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). Read the matching schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). `brief` records why this artifact exists and how it will be judged; it is not audience copy and does not participate in content-to-HTML coverage. For a visual repair, add `target`, `evidence`, and `preserve` so the fix cannot silently grow beyond the reported surface. Older IR files without `brief` remain valid, but every new document should write it. Read the matching content schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
The top-level envelope is strict: it contains only `type`, `lang`, `brief`, and `content`.
Use a language tag such as `cn`, `en`, `ko`, or `zh-TW`; misspelled tags and extra
top-level fields fail before template filling begins.
```bash
python3 scripts/build.py --check-content content.json
@@ -367,6 +399,7 @@ python3 scripts/build.py --check-resume-balance path/to/resume.pdf
python3 scripts/build.py --check-density path/to/filled.pdf # page whitespace (one-page docs included)
python3 scripts/build.py --check-density # repo sweep (skips cover and template skeletons)
python3 scripts/build.py --check-rhythm slides slides-en # warn on monotonous slide sequences
python3 scripts/build.py --doctor # installed render/check/font capability report
python3 scripts/build.py --check # lint + token/theme + public-site fact checks
python3 scripts/build_metadata.py --check # Claude/Codex plugin mirror + marketplace drift check
```
@@ -377,6 +410,8 @@ python3 scripts/build_metadata.py --check # Claude/Codex plugin mirror + marke
> **Font verify (CJK deliverables)**: a missing CJK serif produces no fallback boxes. It silently substitutes a sans that still reads, so the page passes an eyeball pass while carrying typography the parchment metrics were never tuned for, and the result reads heavy and flat without anything looking obviously broken. `--check-fonts` settles it from the rendered PDF's own span table: it names the family that drew the body ideographs and fails on a sans substitution or on text split across two families. Never report a CJK document as visually verified without it, and never assume a sandbox has the fonts: the commercial TsangerJinKai02 files never ship inside the skill package.
> **Fresh review**: after the mechanical and perceptual checks pass, review once from the artifact contract rather than from the builder's rationale. Read `brief`, the content-coverage result, and the rendered evidence; check every acceptance item and every `preserve` boundary; report P0/P1 findings with the page, viewport, or element that proves them. Use an isolated reviewer when the host supports one. Otherwise reload those three evidence surfaces and do a distinct second pass. Fix P0/P1 findings before handoff; do not let the same pass that made the artifact approve its own intentions.
Source templates intentionally keep `{{...}}` fields. Run placeholder checks on completed documents, not on the template library.
For Markdown-sourced long documents, also run `--check-markdown` on the rendered PDF. It catches visible raw `---`, `**bold**`, and inline-code backticks that should have been converted or removed before delivery.
@@ -400,11 +435,18 @@ Two facts worth carrying here: the commercial TsangerJinKai02 files stay in the
## Feedback protocol
When the user gives **vague visual feedback** ("looks off", "太挤了", "not elegant"), do not guess. Ask back naming the element and its current value, offering 2 in-spec alternatives.
When the user gives visual feedback ("looks off", "太挤了", "not elegant"), inspect the current render before asking them to choose a value. The render is the evidence; the user's negative label is the acceptance signal.
Template response: "X is currently set to Y. Would you like (a) [specific alternative within spec] or (b) [another option]?"
1. Name the concrete defect in one sentence: page or surface, viewport or state, and whether the problem is density, hierarchy, alignment, type, color, cropping, or text fit.
2. Lock the repair boundary: `target` is allowed to change; `preserve` names the adjacent pages, sections, content, and shared tokens that must remain stable. Ask only when two plausible targets would produce materially different artifacts.
3. Make the smallest content, geometry, spacing, typography, crop, or token change that fixes the defect. Never hide a content problem by shrinking type first.
4. Verify the affected matrix rather than one screenshot:
- PDF: target page, neighboring pages, total page count, font result, and every locale or template variant reached by a shared token.
- Screen: 1280px and 375px, plus 320px when CTA or nav width is involved; every shipped locale; affected default, focus/selected, loading, empty/error, and transition state only when the surface actually has them.
- PPTX: editable source plus a rendered PDF or opened-deck inspection.
- Generated asset: target slot at its smallest display size plus sibling assets in the same deliverable.
Never say "I'll adjust the spacing" without naming the exact property and its new value.
If no rendered evidence exists and the feedback still leaves two materially different fixes, ask once by naming the current property and offering two in-spec alternatives. Never say "I'll adjust the spacing" without naming the exact property and its new value.
**Escalate after two rounds.** If the same element is still not approved after two adjustment rounds, stop nudging values: produce one comparison artifact instead: the current state plus 2-3 labeled variants (A/B/C) of the same content in the same frame, and let the user pick. For choices with no objective criterion (typeface, accent color, logo), skip the nudging entirely and start with a specimen sheet: up to 5 candidates, each a labeled half-page block of identical title-plus-paragraph content. One round of "pick one" converges where five rounds of "try again" do not; after the pick, apply it everywhere and rebuild affected demos in the same round.
@@ -417,4 +459,3 @@ Never say "I'll adjust the spacing" without naming the exact property and its ne
- Need saturated multi-color (this has one accent)
- Need cartoon / animation / illustration style (this is editorial)
- Web dynamic app UI (this is for print / static documents)
+8 -7
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="How agents and developers integrate Kami: the local MCP server, content schemas, deterministic checks, and every machine-readable file this site publishes.">
<link rel="icon" href="assets/images/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="./styles.css">
<link rel="stylesheet" href="./styles.css?v=1.11.0">
<link rel="canonical" href="https://kami.tw93.fun/developers">
<link rel="alternate" type="text/markdown" href="https://kami.tw93.fun/developers.md">
<meta property="og:type" content="website">
@@ -75,7 +75,7 @@
<h2 class="section-title">Get the skill</h2>
<p class="section-lede">The skill auto-triggers on document requests. No slash command is needed once it is installed.</p>
</div>
<pre class="code"><span class="c"># Claude Code (v2.1.142+)</span>
<pre class="code code-wrap"><span class="c"># Claude Code (v2.1.142+)</span>
/plugin marketplace add tw93/kami
/plugin install kami@kami
@@ -99,9 +99,10 @@ claude mcp add kami -- python3 &lt;checkout&gt;/scripts/mcp_server.py</pre>
</div>
<ul>
<li><code>kami_templates</code> - list document templates, browser-only templates, the diagram library, and content schema types, with the reference docs to read before filling.</li>
<li><code>kami_doctor</code> - report installed PDF render, visual-check, editable-PPTX, and font capabilities without treating an unavailable engine as a clean result.</li>
<li><code>kami_render</code> - render trusted local Kami HTML to PDF through WeasyPrint, with build-time code highlighting. Returns the PDF path and page count.</li>
<li><code>kami_check</code> - run the deterministic checks for a file. HTML: placeholders and markdown residue, plus content coverage when a content IR JSON is supplied. PDF: markdown residue, orphans, density. JSON: content IR schema validation.</li>
<li><code>kami_screenshot</code> - rasterize every PDF page to PNG and return the paths plus a fixed review checklist for the perceptual pass.</li>
<li><code>kami_check</code> - run the deterministic checks for a file. Returns the readable report plus stable rule IDs, findings, engine coverage, and explicit degraded checks.</li>
<li><code>kami_screenshot</code> - rasterize every PDF page to PNG and return the paths, deterministic CJK font verdict, and a <code>review_pending</code> checklist contract for the perceptual pass.</li>
</ul>
<p>The server speaks newline-delimited JSON-RPC 2.0 on stdin and stdout and has no third-party dependency for the protocol itself. Tools that need WeasyPrint, pypdf, or PyMuPDF surface the install hint as a tool error instead of crashing. Its card, including the protocol version and the tool list, is published at <a href="./.well-known/mcp/server-card.json">/.well-known/mcp/server-card.json</a>.</p>
</section>
@@ -112,8 +113,8 @@ claude mcp add kami -- python3 &lt;checkout&gt;/scripts/mcp_server.py</pre>
<h2 class="section-title">Content schemas</h2>
<p class="section-lede">Each document type has a JSON schema that states its structure and its quality bar. Fill the schema first, lay out second.</p>
</div>
<p>Nine schemas live under <code>references/schemas/</code>: changelog, equity-report, landing-page, letter, long-doc, one-pager, portfolio, resume, slides. Validate the content object before layout, then check how much of it actually reached the page:</p>
<pre class="code">python3 scripts/build.py --check-content content.json
<p>Nine schemas live under <code>references/schemas/</code>: changelog, equity-report, landing-page, letter, long-doc, one-pager, portfolio, resume, slides. A new <code>content.json</code> also carries a <code>brief</code> with audience, job, output contract, target, preserve boundary, evidence, and acceptance checks; older IR files remain valid. Validate the content object before layout, then check how much of it actually reached the page:</p>
<pre class="code code-wrap">python3 scripts/build.py --check-content content.json
python3 scripts/build.py --check-content content.json filled.html</pre>
<p>The second form reports coverage: fields that exist in the content object but never made it into the document are the most common failure in agent-generated layouts, and they are invisible to a human skim.</p>
</section>
@@ -167,7 +168,7 @@ python3 scripts/build.py --check-content content.json filled.html</pre>
</div>
</div>
<div class="colophon">
<div><a href="./" style="color:var(--brand); text-decoration:none; font-weight:500;">Home</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="./about" style="color:var(--brand); text-decoration:none; font-weight:500;">About</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="./contact" style="color:var(--brand); text-decoration:none; font-weight:500;">Contact</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="./privacy" style="color:var(--brand); text-decoration:none; font-weight:500;">Privacy</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="https://github.com/tw93/kami" style="color:var(--brand); text-decoration:none; font-weight:500;">GitHub</a></div>
<div><a href="./" style="color:var(--brand); text-decoration:none; font-weight:500;">Home</a> · <a href="./about" style="color:var(--brand); text-decoration:none; font-weight:500;">About</a> · <a href="./contact" style="color:var(--brand); text-decoration:none; font-weight:500;">Contact</a> · <a href="./privacy" style="color:var(--brand); text-decoration:none; font-weight:500;">Privacy</a> · <a href="https://github.com/tw93/kami" style="color:var(--brand); text-decoration:none; font-weight:500;">GitHub</a></div>
<div style="margin-top:8px; color: var(--olive); font-family: var(--serif);">
MIT licensed. Runs where you install it.
</div>
+4 -3
View File
@@ -37,15 +37,16 @@ The server speaks newline-delimited JSON-RPC 2.0 over stdio and has no third-par
| Tool | What it does |
| --- | --- |
| `kami_templates` | List document templates, browser-only templates, the diagram library, and content schema types, with the reference docs to read before filling. |
| `kami_doctor` | Report installed PDF render, visual-check, editable-PPTX, and font capabilities without treating an unavailable engine as a clean result. |
| `kami_render` | Render trusted local Kami HTML to PDF through WeasyPrint, with build-time code highlighting. Returns the PDF path and page count. |
| `kami_check` | Run the deterministic checks for a file. HTML: placeholders and markdown residue, plus content coverage when a content IR JSON is supplied. PDF: markdown residue, orphans, density. JSON: content IR schema validation. |
| `kami_screenshot` | Rasterize every PDF page to PNG and return the paths plus a fixed review checklist for the perceptual pass. |
| `kami_check` | Run the deterministic checks for a file. Returns the readable report plus stable rule IDs, findings, engine coverage, and explicit degraded checks. |
| `kami_screenshot` | Rasterize every PDF page to PNG and return the paths, deterministic CJK font verdict, and a `review_pending` checklist contract for the perceptual pass. |
The server card, including protocol version and tool list, is published at </.well-known/mcp/server-card.json>.
## Content schemas
Nine schemas live under `references/schemas/`: changelog, equity-report, landing-page, letter, long-doc, one-pager, portfolio, resume, slides. Each states the structure and the quality bar for its type. Fill the schema first, lay out second.
Nine schemas live under `references/schemas/`: changelog, equity-report, landing-page, letter, long-doc, one-pager, portfolio, resume, slides. Each states the structure and the quality bar for its type. A new `content.json` also carries a `brief` with audience, job, output contract, target, preserve boundary, evidence, and acceptance checks; older IR files remain valid. Fill the schema first, lay out second.
```
python3 scripts/build.py --check-content content.json
+2 -1
View File
@@ -12,10 +12,11 @@ Reach for Kami when a user wants a finished document whose appearance matters: r
- Start: `python3 scripts/mcp_server.py` (stdio, newline-delimited JSON-RPC 2.0)
- Register: `claude mcp add kami -- python3 <checkout>/scripts/mcp_server.py`
- Card: https://kami.tw93.fun/.well-known/mcp/server-card.json
- Tools: `kami_templates` (templates, diagrams, schema types), `kami_render` (HTML to PDF), `kami_check` (deterministic checks), `kami_screenshot` (page PNGs + review checklist)
- Tools: `kami_templates` (templates, diagrams, schema types), `kami_doctor` (installed capabilities), `kami_render` (HTML to PDF), `kami_check` (stable findings + coverage), `kami_screenshot` (page PNGs + CJK font verdict + `review_pending` checklist)
## Content contracts
- Schemas: `references/schemas/<type>.json` for changelog, equity-report, landing-page, letter, long-doc, one-pager, portfolio, resume, slides
- Artifact brief: new `content.json` files carry audience, job, output contract, target, preserve boundary, evidence, and acceptance checks under `brief`; older files without it remain valid
- Validate: `python3 scripts/build.py --check-content content.json`
- Coverage into the filled document: `python3 scripts/build.py --check-content content.json filled.html`
BIN
View File
Binary file not shown.
+6
View File
@@ -51,6 +51,12 @@ screenshots. Everyday template, script, and site work does not need it.
green means "a clean checkout is fine", and only the second one is what a user
downloads. Poll the structured status; piping `gh run watch` into `tail` swallows
the exit code and reports an unfinished or failed run as passing.
- The release workflow enforces the same contract before it can create or overwrite
an asset: `TAG == V$(cat VERSION)`, the tag resolves to the checked-out commit, an
exact-SHA `check.yml` run is complete and successful, and the rebuilt archive has
the same entry names and per-entry SHA-256 payloads as tracked `dist/kami.zip`.
Immediately before upload, it also confirms the remote tag still resolves to the
reviewed SHA. Keep these as hard gates; a manual dispatch is not an override.
- Create a version tag only when the maintainer explicitly asks for a versioned
release, and tag the commit that already contains the final refreshed
`dist/kami.zip`. Never tag a source-only commit and refresh the archive afterward.
+2 -2
View File
@@ -43,9 +43,9 @@ Nine content schemas under `references/schemas/`: changelog, equity-report, land
## Agent interfaces
- **Content contracts**: `references/schemas/<type>.json` carries the per-type structure. Validate before layout and check coverage after filling: `python3 scripts/build.py --check-content content.json filled.html`.
- **Content contracts**: `references/schemas/<type>.json` carries the per-type structure, while `content.json.brief` records the artifact target, preserve boundary, evidence, and acceptance checks. Validate before layout and check coverage after filling: `python3 scripts/build.py --check-content content.json filled.html`.
- **Deterministic checks**: placeholders, markdown residue, page density, orphan lines, and slide rhythm, plus a perceptual pass that exports page images with a fixed review checklist (`--check-visual`).
- **MCP server**: `python3 scripts/mcp_server.py` speaks MCP over stdio with `kami_templates`, `kami_render`, `kami_check`, and `kami_screenshot`, so an agent can render and verify without loading the skill prompt.
- **MCP server**: `python3 scripts/mcp_server.py` speaks MCP over stdio with `kami_templates`, `kami_doctor`, `kami_render`, `kami_check`, and `kami_screenshot`, so an agent can diagnose, render, and verify without loading the skill prompt.
- **Machine-readable site surfaces**: [/llms.txt](https://kami.tw93.fun/llms.txt), [/developers.md](https://kami.tw93.fun/developers.md), [/.well-known/mcp/server-card.json](https://kami.tw93.fun/.well-known/mcp/server-card.json), [/.well-known/agent-skills/index.json](https://kami.tw93.fun/.well-known/agent-skills/index.json), and the catalog feeds in [/schemamap.xml](https://kami.tw93.fun/schemamap.xml).
## Pages
+2 -2
View File
@@ -52,9 +52,9 @@ How an agent should call Kami: install the skill, fill the matching content sche
- 18 diagram types: architecture, architecture board, flowchart, quadrant, bar chart, line chart, donut chart, state machine, timeline, swimlane, tree, layer stack, venn, candlestick, waterfall, sequence, class, ER
## Agent Interfaces
- Content contracts: `references/schemas/<type>.json` carries the per-type structure and quality bar; validate before layout and check coverage after filling with `python3 scripts/build.py --check-content content.json [filled.html]`
- Content contracts: `references/schemas/<type>.json` carries the per-type structure and quality bar; new `content.json` files also carry an artifact `brief` with target, preserve boundary, evidence, and acceptance checks; validate before layout and check coverage after filling with `python3 scripts/build.py --check-content content.json [filled.html]`
- Deterministic checks: placeholders, markdown residue, page density, orphan lines, slide rhythm, and a perceptual pass that exports page images with a fixed review checklist (`--check-visual`)
- MCP server: `python3 scripts/mcp_server.py` speaks MCP over stdio with tools kami_templates / kami_render / kami_check / kami_screenshot, so agents can render and verify without loading the skill prompt
- MCP server: `python3 scripts/mcp_server.py` speaks MCP over stdio with tools kami_templates / kami_doctor / kami_render / kami_check / kami_screenshot, so agents can diagnose, render, and verify without loading the skill prompt
## Machine-Readable Files
- MCP server card: https://kami.tw93.fun/.well-known/mcp/server-card.json
+52 -11
View File
@@ -61,6 +61,17 @@ Use the nearest existing template and verification path. Do not add a new templa
If a change touches `SKILL.md`, templates, scripts, references, or package inputs, decide whether `dist/kami.zip` must be refreshed before handoff. Shipped behavior is not ready until the package contains the changed files.
### Work mode
Route by the artifact's current state before loading more guidance. This is an internal branch, not a new user-facing command.
| Current task | Mode | Contract |
|---|---|---|
| New document or substantial restructuring | **New document** | Lock the execution contract, write `content.json` with `brief` + `content`, then fill and verify |
| Text replacement, translation, or factual correction in an existing artifact | **Content-only** | Preserve CSS and layout unless the new copy proves a fit defect |
| User supplies a render or screenshot and rejects how it looks | **Visual repair** | Treat the render as the current brief, lock target + preserve boundary, make the smallest fix, then verify the affected matrix |
| Standalone generated illustration, cover, social card, or redraw | **Generated asset** | Lock the semantic image brief before pixels; preserve accepted parts across iterations |
---
## Step 2 · Pick the document type
@@ -135,10 +146,10 @@ Three routes inside that file, by trigger:
Inline diagrams above are vector SVGs you assemble by hand. For a standalone raster illustration, or a redraw of a figure, photo, or screenshot in the Kami look, delegate the drawing to the host's own image generation. Never call an external image API or require a key; rendering is the host's job.
- If the running host can generate images (for example ChatGPT), apply the brief below and render the image directly.
- If it cannot (Claude, Codex, most coding agents), output the brief as text so the user can paste it into any image model.
- If the running host exposes image generation, apply the brief below and render the image directly.
- If image generation is unavailable, output the same complete brief as text. Route from observed capability, not a remembered list of host names.
Brief: warm parchment (`#f5f4ed`) background, never pure white; one accent only, ink blue (`#1B365D`); all else warm gray with a yellow-brown undertone, no other colors; thin single-line geometric strokes and simple flat icons; no gradients, drop shadows, or 3D; serif labels; generous whitespace, composed like a figure in a well-typeset report. Full brief skeleton, icon rules, and QC checklist: `references/diagrams.md` «Illustration briefs».
Brief: first state the claim the image must communicate, its destination and smallest display size, the accepted reference it should sit beside, and what must not appear. Then apply the Kami visual system: warm parchment (`#f5f4ed`) background, never pure white; one accent only, ink blue (`#1B365D`); all else warm gray with a yellow-brown undertone, no other colors; thin single-line geometric strokes and simple flat icons; no gradients, drop shadows, or 3D; serif labels; generous whitespace, composed like a figure in a well-typeset report. Full brief skeleton, icon rules, and QC checklist: `references/diagrams.md` «Illustration briefs».
Switch to this path (instead of enlarging a hand-assembled SVG) when a figure needs more detail than SVG assembly holds at the target display width, typically web-article figures at teaching depth (see `references/diagrams.md` density tiers). When one deliverable needs several generated images, drive them through a single handoff file: one line per image (slot, aspect ratio, shared style anchor, prompt, status), generate in batches of at most 5, update the status column after each batch, and check existing generated output before regenerating. The style anchor is shared by the whole batch; per-image style drift is the failure mode.
@@ -170,7 +181,7 @@ After the material check, output a structured status block before continuing. Th
Materials status:
- Logo: OK assets/client-logo.svg
- Brand colors: OK #1B365D mapped to --brand
- Product screenshot: MISSING (proceeding with kami default placeholder)
- Product screenshot: MISSING (using a pure-text layout; no placeholder image)
- UI screenshot: not required for this doc type
```
@@ -194,13 +205,34 @@ Then proceed to Step 2.6 (slides) or the layout note (all other doc types) with
### Persist the distilled content as a content IR (new documents)
When building a new document (not a text tweak on an existing one), write the distilled result to a `content.json` next to your working HTML before filling:
When building a new document (not a text tweak on an existing one), write the distilled result and the locked execution contract to a `content.json` next to your working HTML before filling:
```json
{"type": "resume", "lang": "cn", "content": { ... }}
{
"type": "resume",
"lang": "cn",
"brief": {
"audience": "Hiring manager for a senior product role",
"job": "Earn an interview by proving scope and outcomes",
"template": "resume",
"formats": ["html", "pdf"],
"page_target": 2,
"narrative": "Scope first, then evidence, then fit",
"required_facts": ["team size", "measured outcomes"],
"required_assets": [],
"acceptance_checks": ["two pages", "all atomic facts survive"],
"preserve": [],
"explicit_deviations": []
},
"content": { ... }
}
```
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). Read the matching schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). `brief` records why this artifact exists and how it will be judged; it is not audience copy and does not participate in content-to-HTML coverage. For a visual repair, add `target`, `evidence`, and `preserve` so the fix cannot silently grow beyond the reported surface. Older IR files without `brief` remain valid, but every new document should write it. Read the matching content schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
The top-level envelope is strict: it contains only `type`, `lang`, `brief`, and `content`.
Use a language tag such as `cn`, `en`, `ko`, or `zh-TW`; misspelled tags and extra
top-level fields fail before template filling begins.
```bash
python3 scripts/build.py --check-content content.json
@@ -367,6 +399,7 @@ python3 scripts/build.py --check-resume-balance path/to/resume.pdf
python3 scripts/build.py --check-density path/to/filled.pdf # page whitespace (one-page docs included)
python3 scripts/build.py --check-density # repo sweep (skips cover and template skeletons)
python3 scripts/build.py --check-rhythm slides slides-en # warn on monotonous slide sequences
python3 scripts/build.py --doctor # installed render/check/font capability report
python3 scripts/build.py --check # lint + token/theme + public-site fact checks
python3 scripts/build_metadata.py --check # Claude/Codex plugin mirror + marketplace drift check
```
@@ -377,6 +410,8 @@ python3 scripts/build_metadata.py --check # Claude/Codex plugin mirror + marke
> **Font verify (CJK deliverables)**: a missing CJK serif produces no fallback boxes. It silently substitutes a sans that still reads, so the page passes an eyeball pass while carrying typography the parchment metrics were never tuned for, and the result reads heavy and flat without anything looking obviously broken. `--check-fonts` settles it from the rendered PDF's own span table: it names the family that drew the body ideographs and fails on a sans substitution or on text split across two families. Never report a CJK document as visually verified without it, and never assume a sandbox has the fonts: the commercial TsangerJinKai02 files never ship inside the skill package.
> **Fresh review**: after the mechanical and perceptual checks pass, review once from the artifact contract rather than from the builder's rationale. Read `brief`, the content-coverage result, and the rendered evidence; check every acceptance item and every `preserve` boundary; report P0/P1 findings with the page, viewport, or element that proves them. Use an isolated reviewer when the host supports one. Otherwise reload those three evidence surfaces and do a distinct second pass. Fix P0/P1 findings before handoff; do not let the same pass that made the artifact approve its own intentions.
Source templates intentionally keep `{{...}}` fields. Run placeholder checks on completed documents, not on the template library.
For Markdown-sourced long documents, also run `--check-markdown` on the rendered PDF. It catches visible raw `---`, `**bold**`, and inline-code backticks that should have been converted or removed before delivery.
@@ -400,11 +435,18 @@ Two facts worth carrying here: the commercial TsangerJinKai02 files stay in the
## Feedback protocol
When the user gives **vague visual feedback** ("looks off", "太挤了", "not elegant"), do not guess. Ask back naming the element and its current value, offering 2 in-spec alternatives.
When the user gives visual feedback ("looks off", "太挤了", "not elegant"), inspect the current render before asking them to choose a value. The render is the evidence; the user's negative label is the acceptance signal.
Template response: "X is currently set to Y. Would you like (a) [specific alternative within spec] or (b) [another option]?"
1. Name the concrete defect in one sentence: page or surface, viewport or state, and whether the problem is density, hierarchy, alignment, type, color, cropping, or text fit.
2. Lock the repair boundary: `target` is allowed to change; `preserve` names the adjacent pages, sections, content, and shared tokens that must remain stable. Ask only when two plausible targets would produce materially different artifacts.
3. Make the smallest content, geometry, spacing, typography, crop, or token change that fixes the defect. Never hide a content problem by shrinking type first.
4. Verify the affected matrix rather than one screenshot:
- PDF: target page, neighboring pages, total page count, font result, and every locale or template variant reached by a shared token.
- Screen: 1280px and 375px, plus 320px when CTA or nav width is involved; every shipped locale; affected default, focus/selected, loading, empty/error, and transition state only when the surface actually has them.
- PPTX: editable source plus a rendered PDF or opened-deck inspection.
- Generated asset: target slot at its smallest display size plus sibling assets in the same deliverable.
Never say "I'll adjust the spacing" without naming the exact property and its new value.
If no rendered evidence exists and the feedback still leaves two materially different fixes, ask once by naming the current property and offering two in-spec alternatives. Never say "I'll adjust the spacing" without naming the exact property and its new value.
**Escalate after two rounds.** If the same element is still not approved after two adjustment rounds, stop nudging values: produce one comparison artifact instead: the current state plus 2-3 labeled variants (A/B/C) of the same content in the same frame, and let the user pick. For choices with no objective criterion (typeface, accent color, logo), skip the nudging entirely and start with a specimen sheet: up to 5 candidates, each a labeled half-page block of identical title-plus-paragraph content. One round of "pick one" converges where five rounds of "try again" do not; after the pick, apply it everywhere and rebuild affected demos in the same round.
@@ -417,4 +459,3 @@ Never say "I'll adjust the spacing" without naming the exact property and its ne
- Need saturated multi-color (this has one accent)
- Need cartoon / animation / illustration style (this is editorial)
- Web dynamic app UI (this is for print / static documents)
@@ -650,11 +650,15 @@ For raster illustrations delegated to the host's image generation (SKILL.md «Il
**Brief skeleton**, in order:
1. Canvas: warm parchment `#f5f4ed`, never pure white; generous whitespace; composed like a figure in a well-typeset report.
2. Accent: ink blue `#1B365D` on the 1-2 focal elements only; everything else warm gray with a yellow-brown undertone; no second hue anywhere.
3. Strokes and icons: thin single-line geometric strokes; flat icons matching section 6 (rounded line style, no fills beyond the two sanctioned ones); no gradients, drop shadows, or 3D.
4. Labels: serif, few, short. Prefer single words; image models misspell long phrases, and a misspelled label voids the image. If a label must be a phrase, plan to typeset it in HTML over the image instead.
5. Content spec: the same complexity budget as section 2 (state the tier: 4/10 editorial or 6-7/10 teaching), what is focal, and the reading direction.
1. Claim: one sentence stating what the reader should conclude. Name the assertion, not the topic: “review protects the release boundary”, not “software workflow”.
2. Placement: destination, aspect ratio, and smallest display size. README inline, social card, slide, and report figure have different type floors.
3. Reference: the accepted sibling image or named visual system this must sit beside. State what survives from that reference: composition, density, line language, or crop.
4. Exclusions: what must not appear. Version strings, release copy, invented UI, unrelated atmosphere, extra hues, and private identifiers stay out unless the task explicitly needs them.
5. Canvas: warm parchment `#f5f4ed`, never pure white; generous whitespace; composed like a figure in a well-typeset report.
6. Accent: ink blue `#1B365D` on the 1-2 focal elements only; everything else warm gray with a yellow-brown undertone; no second hue anywhere.
7. Strokes and icons: thin single-line geometric strokes; flat icons matching section 6 (rounded line style, no fills beyond the two sanctioned ones); no gradients, drop shadows, or 3D.
8. Labels: serif, few, short. Prefer single words; image models misspell long phrases, and a misspelled label voids the image. If a label must be a phrase, plan to typeset it in HTML over the image instead.
9. Content spec: the same complexity budget as section 2 (state the tier: 4/10 editorial or 6-7/10 teaching), what is focal, and the reading direction.
**QC before placing a generated image** (regenerate on any failure, do not retouch expectations):
@@ -662,8 +666,12 @@ For raster illustrations delegated to the host's image generation (SKILL.md «Il
- No gradient, shadow, or 3D crept in.
- Text in the image is spelled correctly and minimal; anything wrong or verbose gets re-briefed with fewer words.
- Composition reads as a report figure (balanced margins, clear focal point), not as a poster or clip art.
- The claim is legible at the stated smallest display size; a detail visible only in the full-resolution source does not count.
- Every exclusion holds, especially version text, invented product surfaces, unrelated decoration, and private identifiers.
- Style matches the other generated images in the same deliverable (shared style anchor, see SKILL.md batch rule).
After a partly successful generation, name what survives before changing the brief. After two look-based rejections, stop blind regeneration and use the SKILL.md comparison protocol: preserve the accepted part, show labeled alternatives in the same frame, and realign on the claim, reference, and exclusions.
---
## 12. Credit
+6 -1
View File
@@ -35,6 +35,7 @@ Usage:
python3 scripts/build.py --check-fonts path/to/doc.pdf # which family actually drew the CJK text
python3 scripts/build.py --check-style path/to/filled.html # template rules against a produced document
python3 scripts/build.py --check-docs # lint the CSS snippets the reference docs teach from
python3 scripts/build.py --doctor # installed render/check/font capabilities
"""
from __future__ import annotations
@@ -58,7 +59,7 @@ from lint import (
check_style,
scan_file,
)
from optional_deps import MissingDepError
from optional_deps import MissingDepError, run_doctor
from render import build_slides, render_pdf
from shared import (
DIAGRAMS,
@@ -206,6 +207,10 @@ def main(argv: list[str]) -> int:
return _error_unexpected(args[1])
target = args[1] if len(args) > 1 else None
return verify_all(target)
if args[0] == "--doctor":
if len(args) > 1:
return _error_unexpected(args[1])
return run_doctor()
# Path-taking check subcommands share one guard + dispatch table.
path_checks = {
"--check-orphans": check_orphans,
+240 -19
View File
@@ -2,7 +2,7 @@
The content IR is a JSON file the agent writes before filling a template:
{"type": "resume", "lang": "cn", "content": {...}}
{"type": "resume", "lang": "cn", "brief": {...}, "content": {...}}
`type` selects a contract from `references/schemas/<type>.json` (a lean JSON
Schema subset). Validation happens before layout, so structural defects
@@ -23,7 +23,15 @@ from pathlib import Path
from urllib.parse import unquote, urlsplit
from checks import css_hidden_selectors, visible_html_text
from shared import ROOT, SCHEMAS_DIR, content_schema_types, rel_to_root
from shared import (
HTML_TEMPLATES,
PPTX_TEMPLATES,
ROOT,
SCHEMAS_DIR,
SCREEN_TEMPLATES,
content_schema_types,
rel_to_root,
)
# Strings longer than this are treated as prose the agent may rephrase while
# filling; only shorter atomic values (names, metrics, dates) must survive
@@ -33,6 +41,8 @@ MAX_COVERAGE_VALUES = 5000
MAX_COVERAGE_ISSUES = 200
_CJK = re.compile(r"[\u3000-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]")
_LANG_TAG = re.compile(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*")
_ENVELOPE_FIELDS = {"type", "lang", "brief", "content"}
_TYPE_CHECKS: dict[str, type | tuple[type, ...]] = {
"object": dict,
@@ -43,6 +53,58 @@ _TYPE_CHECKS: dict[str, type | tuple[type, ...]] = {
"boolean": bool,
}
BRIEF_SCHEMA = {
"type": "object",
"required": ["audience", "job", "template", "formats", "acceptance_checks"],
"additionalProperties": False,
"properties": {
"audience": {"type": "string", "minLength": 1, "maxLength": 240},
"job": {"type": "string", "minLength": 1, "maxLength": 240},
"template": {"type": "string", "minLength": 1, "maxLength": 80},
"formats": {
"type": "array", "minItems": 1, "maxItems": 4,
"items": {"type": "string", "enum": ["html", "pdf", "pptx", "png"]},
},
"page_target": {"type": "integer", "minimum": 1, "maximum": 200},
"length_target": {"type": "string", "minLength": 1, "maxLength": 120},
"narrative": {"type": "string", "minLength": 1, "maxLength": 800},
"required_facts": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
"required_assets": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 500},
},
"acceptance_checks": {
"type": "array", "minItems": 1, "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
"target": {
"type": "object", "additionalProperties": False,
"properties": {
"surface": {"type": "string", "minLength": 1, "maxLength": 120},
"page": {"type": "integer", "minimum": 1, "maximum": 200},
"viewport": {"type": "string", "minLength": 1, "maxLength": 80},
"state": {"type": "string", "minLength": 1, "maxLength": 120},
"element": {"type": "string", "minLength": 1, "maxLength": 160},
},
},
"preserve": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
"evidence": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 500},
},
"explicit_deviations": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
},
}
class _HtmlAttributeParser(HTMLParser):
"""Collect resource-bearing HTML attributes for asset coverage checks."""
@@ -131,7 +193,8 @@ def validate_node(value, schema: dict, path: str = "content") -> list[str]:
"""Validate `value` against a JSON Schema subset; return issue strings.
Supported keywords: type, required, properties, additionalProperties
(False only), items, minItems, maxItems, minLength, maxLength, enum.
(False only), items, minItems, maxItems, minLength, maxLength, minimum,
maximum, enum.
`$comment` and `description` carry authoring guidance and are ignored.
"""
issues: list[str] = []
@@ -153,6 +216,12 @@ def validate_node(value, schema: dict, path: str = "content") -> list[str]:
if "maxLength" in schema and n > schema["maxLength"]:
issues.append(f"{path}: too long ({n} > {schema['maxLength']} chars)")
elif isinstance(value, (int, float)) and not isinstance(value, bool):
if "minimum" in schema and value < schema["minimum"]:
issues.append(f"{path}: too small ({value} < {schema['minimum']})")
if "maximum" in schema and value > schema["maximum"]:
issues.append(f"{path}: too large ({value} > {schema['maximum']})")
elif isinstance(value, list):
n = len(value)
if "minItems" in schema and n < schema["minItems"]:
@@ -180,18 +249,142 @@ def validate_node(value, schema: dict, path: str = "content") -> list[str]:
return issues
def _brief_contract_issues(
brief: dict,
doc_type: str,
lang: str | None = None,
) -> list[str]:
"""Cross-check the artifact brief against the selected document contract."""
issues: list[str] = []
template_names = {*HTML_TEMPLATES, *PPTX_TEMPLATES, *SCREEN_TEMPLATES}
template = brief.get("template")
if not isinstance(template, str):
return issues
allowed_templates = {
name for name in template_names
if name == doc_type or name.startswith(f"{doc_type}-")
}
allowed_templates.add(doc_type)
if template not in allowed_templates:
allowed = ", ".join(sorted(allowed_templates)) or doc_type
issues.append(
f"brief.template: {template!r} does not match content type "
f"{doc_type!r} (allowed: {allowed})"
)
return issues
formats = brief.get("formats")
if isinstance(formats, list):
if doc_type == "slides":
# A slide deliverable combines the WeasyPrint source/PDF with the
# editable python-pptx fallback, even though `template` names the
# primary authoring path.
supported_formats = {"html", "pdf", "pptx", "png"}
elif template in HTML_TEMPLATES:
supported_formats = {"html", "pdf", "png"}
elif template in SCREEN_TEMPLATES:
supported_formats = {"html", "png"}
elif template in PPTX_TEMPLATES:
supported_formats = {"pptx"}
else:
supported_formats = set()
unsupported = sorted(
value for value in formats
if isinstance(value, str) and value not in supported_formats
)
if unsupported:
issues.append(
f"brief.formats: template {template!r} does not support "
f"{', '.join(unsupported)} (allowed: "
f"{', '.join(sorted(supported_formats)) or 'none'})"
)
if isinstance(lang, str):
lang_key = lang.casefold()
if lang_key == "cn" or lang_key.startswith("zh-") or lang_key == "zh":
requested_family = "cn"
elif lang_key == "en" or lang_key.startswith("en-"):
requested_family = "en"
elif lang_key == "ko" or lang_key.startswith("ko-"):
requested_family = "ko"
else:
requested_family = None
template_family = (
"en" if template.endswith("-en")
else "ko" if template.endswith("-ko")
else "cn"
)
korean_pptx_fallback = (
template == "slides-en"
and requested_family == "ko"
and isinstance(formats, list)
and "pptx" in formats
)
if (
requested_family is not None
and requested_family != template_family
and not korean_pptx_fallback
):
issues.append(
f"brief.template: {template!r} is the {template_family} variant "
f"and does not match language {lang!r}"
)
page_target = brief.get("page_target")
print_spec = HTML_TEMPLATES.get(template) or HTML_TEMPLATES.get(doc_type)
max_pages = print_spec.build_max_pages if print_spec is not None else 0
if (
doc_type == "resume"
and isinstance(page_target, int)
and not isinstance(page_target, bool)
and page_target != 2
):
issues.append(
f"brief.page_target: resume templates require exactly 2 pages, got {page_target}"
)
elif (
isinstance(page_target, int)
and not isinstance(page_target, bool)
and max_pages > 0
and page_target > max_pages
):
issues.append(
f"brief.page_target: {page_target} exceeds template "
f"{template!r} maximum {max_pages}"
)
return issues
def validate_content_file(data) -> tuple[str | None, list[str]]:
"""Validate a parsed content IR envelope. Returns (doc_type, issues)."""
if not isinstance(data, dict):
return None, ["content file must be a JSON object"]
issues = [
f"top-level: unknown field {key!r}"
for key in sorted(set(data) - _ENVELOPE_FIELDS)
]
lang = data.get("lang")
if not isinstance(lang, str) or not _LANG_TAG.fullmatch(lang):
issues.append(
"top-level 'lang' must be a language tag such as cn, en, ko, or zh-TW"
)
doc_type = data.get("type")
if not isinstance(doc_type, str) or doc_type not in content_schema_types():
known = ", ".join(content_schema_types()) or "none"
return None, [f"top-level 'type' must be one of: {known}"]
issues.append(f"top-level 'type' must be one of: {known}")
return None, issues
body = data.get("content")
if not isinstance(body, dict):
return doc_type, ["top-level 'content' must be an object"]
return doc_type, validate_node(body, load_schema(doc_type))
issues.append("top-level 'content' must be an object")
return doc_type, issues
brief = data.get("brief")
if brief is not None:
issues.extend(validate_node(brief, BRIEF_SCHEMA, "brief"))
if isinstance(brief, dict):
issues.extend(_brief_contract_issues(brief, doc_type, lang))
issues.extend(validate_node(body, load_schema(doc_type)))
return doc_type, issues
# ---------- coverage: content values must survive into the filled HTML ----------
@@ -228,10 +421,19 @@ def html_resource_attributes(raw: str) -> set[str]:
def _asset_present(needle: str, attributes: set[str]) -> bool:
expected = unquote(urlsplit(needle).path).lstrip("./")
expected_url = urlsplit(needle)
expected = unquote(expected_url.path).lstrip("./")
for raw in attributes:
actual = unquote(urlsplit(raw).path).lstrip("./")
if actual == expected or actual.endswith(f"/{expected}"):
actual_url = urlsplit(raw)
actual = unquote(actual_url.path).lstrip("./")
if expected_url.scheme or expected_url.netloc:
if (
actual_url.scheme.casefold() == expected_url.scheme.casefold()
and actual_url.netloc.casefold() == expected_url.netloc.casefold()
and actual == expected
):
return True
elif actual == expected or actual.endswith(f"/{expected}"):
return True
return False
@@ -248,9 +450,12 @@ def _leaf_values(node, path: str):
def coverage_issues(
content: dict,
content: dict | list,
html_text: str,
html_attributes: set[str] | None = None,
*,
root_path: str = "content",
force_assets: bool = False,
) -> tuple[list[str], int, int]:
"""Return (issues, checked, skipped) for content-to-HTML coverage.
@@ -262,7 +467,7 @@ def coverage_issues(
issues: list[str] = []
checked = skipped = 0
for index, (path, value) in enumerate(_leaf_values(content, "content")):
for index, (path, value) in enumerate(_leaf_values(content, root_path)):
if index >= MAX_COVERAGE_VALUES:
issues.append(f"content: too many atomic values to check (limit {MAX_COVERAGE_VALUES})")
break
@@ -278,18 +483,22 @@ def coverage_issues(
if not needle:
continue
if isinstance(value, str):
if len(needle) > COVERAGE_MAX_LEN:
skipped += 1
continue
# Asset paths are consumed by attributes, not visible text. Direct
# text-only callers may omit the attribute set; the real CLI always
# provides it and therefore proves required images were embedded.
if re.search(r"\.image(s\[\d+\])?$", path) or re.search(r"\.(png|jpe?g|svg|webp)$", needle, re.I):
is_asset = force_assets or bool(
re.search(r"\.image(s\[\d+\])?$", path)
or re.search(r"\.(png|jpe?g|svg|webp)$", needle, re.I)
)
# Asset paths are consumed by attributes, not visible text. Check
# them before the prose-length cutoff: a long URL is still a
# required resource, not prose that may be rephrased.
if is_asset:
if html_attributes is not None:
checked += 1
if not _asset_present(needle, html_attributes):
issues.append(f"{path}: asset not found in document attributes: {needle!r}")
continue
if len(needle) > COVERAGE_MAX_LEN:
skipped += 1
continue
checked += 1
cjk = bool(_CJK.search(needle))
normalized = _normalize(needle, cjk=cjk)
@@ -352,9 +561,21 @@ def check_content(paths: list[str]) -> int:
return 2
html_raw = html_path.read_text(encoding="utf-8", errors="replace")
html_text = visible_html_text(html_raw)
html_attributes = html_resource_attributes(html_raw)
missing, checked, skipped = coverage_issues(
data["content"], html_text, html_resource_attributes(html_raw)
data["content"], html_text, html_attributes
)
required_assets = (data.get("brief") or {}).get("required_assets", [])
if required_assets:
asset_missing, asset_checked, _ = coverage_issues(
required_assets,
html_text,
html_attributes,
root_path="brief.required_assets",
force_assets=True,
)
missing.extend(asset_missing)
checked += asset_checked
if missing:
print(f"ERROR: {html_rel}: {len(missing)} content value(s) missing from document")
for issue in missing:
+108 -24
View File
@@ -14,8 +14,9 @@ Register with an MCP client, for example:
Tools:
kami_templates discover templates, diagram library, content schema types
kami_doctor report installed render, check, PPTX, and font capabilities
kami_render render a filled HTML file to PDF (WeasyPrint + highlight)
kami_check run the matching deterministic checks for a file
kami_check run deterministic checks with stable findings and coverage
kami_screenshot rasterize a PDF to page PNGs plus the review checklist
Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout (MCP stdio).
@@ -37,7 +38,7 @@ from checks import (
check_placeholders,
)
from content import check_content
from optional_deps import MissingDepError
from optional_deps import MissingDepError, doctor_report
from render import render_pdf
from shared import (
DIAGRAM_TEMPLATES,
@@ -48,10 +49,43 @@ from shared import (
kami_version,
)
from visual import MAX_DPI, MIN_DPI, REVIEW_CHECKLIST, render_pages
from verify import check_fonts
PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18"}
CHECK_RULESET_VERSION = 1
CHECK_REGISTRY = {
"html.placeholders": {
"scope": "html", "severity": "error", "required_engine": "stdlib",
"explanation": "Completed HTML must not expose unresolved template placeholders.",
},
"html.markdown-residue": {
"scope": "html", "severity": "error", "required_engine": "stdlib",
"explanation": "Rendered audience copy must not expose raw Markdown syntax.",
},
"content.contract": {
"scope": "content-ir", "severity": "error", "required_engine": "stdlib",
"explanation": "Content IR must satisfy its document schema and optional artifact brief.",
},
"content.coverage": {
"scope": "html+content-ir", "severity": "error", "required_engine": "stdlib",
"explanation": "Every atomic fact and required asset in content IR must survive into the document.",
},
"pdf.markdown-residue": {
"scope": "pdf", "severity": "error", "required_engine": "pypdf",
"explanation": "Extracted PDF text must not expose raw Markdown syntax.",
},
"pdf.orphans": {
"scope": "pdf", "severity": "warning", "required_engine": "pymupdf",
"explanation": "Rendered text blocks must not end in short orphan lines.",
},
"pdf.density": {
"scope": "pdf", "severity": "warning", "required_engine": "pymupdf",
"explanation": "Rendered pages must not carry excessive trailing whitespace.",
},
}
TOOLS = [
{
"name": "kami_templates",
@@ -62,6 +96,16 @@ TOOLS = [
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "kami_doctor",
"description": (
"Report whether this installed Kami runtime can render PDFs, run "
"visual checks, build editable PPTX fallback decks, and resolve "
"the expected font families. Read-only; missing capabilities are "
"reported explicitly rather than treated as clean checks."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "kami_render",
"description": (
@@ -85,7 +129,8 @@ TOOLS = [
"Run Kami's deterministic checks for a file. HTML: placeholders + "
"markdown residue (+ content coverage when a content IR JSON is "
"given). PDF: markdown residue + orphans + density. JSON: content "
"IR schema validation. Returns the full report text."
"IR schema validation. Returns the legacy report plus stable rule "
"IDs, findings, coverage status, and explicit degraded checks."
),
"inputSchema": {
"type": "object",
@@ -101,8 +146,8 @@ TOOLS = [
"description": (
"Rasterize every PDF page to PNG for a perceptual review pass. "
"Writes or replaces <pdf-stem>-visual/page-*.png, then returns the "
"image paths and fixed review checklist; view every image against "
"the checklist before shipping."
"image paths, deterministic CJK font verdict, and fixed review "
"checklist; view every image against the checklist before shipping."
),
"inputSchema": {
"type": "object",
@@ -146,6 +191,10 @@ def tool_templates(_args: dict) -> dict:
}
def tool_doctor(_args: dict) -> dict:
return doctor_report()
def tool_render(args: dict) -> dict:
html_path = _resolve(args["html"])
if not html_path.exists():
@@ -178,34 +227,57 @@ def _run_check(fn, argv: list[str]) -> tuple[int, str]:
return code, buffer.getvalue().rstrip()
def _check_plan(path: Path, content: str | None) -> list[tuple[str, object, list[str]]]:
suffix = path.suffix.lower()
if suffix in {".html", ".htm"}:
checks: list[tuple[str, object, list[str]]] = [
("html.placeholders", check_placeholders, [str(path)]),
("html.markdown-residue", check_markdown_residue, [str(path)]),
]
if content:
checks.append((
"content.coverage", check_content,
[str(_resolve(content)), str(path)],
))
return checks
if suffix == ".pdf":
return [
("pdf.markdown-residue", check_markdown_residue, [str(path)]),
("pdf.orphans", check_orphans, [str(path)]),
("pdf.density", check_density, [str(path)]),
]
if suffix == ".json":
return [("content.contract", check_content, [str(path)])]
raise ValueError(f"unsupported file type: {path.name} (expected .html, .pdf, or .json)")
def tool_check(args: dict) -> dict:
path = _resolve(args["path"])
if not path.exists():
raise FileNotFoundError(f"file not found: {path}")
suffix = path.suffix.lower()
reports: list[str] = []
coverage: list[dict] = []
findings: list[dict] = []
worst = 0
if suffix in {".html", ".htm"}:
checks = [(check_placeholders, [str(path)]), (check_markdown_residue, [str(path)])]
if args.get("content"):
checks.append((check_content, [str(_resolve(args["content"])), str(path)]))
elif suffix == ".pdf":
checks = [
(check_markdown_residue, [str(path)]),
(check_orphans, [str(path)]),
(check_density, [str(path)]),
]
elif suffix == ".json":
checks = [(check_content, [str(path)])]
else:
raise ValueError(f"unsupported file type: {path.name} (expected .html, .pdf, or .json)")
for fn, argv in checks:
for rule_id, fn, argv in _check_plan(path, args.get("content")):
code, report = _run_check(fn, argv)
worst = max(worst, code)
reports.append(report)
return {"exit_code": worst, "ok": worst == 0, "report": "\n".join(reports)}
status = "passed" if code == 0 else ("failed" if code == 1 else "degraded")
rule = {"id": rule_id, **CHECK_REGISTRY[rule_id]}
coverage.append({**rule, "status": status, "exit_code": code})
if status != "passed":
findings.append({**rule, "status": status, "evidence": report})
return {
"ruleset_version": CHECK_RULESET_VERSION,
"exit_code": worst,
"ok": worst == 0,
"degraded": any(item["status"] == "degraded" for item in coverage),
"findings": findings,
"coverage": coverage,
"report": "\n".join(reports),
}
def tool_screenshot(args: dict) -> dict:
@@ -216,15 +288,27 @@ def tool_screenshot(args: dict) -> dict:
if dpi is not None and (isinstance(dpi, bool) or not isinstance(dpi, int)):
raise ValueError("dpi must be an integer")
pages = render_pages(pdf, dpi=dpi)
font_code, font_report = _run_check(check_fonts, [str(pdf)])
return {
"rasterized": True,
"review_pending": True,
"pages": [str(p) for p in pages],
"font_check": {
"exit_code": font_code,
"ok": font_code == 0,
"report": font_report,
},
"review_checklist": list(REVIEW_CHECKLIST),
"instruction": "View every page image against the checklist before shipping.",
"instruction": (
"Require font_check.ok, then view every page image against the "
"checklist. Settle the perceptual review outside this tool before shipping."
),
}
TOOL_HANDLERS = {
"kami_templates": tool_templates,
"kami_doctor": tool_doctor,
"kami_render": tool_render,
"kami_check": tool_check,
"kami_screenshot": tool_screenshot,
@@ -7,9 +7,13 @@ is configured once at the import call site.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import shutil
import subprocess
import sys
from shared import configure_weasyprint_runtime
from shared import ROOT, configure_weasyprint_runtime, kami_version
# On Linux, WeasyPrint links against cairo / pango / harfbuzz at runtime; a bare
# `pip install weasyprint` succeeds but then fails to load with a cryptic
@@ -72,3 +76,176 @@ def require_pymupdf():
raise MissingDepError(
f"missing PyMuPDF. {PYMUPDF_INSTALL_HINT}"
) from exc
def _distribution_version(name: str) -> str | None:
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return None
def _probe_dependency(
name: str,
distribution: str,
purpose: str,
loader,
*,
required: bool,
) -> dict:
try:
loader()
except MissingDepError as exc:
return {
"name": name,
"status": "missing",
"required": required,
"purpose": purpose,
"version": _distribution_version(distribution),
"detail": str(exc),
}
except Exception as exc:
return {
"name": name,
"status": "degraded",
"required": required,
"purpose": purpose,
"version": _distribution_version(distribution),
"detail": f"import failed: {type(exc).__name__}: {exc}",
}
return {
"name": name,
"status": "available",
"required": required,
"purpose": purpose,
"version": _distribution_version(distribution),
}
def _probe_module(name: str):
return importlib.import_module(name)
def _probe_font(family: str, bundled_names: tuple[str, ...], purpose: str) -> dict:
font_dir = ROOT / "assets" / "fonts"
bundled = [name for name in bundled_names if (font_dir / name).is_file()]
matched = None
matcher = shutil.which("fc-match")
if matcher:
try:
result = subprocess.run(
[matcher, "-f", "%{family}\n", family],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode == 0:
matched = (result.stdout.splitlines() or [""])[0].strip() or None
except (OSError, subprocess.TimeoutExpired):
matched = None
resolved = bool(matched and family.casefold() in matched.casefold())
status = "available" if bundled or resolved else "unconfirmed"
return {
"name": family,
"status": status,
"purpose": purpose,
"bundled": bundled,
"fontconfig_match": matched,
"detail": (
None if status == "available"
else "not bundled and fontconfig did not confirm the requested family"
),
}
def doctor_report() -> dict:
"""Return installed render, verification, and font capabilities.
Required dependencies define whether the normal HTML -> PDF -> visual
verification path is ready. Editable PPTX and language-specific font
families are reported separately so an unavailable optional path cannot be
mistaken for a clean check or for a broken core install.
"""
dependencies = [
_probe_dependency(
"weasyprint", "weasyprint", "HTML to PDF rendering",
require_weasyprint_html, required=True,
),
_probe_dependency(
"pypdf", "pypdf", "PDF text, metadata, and page checks",
require_pypdf_reader, required=True,
),
_probe_dependency(
"pymupdf", "PyMuPDF", "page screenshots, density, and orphan checks",
require_pymupdf, required=True,
),
_probe_dependency(
"python-pptx", "python-pptx", "editable PPTX fallback",
lambda: _probe_module("pptx"), required=False,
),
_probe_dependency(
"pygments", "Pygments", "build-time code highlighting",
lambda: _probe_module("pygments"), required=False,
),
]
fonts = [
_probe_font(
"JetBrains Mono", ("JetBrainsMono.woff2",),
"code and metadata labels",
),
_probe_font(
"TsangerJinKai02", ("TsangerJinKai02-W04.ttf", "TsangerJinKai02-W05.ttf"),
"primary Chinese editorial serif",
),
_probe_font(
"Source Han Serif KR",
("SourceHanSerifKR-Regular.otf", "SourceHanSerifKR-Medium.otf"),
"Korean editorial serif fallback",
),
]
required_ready = all(
item["status"] == "available"
for item in dependencies
if item["required"]
)
return {
"version": kami_version(),
"ok": required_ready,
"dependencies": dependencies,
"fonts": fonts,
"capabilities": {
"pdf_render": all(
item["status"] == "available"
for item in dependencies
if item["name"] in {"weasyprint", "pypdf"}
),
"pdf_visual_review": all(
item["status"] == "available"
for item in dependencies
if item["name"] in {"pypdf", "pymupdf"}
),
"editable_pptx": next(
item["status"] == "available"
for item in dependencies
if item["name"] == "python-pptx"
),
},
}
def run_doctor() -> int:
report = doctor_report()
print(f"Kami doctor {report['version']}")
for item in report["dependencies"]:
label = "OK" if item["status"] == "available" else item["status"].upper()
version = f" {item['version']}" if item.get("version") else ""
print(f"{label}: {item['name']}{version}: {item['purpose']}")
if item.get("detail"):
print(f" {item['detail']}")
for item in report["fonts"]:
label = "OK" if item["status"] == "available" else item["status"].upper()
source = "bundled" if item["bundled"] else (item.get("fontconfig_match") or "not confirmed")
print(f"{label}: font {item['name']}: {source}")
print("OK: core render and visual verification ready" if report["ok"] else "ERROR: core capability missing or degraded")
return 0 if report["ok"] else 1
@@ -60,7 +60,7 @@ awk '
# allowlist above or named in the repo-only exclusion below. Without this, a
# new runtime module that build.py imports would silently miss the zip and
# the installed skill would ImportError while every local check stays green.
SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|package-skill\.sh|tests/)'
SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|release_gate\.py|package-skill\.sh|tests/)'
unaccounted="$(grep '^scripts/' "$MANIFEST" \
| grep -Ev "$SCRIPTS_REPO_ONLY_RE" \
| grep -Fvx -f <(grep '^scripts/' "$FILTERED_MANIFEST" || true) || true)"
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Fail-closed identity and artifact checks for versioned releases."""
from __future__ import annotations
import argparse
import hashlib
import re
import subprocess
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
def release_identity_issues(
tag: str,
version: str,
head_sha: str,
tag_sha: str,
) -> list[str]:
"""Return identity mismatches that make a versioned release unsafe."""
issues: list[str] = []
if not VERSION_RE.fullmatch(version):
issues.append(f"VERSION must be x.y.z, got {version!r}")
expected_tag = f"V{version}"
if tag != expected_tag:
issues.append(f"tag {tag!r} does not match VERSION ({expected_tag})")
if head_sha != tag_sha:
issues.append(f"tag commit {tag_sha} does not match checkout HEAD {head_sha}")
return issues
def _zip_payloads(path: Path) -> dict[str, str | None]:
"""Return the exact ZIP manifest and payloads, rejecting ambiguous names."""
with zipfile.ZipFile(path) as archive:
payloads: dict[str, str | None] = {}
for info in archive.infolist():
name = info.filename
if name in payloads:
raise ValueError(f"duplicate ZIP entry: {name}")
payloads[name] = (
None if info.is_dir()
else hashlib.sha256(archive.read(info)).hexdigest()
)
return dict(sorted(payloads.items()))
def archive_payload_issues(tracked: Path, candidate: Path) -> list[str]:
"""Compare release archives by entry names and uncompressed payload bytes."""
if not tracked.is_file():
return [f"tracked archive not found: {tracked}"]
if not candidate.is_file():
return [f"candidate archive not found: {candidate}"]
try:
tracked_payloads = _zip_payloads(tracked)
candidate_payloads = _zip_payloads(candidate)
except (OSError, ValueError, zipfile.BadZipFile) as exc:
return [f"could not read release archive: {exc}"]
issues: list[str] = []
tracked_names = set(tracked_payloads)
candidate_names = set(candidate_payloads)
for name in sorted(tracked_names - candidate_names):
issues.append(f"candidate archive is missing {name}")
for name in sorted(candidate_names - tracked_names):
issues.append(f"candidate archive has extra entry {name}")
for name in sorted(tracked_names & candidate_names):
if tracked_payloads[name] != candidate_payloads[name]:
issues.append(f"candidate payload differs: {name}")
return issues
def _git(*args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout.strip()
def resolve_tag_commit(tag: str) -> str:
"""Resolve only the tag namespace, never a same-named branch or revision."""
return _git("rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", required=True, help="version tag being published")
parser.add_argument("--tracked-archive", type=Path)
parser.add_argument("--candidate-archive", type=Path)
args = parser.parse_args(argv)
try:
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
head_sha = _git("rev-parse", "HEAD")
tag_sha = resolve_tag_commit(args.tag)
except (OSError, RuntimeError) as exc:
print(f"ERROR: release identity could not be resolved: {exc}")
return 2
issues = release_identity_issues(args.tag, version, head_sha, tag_sha)
if bool(args.tracked_archive) != bool(args.candidate_archive):
issues.append("provide both --tracked-archive and --candidate-archive")
elif args.tracked_archive and args.candidate_archive:
issues.extend(archive_payload_issues(args.tracked_archive, args.candidate_archive))
if issues:
for issue in issues:
print(f"ERROR: {issue}")
return 1
print(f"OK: release identity matches {args.tag} at {head_sha}")
if args.tracked_archive:
print("OK: candidate archive payloads match tracked dist/kami.zip")
return 0
if __name__ == "__main__":
sys.exit(main())
+16 -4
View File
@@ -13,6 +13,7 @@ import functools
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from highlight import highlight_code_blocks
@@ -108,11 +109,22 @@ def render_pdf(src: Path, out: Path) -> int:
HTML = require_weasyprint_html()
PdfReader = require_pypdf_reader()
html_text = highlight_code_blocks(src.read_text(encoding="utf-8"))
out.parent.mkdir(parents=True, exist_ok=True)
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out))
set_pdf_metadata(out, author=infer_author())
return len(PdfReader(str(out)).pages)
html_text = highlight_code_blocks(src.read_text(encoding="utf-8"))
# Build and validate beside the destination, then atomically replace it.
# Metadata stamping and the final page read can still fail after WeasyPrint
# succeeds; writing straight to `out` would destroy the last good artifact
# before the caller learns that the render failed.
with tempfile.TemporaryDirectory(
dir=out.parent,
prefix=f".{out.name}-",
) as staging_dir:
candidate = Path(staging_dir) / out.name
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(candidate))
set_pdf_metadata(candidate, author=infer_author())
page_count = len(PdfReader(str(candidate)).pages)
os.replace(candidate, out)
return page_count
def build_slides(name: str = "slides") -> bool:
@@ -20,6 +20,7 @@ import shutil
import subprocess
import sys
import tempfile
import warnings
import zipfile
from pathlib import Path
@@ -100,6 +101,7 @@ from verify import ( # noqa: E402
_PASS = 0
_FAIL = 0
_SKIP = 0
def check(name: str, predicate: bool, detail: str = "") -> None:
@@ -112,6 +114,17 @@ def check(name: str, predicate: bool, detail: str = "") -> None:
print(f"ERROR: {name}{(' - ' + detail) if detail else ''}")
def skip(name: str, detail: str = "", *, ci_required: bool = False) -> None:
"""Record an unavailable optional-dependency test without calling it a pass."""
global _SKIP, _FAIL
_SKIP += 1
if ci_required and os.environ.get("CI"):
_FAIL += 1
print(f"ERROR: required CI test skipped: {name}{(' - ' + detail) if detail else ''}")
else:
print(f"SKIP: {name}{(' - ' + detail) if detail else ''}")
def write_temp_html(body: str, suffix: str = "-en.html") -> Path:
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8")
f.write(body)
@@ -755,8 +768,9 @@ def test_density_scans_the_only_page_of_a_single_page_pdf() -> None:
"""
try:
fitz = require_pymupdf()
except MissingDepError:
return # PyMuPDF absent (the lint-and-test CI job); density suite skipped
except MissingDepError as exc:
skip("single-page density regression", str(exc), ci_required=True)
return
with tempfile.TemporaryDirectory() as tmp:
single = Path(tmp) / "one-page.pdf"
@@ -783,6 +797,24 @@ def test_density_scans_the_only_page_of_a_single_page_pdf() -> None:
f"scan: {explicit_scan} (fixture leaves ~64% of the page empty)")
def test_ci_required_skip_is_a_failure_not_a_pass() -> None:
"""A missing heavy dependency in CI must turn the suite red."""
global _FAIL, _SKIP
original_fail, original_skip = _FAIL, _SKIP
original_ci = os.environ.get("CI")
try:
os.environ["CI"] = "1"
silently(skip, "negative-control fixture", ci_required=True)
rejected = _FAIL == original_fail + 1 and _SKIP == original_skip + 1
finally:
_FAIL, _SKIP = original_fail, original_skip
if original_ci is None:
os.environ.pop("CI", None)
else:
os.environ["CI"] = original_ci
check("CI-required skip increments failure and skip counters", rejected)
def test_chinese_slides_mono_has_cjk_fallback() -> None:
"""Slide labels may mix mono Latin and CJK; the mono stack needs CJK fallback."""
text = (TEMPLATES / "slides-weasy.html").read_text(encoding="utf-8")
@@ -1480,6 +1512,117 @@ def test_highlight_without_pygments_dependency() -> None:
f"warning: {warning.getvalue()}")
def test_render_pdf_preserves_last_good_output_on_post_render_failure() -> None:
"""A failed metadata/page validation must not replace the prior PDF."""
import render as render_mod
class FakeHTML:
def __init__(self, **_kwargs):
pass
def write_pdf(self, path: str) -> None:
Path(path).write_bytes(b"new-candidate")
original_html = render_mod.require_weasyprint_html
original_reader = render_mod.require_pypdf_reader
original_metadata = render_mod.set_pdf_metadata
try:
render_mod.require_weasyprint_html = lambda: FakeHTML
render_mod.require_pypdf_reader = lambda: object
def fail_metadata(*_args, **_kwargs) -> None:
raise RuntimeError("injected metadata failure")
render_mod.set_pdf_metadata = fail_metadata
with tempfile.TemporaryDirectory() as d:
root = Path(d)
src = root / "source.html"
out = root / "output.pdf"
src.write_text("<html><body>candidate</body></html>", encoding="utf-8")
out.write_bytes(b"last-good")
try:
render_mod.render_pdf(src, out)
except RuntimeError as exc:
failed = "metadata failure" in str(exc)
else:
failed = False
staged = list(root.glob(".output.pdf-*"))
check("render failure preserves the last good PDF",
failed and out.read_bytes() == b"last-good",
f"failed={failed} bytes={out.read_bytes()!r}")
check("render failure cleans staged candidates", staged == [], str(staged))
finally:
render_mod.require_weasyprint_html = original_html
render_mod.require_pypdf_reader = original_reader
render_mod.set_pdf_metadata = original_metadata
def test_release_gate_rejects_identity_mismatches() -> None:
from release_gate import release_identity_issues
good = release_identity_issues("V1.2.3", "1.2.3", "same", "same")
wrong_tag = release_identity_issues("V1.2.4", "1.2.3", "same", "same")
wrong_sha = release_identity_issues("V1.2.3", "1.2.3", "head", "tag")
check("release identity accepts an exact tag, version, and SHA", good == [], str(good))
check("release identity rejects a tag/version mismatch",
any("does not match VERSION" in issue for issue in wrong_tag), str(wrong_tag))
check("release identity rejects a tag/checkout SHA mismatch",
any("does not match checkout HEAD" in issue for issue in wrong_sha), str(wrong_sha))
def test_release_gate_resolves_only_the_tag_namespace() -> None:
import release_gate as release_gate_mod
calls = []
original_git = release_gate_mod._git
try:
release_gate_mod._git = lambda *args: calls.append(args) or "tag-sha"
resolved = release_gate_mod.resolve_tag_commit("V1.2.3")
finally:
release_gate_mod._git = original_git
check("release gate cannot resolve a same-named branch as a tag",
resolved == "tag-sha"
and calls == [("rev-parse", "--verify", "refs/tags/V1.2.3^{commit}")],
str(calls))
def test_release_gate_compares_zip_payloads_not_container_bytes() -> None:
from release_gate import archive_payload_issues
with tempfile.TemporaryDirectory() as d:
root = Path(d)
tracked = root / "tracked.zip"
equivalent = root / "equivalent.zip"
changed = root / "changed.zip"
duplicated = root / "duplicated.zip"
with zipfile.ZipFile(tracked, "w") as archive:
archive.writestr("kami/VERSION", "1.2.3")
with zipfile.ZipFile(equivalent, "w") as archive:
info = zipfile.ZipInfo("kami/VERSION", date_time=(2026, 1, 2, 3, 4, 6))
archive.writestr(info, "1.2.3")
with zipfile.ZipFile(changed, "w") as archive:
archive.writestr("kami/VERSION", "1.2.4")
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
with zipfile.ZipFile(duplicated, "w") as archive:
archive.writestr("kami/VERSION", "first")
archive.writestr("kami/VERSION", "second")
same_issues = archive_payload_issues(tracked, equivalent)
changed_issues = archive_payload_issues(tracked, changed)
duplicate_issues = archive_payload_issues(duplicated, tracked)
check("release gate ignores ZIP container timestamp differences",
tracked.read_bytes() != equivalent.read_bytes() and same_issues == [],
str(same_issues))
check("release gate rejects changed entry payloads",
changed_issues == ["candidate payload differs: kami/VERSION"],
str(changed_issues))
check("release gate rejects duplicate ZIP entry names",
len(duplicate_issues) == 1
and "duplicate ZIP entry: kami/VERSION" in duplicate_issues[0],
str(duplicate_issues))
def test_marp_themes_token_synced() -> None:
"""Marp theme CSS keeps its :root tokens in sync with tokens.json.
@@ -1740,6 +1883,9 @@ def test_validate_node_flags_structural_defects() -> None:
"too long" in text and "too few items" in text
and "missing required field 'value'" in text and "'hero' not in" in text,
text)
numeric = validate_node(0, {"type": "integer", "minimum": 1, "maximum": 3}, "page")
check("validate_node enforces numeric bounds",
numeric == ["page: too small (0 < 1)"], str(numeric))
def test_coverage_issues_catch_dropped_values() -> None:
@@ -1793,6 +1939,28 @@ def test_check_content_cli_validates_and_covers() -> None:
try:
rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage passes when atomic values present", rc == 0)
payload["brief"] = {
"audience": "Technical collaborator",
"job": "Secure review",
"template": "letter-en",
"formats": ["html", "pdf"],
"required_assets": ["must-appear-logo.svg"],
"acceptance_checks": ["required logo is embedded"],
}
content_path.write_text(json.dumps(payload), encoding="utf-8")
missing_asset_rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage rejects a missing required brief asset",
missing_asset_rc == 1)
html.write_text(
html.read_text(encoding="utf-8").replace(
"</body>", '<img src="must-appear-logo.svg" alt=""></body>'
),
encoding="utf-8",
)
embedded_asset_rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage accepts an embedded required brief asset",
embedded_asset_rc == 0)
finally:
html.unlink()
del payload["content"]["signature"]
@@ -1803,6 +1971,162 @@ def test_check_content_cli_validates_and_covers() -> None:
check("check_content usage error returns 2", rc == 2)
def test_content_ir_rejects_invalid_envelope() -> None:
from content import validate_content_file
body = {
"sender": "Ada Lovelace, London",
"date": "2026-07-13",
"recipient": "Charles Babbage",
"salutation": "Dear Charles,",
"paragraphs": [
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
"My ask is specific: review the table this month so we can test it on the mill.",
],
"signoff": "Sincerely,",
"signature": "Ada",
}
valid_type, valid_issues = validate_content_file({
"type": "letter", "lang": "zh-TW", "content": body,
})
_, invalid_issues = validate_content_file({
"type": "letter", "lang": "not_a_locale", "content": body,
"unexpected": True,
})
check("content IR accepts a strict language-tagged envelope",
valid_type == "letter" and valid_issues == [], str(valid_issues))
check("content IR rejects invalid lang and unknown top-level fields",
any("'lang'" in issue for issue in invalid_issues)
and any("unknown field 'unexpected'" in issue for issue in invalid_issues),
str(invalid_issues))
def test_content_ir_validates_optional_artifact_brief() -> None:
from content import _brief_contract_issues, validate_content_file
body = {
"sender": "Ada Lovelace, London",
"date": "2026-07-13",
"recipient": "Charles Babbage",
"salutation": "Dear Charles,",
"paragraphs": [
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
"My ask is specific: review the table this month so we can test it on the mill.",
],
"signoff": "Sincerely,",
"signature": "Ada",
}
brief = {
"audience": "Technical collaborator",
"job": "Secure a review of the program table",
"template": "letter-en",
"formats": ["html", "pdf"],
"page_target": 1,
"acceptance_checks": ["one page", "specific ask remains visible"],
"target": {"surface": "letter body", "page": 1},
"preserve": ["letterhead", "signature"],
"evidence": ["rendered page 1"],
}
_, valid_issues = validate_content_file({
"type": "letter", "lang": "en", "brief": brief, "content": body,
})
invalid = dict(brief)
invalid["formats"] = ["docx"]
invalid["page_target"] = 0
invalid["mystery"] = True
_, invalid_issues = validate_content_file({
"type": "letter", "lang": "en", "brief": invalid, "content": body,
})
text = "\n".join(invalid_issues)
check("content IR accepts a structured artifact brief", valid_issues == [], str(valid_issues))
check("content IR rejects unknown brief fields, formats, and page bounds",
"brief: unknown field 'mystery'" in text
and "'docx' not in" in text
and "brief.page_target: too small" in text,
text)
conflicting = dict(brief, template="one-pager", page_target=2)
_, conflict_issues = validate_content_file({
"type": "one-pager", "lang": "cn", "brief": conflicting,
"content": {
"title": "A concise product claim",
"subtitle": "A subtitle long enough to explain the intended audience and outcome",
"metrics": [
{"value": "10x", "label": "faster"},
{"value": "99%", "label": "coverage"},
{"value": "2m", "label": "setup"},
],
"argument": [
"A focused paragraph that explains the user problem, the proposed change, and why it matters now in concrete terms."
],
"evidence": ["Measured result", "Observed behavior", "Verified source"],
"next_step": "Review the evidence and approve the next test.",
},
})
check("content IR rejects a page target beyond the template ceiling",
any("exceeds template 'one-pager' maximum 1" in issue for issue in conflict_issues),
str(conflict_issues))
one_page_resume_issues = _brief_contract_issues(
{"template": "resume-en", "formats": ["html", "pdf"], "page_target": 1},
"resume",
"en",
)
check("brief contract enforces the two-page resume hard stop",
any("require exactly 2 pages" in issue for issue in one_page_resume_issues),
str(one_page_resume_issues))
landing_issues = _brief_contract_issues(
{"template": "landing-page", "page_target": 2}, "landing-page"
)
slides_issues = _brief_contract_issues(
{"template": "slides", "page_target": 12}, "slides"
)
editable_slides_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["pptx"], "page_target": 12}, "slides"
)
impossible_resume_issues = _brief_contract_issues(
{"template": "resume", "formats": ["pptx"], "page_target": 2}, "resume"
)
wrong_language_issues = _brief_contract_issues(
{"template": "resume-en", "formats": ["html", "pdf"], "page_target": 2},
"resume",
"ko",
)
korean_pptx_fallback_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf", "pptx"]},
"slides",
"ko",
)
korean_pdf_wrong_variant_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf"]},
"slides",
"ko",
)
check("brief contract accepts screen, generic slide, and editable PPTX keys",
landing_issues == [] and slides_issues == [] and editable_slides_issues == [],
f"landing={landing_issues} slides={slides_issues} "
f"editable={editable_slides_issues}")
check("brief contract rejects a format the selected template cannot produce",
any("does not support pptx" in issue for issue in impossible_resume_issues),
str(impossible_resume_issues))
check("brief contract rejects a known language/template variant mismatch",
any("does not match language 'ko'" in issue for issue in wrong_language_issues),
str(wrong_language_issues))
check("brief contract keeps the documented Korean editable-PPTX fallback",
korean_pptx_fallback_issues == [], str(korean_pptx_fallback_issues))
check("brief contract limits the Korean slides-en fallback to editable PPTX",
any("does not match language 'ko'" in issue
for issue in korean_pdf_wrong_variant_issues),
str(korean_pdf_wrong_variant_issues))
_, legacy_issues = validate_content_file({
"type": "letter", "lang": "en", "content": body,
})
check("content IR keeps pre-brief files valid", legacy_issues == [], str(legacy_issues))
def test_build_cli_dispatches_new_checks() -> None:
rc, out = run_build_args(["--check-content"])
check("build.py --check-content without args is a usage error",
@@ -1810,6 +2134,36 @@ def test_build_cli_dispatches_new_checks() -> None:
rc, out = run_build_args(["--check-visual"])
check("build.py --check-visual without args is a usage error",
rc == 2 and "usage" in out, out.strip()[:120])
rc, out = run_build_args(["--doctor"])
check("build.py --doctor reports capability status",
rc in {0, 1} and "Kami doctor" in out
and "visual verification" in out,
out.strip()[:300])
def test_skill_routes_visual_repairs_and_generated_assets_without_losing_contracts() -> None:
skill = (REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
diagrams = (REPO_ROOT / "references" / "diagrams.md").read_text(encoding="utf-8")
for mode in ("New document", "Content-only", "Visual repair", "Generated asset"):
check(f"SKILL work mode keeps {mode}", mode in skill)
check("visual repair locks target, preserve, evidence, and artifact matrices",
all(term in skill for term in (
"`target`", "`preserve`", "PDF: target page", "Screen: 1280px",
"PPTX: editable source", "Generated asset: target slot",
)),
"visual feedback contract incomplete")
check("image generation routes from observed capability",
"Route from observed capability" in skill
and "Claude, Codex, most coding agents" not in skill,
"host-name capability list still present")
check("illustration brief keeps old visual system and adds semantic anchors",
all(term in diagrams for term in (
"1. Claim:", "2. Placement:", "3. Reference:", "4. Exclusions:",
"5. Canvas:", "6. Accent:", "7. Strokes and icons:",
"8. Labels:", "9. Content spec:",
))
and "After two look-based rejections" in diagrams,
"illustration brief lost a field")
def test_visual_checklist_and_output_dir() -> None:
@@ -1893,6 +2247,24 @@ def test_coverage_checks_asset_attributes() -> None:
)
check("coverage ignores assets in templates and plain links",
len(hidden) == 2, f"issues={hidden} attrs={attrs}")
wrong_origin, _, _ = coverage_issues(
["https://brand.example/logo.svg"],
"",
html_resource_attributes('<img src="https://other.example/logo.svg">'),
root_path="brief.required_assets",
force_assets=True,
)
same_origin, _, _ = coverage_issues(
["https://brand.example/logo.svg?v=approved"],
"",
html_resource_attributes('<img src="https://brand.example/logo.svg?v=cache">'),
root_path="brief.required_assets",
force_assets=True,
)
check("required absolute assets cannot be impersonated by the same path on another host",
len(wrong_origin) == 1, str(wrong_origin))
check("required absolute assets tolerate cache-query changes on the same origin and path",
same_origin == [], str(same_origin))
def test_coverage_caps_adversarial_reports() -> None:
@@ -1920,6 +2292,8 @@ def test_mcp_server_stdio_protocol() -> None:
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "kami_templates", "arguments": {}}},
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "kami_doctor", "arguments": {}}},
{"jsonrpc": "2.0", "id": 5, "method": "tools/call",
"params": {"name": "nope", "arguments": {}}},
]
stdin = "".join(json.dumps(m) + "\n" for m in msgs)
@@ -1938,8 +2312,8 @@ def test_mcp_server_stdio_protocol() -> None:
and init.get("serverInfo", {}).get("name") == "kami",
json.dumps(init)[:200])
tools = [t["name"] for t in replies.get(2, {}).get("result", {}).get("tools", [])]
check("mcp tools/list exposes the four kami tools",
tools == ["kami_templates", "kami_render", "kami_check", "kami_screenshot"],
check("mcp tools/list exposes the five kami tools",
tools == ["kami_templates", "kami_doctor", "kami_render", "kami_check", "kami_screenshot"],
str(tools))
body = replies.get(3, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
payload = json.loads(body)
@@ -1947,9 +2321,49 @@ def test_mcp_server_stdio_protocol() -> None:
set(payload.get("document_templates", {})) == set(HTML_TEMPLATES)
and payload.get("content_schema_types"),
body[:200])
doctor_body = replies.get(4, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
doctor = json.loads(doctor_body)
check("mcp kami_doctor reports dependencies, fonts, and capabilities",
isinstance(doctor.get("ok"), bool)
and len(doctor.get("dependencies", [])) >= 3
and len(doctor.get("fonts", [])) >= 3
and "pdf_visual_review" in doctor.get("capabilities", {}),
doctor_body[:300])
check("mcp unknown tool returns a JSON-RPC error",
"error" in replies.get(4, {}), json.dumps(replies.get(4, {}))[:200])
check("mcp notification produced no reply", len(replies) == 4, str(sorted(replies)))
"error" in replies.get(5, {}), json.dumps(replies.get(5, {}))[:200])
check("mcp notification produced no reply", len(replies) == 5, str(sorted(replies)))
def test_mcp_check_returns_stable_findings_and_coverage() -> None:
from mcp_server import CHECK_REGISTRY, tool_check
with tempfile.TemporaryDirectory() as d:
clean = Path(d) / "clean.html"
broken = Path(d) / "broken.html"
clean.write_text("<html><body><p>Ready</p></body></html>", encoding="utf-8")
broken.write_text("<html><body><p>{{ missing }}</p></body></html>", encoding="utf-8")
clean_result = tool_check({"path": str(clean)})
broken_result = tool_check({"path": str(broken)})
check("MCP check registry carries unique stable rule IDs",
len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
and all({"scope", "severity", "required_engine", "explanation"} <= set(rule)
for rule in CHECK_REGISTRY.values()),
str(CHECK_REGISTRY))
check("MCP clean check returns coverage without findings",
clean_result["ok"] is True
and clean_result["degraded"] is False
and clean_result["findings"] == []
and [item["id"] for item in clean_result["coverage"]]
== ["html.placeholders", "html.markdown-residue"]
and clean_result["report"],
json.dumps(clean_result)[:500])
check("MCP failed check returns a stable finding and legacy report",
broken_result["ok"] is False
and broken_result["findings"][0]["id"] == "html.placeholders"
and broken_result["findings"][0]["status"] == "failed"
and "placeholder" in broken_result["report"].lower(),
json.dumps(broken_result)[:500])
def test_mcp_server_rejects_bad_frames_without_exiting() -> None:
@@ -1978,6 +2392,76 @@ def test_mcp_server_rejects_bad_frames_without_exiting() -> None:
result.stdout[:400])
def test_mcp_all_tools_succeed_over_stdio() -> None:
"""Exercise render, check, and screenshot through the installed protocol path."""
try:
from optional_deps import require_pypdf_reader, require_weasyprint_html
require_weasyprint_html()
require_pypdf_reader()
require_pymupdf()
except MissingDepError as exc:
skip("MCP all-tools stdio success path", str(exc), ci_required=True)
return
script = REPO_ROOT / "scripts" / "mcp_server.py"
with tempfile.TemporaryDirectory() as d:
root = Path(d)
html = root / "source.html"
pdf = root / "output.pdf"
html.write_text(
"<!doctype html><html><head><style>"
"@page{size:A4;margin:20mm}body{font-family:serif}"
"</style></head><body><h1>Kami MCP smoke</h1><p>Rendered.</p></body></html>",
encoding="utf-8",
)
msgs = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18"}},
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "kami_render", "arguments": {
"html": str(html), "out": str(pdf)}}},
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "kami_check", "arguments": {"path": str(html)}}},
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "kami_screenshot", "arguments": {"pdf": str(pdf)}}},
]
result = subprocess.run(
[sys.executable, str(script)],
input="".join(json.dumps(m) + "\n" for m in msgs),
capture_output=True, text=True, cwd=REPO_ROOT, timeout=120,
)
try:
replies = {m.get("id"): m for m in map(json.loads, result.stdout.strip().splitlines())}
payloads = {
reply_id: json.loads(
replies[reply_id]["result"]["content"][0]["text"]
)
for reply_id in (2, 3, 4)
}
except (KeyError, json.JSONDecodeError) as exc:
check("MCP all-tools success path returns JSON results", False,
f"{exc}: {(result.stdout + result.stderr)[:500]}")
return
render_result = payloads[2]
check_result = payloads[3]
screenshot_result = payloads[4]
check("MCP render succeeds over stdio",
result.returncode == 0 and render_result.get("pages") == 1 and pdf.is_file(),
json.dumps(render_result)[:300])
check("MCP check succeeds over stdio",
check_result.get("ok") is True and check_result.get("exit_code") == 0,
json.dumps(check_result)[:300])
page_paths = [Path(path) for path in screenshot_result.get("pages", [])]
check("MCP screenshot returns evidence without a false perceptual verdict",
"ok" not in screenshot_result
and screenshot_result.get("rasterized") is True
and screenshot_result.get("review_pending") is True
and screenshot_result.get("font_check", {}).get("ok") is True
and len(page_paths) == 1 and all(path.is_file() for path in page_paths),
json.dumps(screenshot_result)[:500])
def test_mcp_render_guards_source_and_output_types() -> None:
from mcp_server import tool_render
@@ -2009,7 +2493,7 @@ def test_visual_rejects_empty_pdf_and_bad_dpi() -> None:
from pypdf import PdfWriter
from visual import render_pages
except ImportError:
check("visual empty-PDF guard skipped without pypdf", True)
skip("visual empty-PDF guard", "pypdf unavailable", ci_required=True)
return
with tempfile.TemporaryDirectory() as d:
@@ -2086,7 +2570,7 @@ def main() -> int:
continue
func()
print()
print(f"Passed: {_PASS} | Failed: {_FAIL}")
print(f"Passed: {_PASS} | Skipped: {_SKIP} | Failed: {_FAIL}")
return 0 if _FAIL == 0 else 1
+13 -5
View File
@@ -650,11 +650,15 @@ For raster illustrations delegated to the host's image generation (SKILL.md «Il
**Brief skeleton**, in order:
1. Canvas: warm parchment `#f5f4ed`, never pure white; generous whitespace; composed like a figure in a well-typeset report.
2. Accent: ink blue `#1B365D` on the 1-2 focal elements only; everything else warm gray with a yellow-brown undertone; no second hue anywhere.
3. Strokes and icons: thin single-line geometric strokes; flat icons matching section 6 (rounded line style, no fills beyond the two sanctioned ones); no gradients, drop shadows, or 3D.
4. Labels: serif, few, short. Prefer single words; image models misspell long phrases, and a misspelled label voids the image. If a label must be a phrase, plan to typeset it in HTML over the image instead.
5. Content spec: the same complexity budget as section 2 (state the tier: 4/10 editorial or 6-7/10 teaching), what is focal, and the reading direction.
1. Claim: one sentence stating what the reader should conclude. Name the assertion, not the topic: “review protects the release boundary”, not “software workflow”.
2. Placement: destination, aspect ratio, and smallest display size. README inline, social card, slide, and report figure have different type floors.
3. Reference: the accepted sibling image or named visual system this must sit beside. State what survives from that reference: composition, density, line language, or crop.
4. Exclusions: what must not appear. Version strings, release copy, invented UI, unrelated atmosphere, extra hues, and private identifiers stay out unless the task explicitly needs them.
5. Canvas: warm parchment `#f5f4ed`, never pure white; generous whitespace; composed like a figure in a well-typeset report.
6. Accent: ink blue `#1B365D` on the 1-2 focal elements only; everything else warm gray with a yellow-brown undertone; no second hue anywhere.
7. Strokes and icons: thin single-line geometric strokes; flat icons matching section 6 (rounded line style, no fills beyond the two sanctioned ones); no gradients, drop shadows, or 3D.
8. Labels: serif, few, short. Prefer single words; image models misspell long phrases, and a misspelled label voids the image. If a label must be a phrase, plan to typeset it in HTML over the image instead.
9. Content spec: the same complexity budget as section 2 (state the tier: 4/10 editorial or 6-7/10 teaching), what is focal, and the reading direction.
**QC before placing a generated image** (regenerate on any failure, do not retouch expectations):
@@ -662,8 +666,12 @@ For raster illustrations delegated to the host's image generation (SKILL.md «Il
- No gradient, shadow, or 3D crept in.
- Text in the image is spelled correctly and minimal; anything wrong or verbose gets re-briefed with fewer words.
- Composition reads as a report figure (balanced margins, clear focal point), not as a poster or clip art.
- The claim is legible at the stated smallest display size; a detail visible only in the full-resolution source does not count.
- Every exclusion holds, especially version text, invented product surfaces, unrelated decoration, and private identifiers.
- Style matches the other generated images in the same deliverable (shared style anchor, see SKILL.md batch rule).
After a partly successful generation, name what survives before changing the brief. After two look-based rejections, stop blind regeneration and use the SKILL.md comparison protocol: preserve the accepted part, show labeled alternatives in the same frame, and realign on the claim, reference, and exclusions.
---
## 12. Credit
+6 -1
View File
@@ -35,6 +35,7 @@ Usage:
python3 scripts/build.py --check-fonts path/to/doc.pdf # which family actually drew the CJK text
python3 scripts/build.py --check-style path/to/filled.html # template rules against a produced document
python3 scripts/build.py --check-docs # lint the CSS snippets the reference docs teach from
python3 scripts/build.py --doctor # installed render/check/font capabilities
"""
from __future__ import annotations
@@ -58,7 +59,7 @@ from lint import (
check_style,
scan_file,
)
from optional_deps import MissingDepError
from optional_deps import MissingDepError, run_doctor
from render import build_slides, render_pdf
from shared import (
DIAGRAMS,
@@ -206,6 +207,10 @@ def main(argv: list[str]) -> int:
return _error_unexpected(args[1])
target = args[1] if len(args) > 1 else None
return verify_all(target)
if args[0] == "--doctor":
if len(args) > 1:
return _error_unexpected(args[1])
return run_doctor()
# Path-taking check subcommands share one guard + dispatch table.
path_checks = {
"--check-orphans": check_orphans,
+240 -19
View File
@@ -2,7 +2,7 @@
The content IR is a JSON file the agent writes before filling a template:
{"type": "resume", "lang": "cn", "content": {...}}
{"type": "resume", "lang": "cn", "brief": {...}, "content": {...}}
`type` selects a contract from `references/schemas/<type>.json` (a lean JSON
Schema subset). Validation happens before layout, so structural defects
@@ -23,7 +23,15 @@ from pathlib import Path
from urllib.parse import unquote, urlsplit
from checks import css_hidden_selectors, visible_html_text
from shared import ROOT, SCHEMAS_DIR, content_schema_types, rel_to_root
from shared import (
HTML_TEMPLATES,
PPTX_TEMPLATES,
ROOT,
SCHEMAS_DIR,
SCREEN_TEMPLATES,
content_schema_types,
rel_to_root,
)
# Strings longer than this are treated as prose the agent may rephrase while
# filling; only shorter atomic values (names, metrics, dates) must survive
@@ -33,6 +41,8 @@ MAX_COVERAGE_VALUES = 5000
MAX_COVERAGE_ISSUES = 200
_CJK = re.compile(r"[\u3000-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]")
_LANG_TAG = re.compile(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*")
_ENVELOPE_FIELDS = {"type", "lang", "brief", "content"}
_TYPE_CHECKS: dict[str, type | tuple[type, ...]] = {
"object": dict,
@@ -43,6 +53,58 @@ _TYPE_CHECKS: dict[str, type | tuple[type, ...]] = {
"boolean": bool,
}
BRIEF_SCHEMA = {
"type": "object",
"required": ["audience", "job", "template", "formats", "acceptance_checks"],
"additionalProperties": False,
"properties": {
"audience": {"type": "string", "minLength": 1, "maxLength": 240},
"job": {"type": "string", "minLength": 1, "maxLength": 240},
"template": {"type": "string", "minLength": 1, "maxLength": 80},
"formats": {
"type": "array", "minItems": 1, "maxItems": 4,
"items": {"type": "string", "enum": ["html", "pdf", "pptx", "png"]},
},
"page_target": {"type": "integer", "minimum": 1, "maximum": 200},
"length_target": {"type": "string", "minLength": 1, "maxLength": 120},
"narrative": {"type": "string", "minLength": 1, "maxLength": 800},
"required_facts": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
"required_assets": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 500},
},
"acceptance_checks": {
"type": "array", "minItems": 1, "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
"target": {
"type": "object", "additionalProperties": False,
"properties": {
"surface": {"type": "string", "minLength": 1, "maxLength": 120},
"page": {"type": "integer", "minimum": 1, "maximum": 200},
"viewport": {"type": "string", "minLength": 1, "maxLength": 80},
"state": {"type": "string", "minLength": 1, "maxLength": 120},
"element": {"type": "string", "minLength": 1, "maxLength": 160},
},
},
"preserve": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
"evidence": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 500},
},
"explicit_deviations": {
"type": "array", "maxItems": 100,
"items": {"type": "string", "minLength": 1, "maxLength": 240},
},
},
}
class _HtmlAttributeParser(HTMLParser):
"""Collect resource-bearing HTML attributes for asset coverage checks."""
@@ -131,7 +193,8 @@ def validate_node(value, schema: dict, path: str = "content") -> list[str]:
"""Validate `value` against a JSON Schema subset; return issue strings.
Supported keywords: type, required, properties, additionalProperties
(False only), items, minItems, maxItems, minLength, maxLength, enum.
(False only), items, minItems, maxItems, minLength, maxLength, minimum,
maximum, enum.
`$comment` and `description` carry authoring guidance and are ignored.
"""
issues: list[str] = []
@@ -153,6 +216,12 @@ def validate_node(value, schema: dict, path: str = "content") -> list[str]:
if "maxLength" in schema and n > schema["maxLength"]:
issues.append(f"{path}: too long ({n} > {schema['maxLength']} chars)")
elif isinstance(value, (int, float)) and not isinstance(value, bool):
if "minimum" in schema and value < schema["minimum"]:
issues.append(f"{path}: too small ({value} < {schema['minimum']})")
if "maximum" in schema and value > schema["maximum"]:
issues.append(f"{path}: too large ({value} > {schema['maximum']})")
elif isinstance(value, list):
n = len(value)
if "minItems" in schema and n < schema["minItems"]:
@@ -180,18 +249,142 @@ def validate_node(value, schema: dict, path: str = "content") -> list[str]:
return issues
def _brief_contract_issues(
brief: dict,
doc_type: str,
lang: str | None = None,
) -> list[str]:
"""Cross-check the artifact brief against the selected document contract."""
issues: list[str] = []
template_names = {*HTML_TEMPLATES, *PPTX_TEMPLATES, *SCREEN_TEMPLATES}
template = brief.get("template")
if not isinstance(template, str):
return issues
allowed_templates = {
name for name in template_names
if name == doc_type or name.startswith(f"{doc_type}-")
}
allowed_templates.add(doc_type)
if template not in allowed_templates:
allowed = ", ".join(sorted(allowed_templates)) or doc_type
issues.append(
f"brief.template: {template!r} does not match content type "
f"{doc_type!r} (allowed: {allowed})"
)
return issues
formats = brief.get("formats")
if isinstance(formats, list):
if doc_type == "slides":
# A slide deliverable combines the WeasyPrint source/PDF with the
# editable python-pptx fallback, even though `template` names the
# primary authoring path.
supported_formats = {"html", "pdf", "pptx", "png"}
elif template in HTML_TEMPLATES:
supported_formats = {"html", "pdf", "png"}
elif template in SCREEN_TEMPLATES:
supported_formats = {"html", "png"}
elif template in PPTX_TEMPLATES:
supported_formats = {"pptx"}
else:
supported_formats = set()
unsupported = sorted(
value for value in formats
if isinstance(value, str) and value not in supported_formats
)
if unsupported:
issues.append(
f"brief.formats: template {template!r} does not support "
f"{', '.join(unsupported)} (allowed: "
f"{', '.join(sorted(supported_formats)) or 'none'})"
)
if isinstance(lang, str):
lang_key = lang.casefold()
if lang_key == "cn" or lang_key.startswith("zh-") or lang_key == "zh":
requested_family = "cn"
elif lang_key == "en" or lang_key.startswith("en-"):
requested_family = "en"
elif lang_key == "ko" or lang_key.startswith("ko-"):
requested_family = "ko"
else:
requested_family = None
template_family = (
"en" if template.endswith("-en")
else "ko" if template.endswith("-ko")
else "cn"
)
korean_pptx_fallback = (
template == "slides-en"
and requested_family == "ko"
and isinstance(formats, list)
and "pptx" in formats
)
if (
requested_family is not None
and requested_family != template_family
and not korean_pptx_fallback
):
issues.append(
f"brief.template: {template!r} is the {template_family} variant "
f"and does not match language {lang!r}"
)
page_target = brief.get("page_target")
print_spec = HTML_TEMPLATES.get(template) or HTML_TEMPLATES.get(doc_type)
max_pages = print_spec.build_max_pages if print_spec is not None else 0
if (
doc_type == "resume"
and isinstance(page_target, int)
and not isinstance(page_target, bool)
and page_target != 2
):
issues.append(
f"brief.page_target: resume templates require exactly 2 pages, got {page_target}"
)
elif (
isinstance(page_target, int)
and not isinstance(page_target, bool)
and max_pages > 0
and page_target > max_pages
):
issues.append(
f"brief.page_target: {page_target} exceeds template "
f"{template!r} maximum {max_pages}"
)
return issues
def validate_content_file(data) -> tuple[str | None, list[str]]:
"""Validate a parsed content IR envelope. Returns (doc_type, issues)."""
if not isinstance(data, dict):
return None, ["content file must be a JSON object"]
issues = [
f"top-level: unknown field {key!r}"
for key in sorted(set(data) - _ENVELOPE_FIELDS)
]
lang = data.get("lang")
if not isinstance(lang, str) or not _LANG_TAG.fullmatch(lang):
issues.append(
"top-level 'lang' must be a language tag such as cn, en, ko, or zh-TW"
)
doc_type = data.get("type")
if not isinstance(doc_type, str) or doc_type not in content_schema_types():
known = ", ".join(content_schema_types()) or "none"
return None, [f"top-level 'type' must be one of: {known}"]
issues.append(f"top-level 'type' must be one of: {known}")
return None, issues
body = data.get("content")
if not isinstance(body, dict):
return doc_type, ["top-level 'content' must be an object"]
return doc_type, validate_node(body, load_schema(doc_type))
issues.append("top-level 'content' must be an object")
return doc_type, issues
brief = data.get("brief")
if brief is not None:
issues.extend(validate_node(brief, BRIEF_SCHEMA, "brief"))
if isinstance(brief, dict):
issues.extend(_brief_contract_issues(brief, doc_type, lang))
issues.extend(validate_node(body, load_schema(doc_type)))
return doc_type, issues
# ---------- coverage: content values must survive into the filled HTML ----------
@@ -228,10 +421,19 @@ def html_resource_attributes(raw: str) -> set[str]:
def _asset_present(needle: str, attributes: set[str]) -> bool:
expected = unquote(urlsplit(needle).path).lstrip("./")
expected_url = urlsplit(needle)
expected = unquote(expected_url.path).lstrip("./")
for raw in attributes:
actual = unquote(urlsplit(raw).path).lstrip("./")
if actual == expected or actual.endswith(f"/{expected}"):
actual_url = urlsplit(raw)
actual = unquote(actual_url.path).lstrip("./")
if expected_url.scheme or expected_url.netloc:
if (
actual_url.scheme.casefold() == expected_url.scheme.casefold()
and actual_url.netloc.casefold() == expected_url.netloc.casefold()
and actual == expected
):
return True
elif actual == expected or actual.endswith(f"/{expected}"):
return True
return False
@@ -248,9 +450,12 @@ def _leaf_values(node, path: str):
def coverage_issues(
content: dict,
content: dict | list,
html_text: str,
html_attributes: set[str] | None = None,
*,
root_path: str = "content",
force_assets: bool = False,
) -> tuple[list[str], int, int]:
"""Return (issues, checked, skipped) for content-to-HTML coverage.
@@ -262,7 +467,7 @@ def coverage_issues(
issues: list[str] = []
checked = skipped = 0
for index, (path, value) in enumerate(_leaf_values(content, "content")):
for index, (path, value) in enumerate(_leaf_values(content, root_path)):
if index >= MAX_COVERAGE_VALUES:
issues.append(f"content: too many atomic values to check (limit {MAX_COVERAGE_VALUES})")
break
@@ -278,18 +483,22 @@ def coverage_issues(
if not needle:
continue
if isinstance(value, str):
if len(needle) > COVERAGE_MAX_LEN:
skipped += 1
continue
# Asset paths are consumed by attributes, not visible text. Direct
# text-only callers may omit the attribute set; the real CLI always
# provides it and therefore proves required images were embedded.
if re.search(r"\.image(s\[\d+\])?$", path) or re.search(r"\.(png|jpe?g|svg|webp)$", needle, re.I):
is_asset = force_assets or bool(
re.search(r"\.image(s\[\d+\])?$", path)
or re.search(r"\.(png|jpe?g|svg|webp)$", needle, re.I)
)
# Asset paths are consumed by attributes, not visible text. Check
# them before the prose-length cutoff: a long URL is still a
# required resource, not prose that may be rephrased.
if is_asset:
if html_attributes is not None:
checked += 1
if not _asset_present(needle, html_attributes):
issues.append(f"{path}: asset not found in document attributes: {needle!r}")
continue
if len(needle) > COVERAGE_MAX_LEN:
skipped += 1
continue
checked += 1
cjk = bool(_CJK.search(needle))
normalized = _normalize(needle, cjk=cjk)
@@ -352,9 +561,21 @@ def check_content(paths: list[str]) -> int:
return 2
html_raw = html_path.read_text(encoding="utf-8", errors="replace")
html_text = visible_html_text(html_raw)
html_attributes = html_resource_attributes(html_raw)
missing, checked, skipped = coverage_issues(
data["content"], html_text, html_resource_attributes(html_raw)
data["content"], html_text, html_attributes
)
required_assets = (data.get("brief") or {}).get("required_assets", [])
if required_assets:
asset_missing, asset_checked, _ = coverage_issues(
required_assets,
html_text,
html_attributes,
root_path="brief.required_assets",
force_assets=True,
)
missing.extend(asset_missing)
checked += asset_checked
if missing:
print(f"ERROR: {html_rel}: {len(missing)} content value(s) missing from document")
for issue in missing:
+108 -24
View File
@@ -14,8 +14,9 @@ Register with an MCP client, for example:
Tools:
kami_templates discover templates, diagram library, content schema types
kami_doctor report installed render, check, PPTX, and font capabilities
kami_render render a filled HTML file to PDF (WeasyPrint + highlight)
kami_check run the matching deterministic checks for a file
kami_check run deterministic checks with stable findings and coverage
kami_screenshot rasterize a PDF to page PNGs plus the review checklist
Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout (MCP stdio).
@@ -37,7 +38,7 @@ from checks import (
check_placeholders,
)
from content import check_content
from optional_deps import MissingDepError
from optional_deps import MissingDepError, doctor_report
from render import render_pdf
from shared import (
DIAGRAM_TEMPLATES,
@@ -48,10 +49,43 @@ from shared import (
kami_version,
)
from visual import MAX_DPI, MIN_DPI, REVIEW_CHECKLIST, render_pages
from verify import check_fonts
PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18"}
CHECK_RULESET_VERSION = 1
CHECK_REGISTRY = {
"html.placeholders": {
"scope": "html", "severity": "error", "required_engine": "stdlib",
"explanation": "Completed HTML must not expose unresolved template placeholders.",
},
"html.markdown-residue": {
"scope": "html", "severity": "error", "required_engine": "stdlib",
"explanation": "Rendered audience copy must not expose raw Markdown syntax.",
},
"content.contract": {
"scope": "content-ir", "severity": "error", "required_engine": "stdlib",
"explanation": "Content IR must satisfy its document schema and optional artifact brief.",
},
"content.coverage": {
"scope": "html+content-ir", "severity": "error", "required_engine": "stdlib",
"explanation": "Every atomic fact and required asset in content IR must survive into the document.",
},
"pdf.markdown-residue": {
"scope": "pdf", "severity": "error", "required_engine": "pypdf",
"explanation": "Extracted PDF text must not expose raw Markdown syntax.",
},
"pdf.orphans": {
"scope": "pdf", "severity": "warning", "required_engine": "pymupdf",
"explanation": "Rendered text blocks must not end in short orphan lines.",
},
"pdf.density": {
"scope": "pdf", "severity": "warning", "required_engine": "pymupdf",
"explanation": "Rendered pages must not carry excessive trailing whitespace.",
},
}
TOOLS = [
{
"name": "kami_templates",
@@ -62,6 +96,16 @@ TOOLS = [
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "kami_doctor",
"description": (
"Report whether this installed Kami runtime can render PDFs, run "
"visual checks, build editable PPTX fallback decks, and resolve "
"the expected font families. Read-only; missing capabilities are "
"reported explicitly rather than treated as clean checks."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "kami_render",
"description": (
@@ -85,7 +129,8 @@ TOOLS = [
"Run Kami's deterministic checks for a file. HTML: placeholders + "
"markdown residue (+ content coverage when a content IR JSON is "
"given). PDF: markdown residue + orphans + density. JSON: content "
"IR schema validation. Returns the full report text."
"IR schema validation. Returns the legacy report plus stable rule "
"IDs, findings, coverage status, and explicit degraded checks."
),
"inputSchema": {
"type": "object",
@@ -101,8 +146,8 @@ TOOLS = [
"description": (
"Rasterize every PDF page to PNG for a perceptual review pass. "
"Writes or replaces <pdf-stem>-visual/page-*.png, then returns the "
"image paths and fixed review checklist; view every image against "
"the checklist before shipping."
"image paths, deterministic CJK font verdict, and fixed review "
"checklist; view every image against the checklist before shipping."
),
"inputSchema": {
"type": "object",
@@ -146,6 +191,10 @@ def tool_templates(_args: dict) -> dict:
}
def tool_doctor(_args: dict) -> dict:
return doctor_report()
def tool_render(args: dict) -> dict:
html_path = _resolve(args["html"])
if not html_path.exists():
@@ -178,34 +227,57 @@ def _run_check(fn, argv: list[str]) -> tuple[int, str]:
return code, buffer.getvalue().rstrip()
def _check_plan(path: Path, content: str | None) -> list[tuple[str, object, list[str]]]:
suffix = path.suffix.lower()
if suffix in {".html", ".htm"}:
checks: list[tuple[str, object, list[str]]] = [
("html.placeholders", check_placeholders, [str(path)]),
("html.markdown-residue", check_markdown_residue, [str(path)]),
]
if content:
checks.append((
"content.coverage", check_content,
[str(_resolve(content)), str(path)],
))
return checks
if suffix == ".pdf":
return [
("pdf.markdown-residue", check_markdown_residue, [str(path)]),
("pdf.orphans", check_orphans, [str(path)]),
("pdf.density", check_density, [str(path)]),
]
if suffix == ".json":
return [("content.contract", check_content, [str(path)])]
raise ValueError(f"unsupported file type: {path.name} (expected .html, .pdf, or .json)")
def tool_check(args: dict) -> dict:
path = _resolve(args["path"])
if not path.exists():
raise FileNotFoundError(f"file not found: {path}")
suffix = path.suffix.lower()
reports: list[str] = []
coverage: list[dict] = []
findings: list[dict] = []
worst = 0
if suffix in {".html", ".htm"}:
checks = [(check_placeholders, [str(path)]), (check_markdown_residue, [str(path)])]
if args.get("content"):
checks.append((check_content, [str(_resolve(args["content"])), str(path)]))
elif suffix == ".pdf":
checks = [
(check_markdown_residue, [str(path)]),
(check_orphans, [str(path)]),
(check_density, [str(path)]),
]
elif suffix == ".json":
checks = [(check_content, [str(path)])]
else:
raise ValueError(f"unsupported file type: {path.name} (expected .html, .pdf, or .json)")
for fn, argv in checks:
for rule_id, fn, argv in _check_plan(path, args.get("content")):
code, report = _run_check(fn, argv)
worst = max(worst, code)
reports.append(report)
return {"exit_code": worst, "ok": worst == 0, "report": "\n".join(reports)}
status = "passed" if code == 0 else ("failed" if code == 1 else "degraded")
rule = {"id": rule_id, **CHECK_REGISTRY[rule_id]}
coverage.append({**rule, "status": status, "exit_code": code})
if status != "passed":
findings.append({**rule, "status": status, "evidence": report})
return {
"ruleset_version": CHECK_RULESET_VERSION,
"exit_code": worst,
"ok": worst == 0,
"degraded": any(item["status"] == "degraded" for item in coverage),
"findings": findings,
"coverage": coverage,
"report": "\n".join(reports),
}
def tool_screenshot(args: dict) -> dict:
@@ -216,15 +288,27 @@ def tool_screenshot(args: dict) -> dict:
if dpi is not None and (isinstance(dpi, bool) or not isinstance(dpi, int)):
raise ValueError("dpi must be an integer")
pages = render_pages(pdf, dpi=dpi)
font_code, font_report = _run_check(check_fonts, [str(pdf)])
return {
"rasterized": True,
"review_pending": True,
"pages": [str(p) for p in pages],
"font_check": {
"exit_code": font_code,
"ok": font_code == 0,
"report": font_report,
},
"review_checklist": list(REVIEW_CHECKLIST),
"instruction": "View every page image against the checklist before shipping.",
"instruction": (
"Require font_check.ok, then view every page image against the "
"checklist. Settle the perceptual review outside this tool before shipping."
),
}
TOOL_HANDLERS = {
"kami_templates": tool_templates,
"kami_doctor": tool_doctor,
"kami_render": tool_render,
"kami_check": tool_check,
"kami_screenshot": tool_screenshot,
+178 -1
View File
@@ -7,9 +7,13 @@ is configured once at the import call site.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import shutil
import subprocess
import sys
from shared import configure_weasyprint_runtime
from shared import ROOT, configure_weasyprint_runtime, kami_version
# On Linux, WeasyPrint links against cairo / pango / harfbuzz at runtime; a bare
# `pip install weasyprint` succeeds but then fails to load with a cryptic
@@ -72,3 +76,176 @@ def require_pymupdf():
raise MissingDepError(
f"missing PyMuPDF. {PYMUPDF_INSTALL_HINT}"
) from exc
def _distribution_version(name: str) -> str | None:
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return None
def _probe_dependency(
name: str,
distribution: str,
purpose: str,
loader,
*,
required: bool,
) -> dict:
try:
loader()
except MissingDepError as exc:
return {
"name": name,
"status": "missing",
"required": required,
"purpose": purpose,
"version": _distribution_version(distribution),
"detail": str(exc),
}
except Exception as exc:
return {
"name": name,
"status": "degraded",
"required": required,
"purpose": purpose,
"version": _distribution_version(distribution),
"detail": f"import failed: {type(exc).__name__}: {exc}",
}
return {
"name": name,
"status": "available",
"required": required,
"purpose": purpose,
"version": _distribution_version(distribution),
}
def _probe_module(name: str):
return importlib.import_module(name)
def _probe_font(family: str, bundled_names: tuple[str, ...], purpose: str) -> dict:
font_dir = ROOT / "assets" / "fonts"
bundled = [name for name in bundled_names if (font_dir / name).is_file()]
matched = None
matcher = shutil.which("fc-match")
if matcher:
try:
result = subprocess.run(
[matcher, "-f", "%{family}\n", family],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode == 0:
matched = (result.stdout.splitlines() or [""])[0].strip() or None
except (OSError, subprocess.TimeoutExpired):
matched = None
resolved = bool(matched and family.casefold() in matched.casefold())
status = "available" if bundled or resolved else "unconfirmed"
return {
"name": family,
"status": status,
"purpose": purpose,
"bundled": bundled,
"fontconfig_match": matched,
"detail": (
None if status == "available"
else "not bundled and fontconfig did not confirm the requested family"
),
}
def doctor_report() -> dict:
"""Return installed render, verification, and font capabilities.
Required dependencies define whether the normal HTML -> PDF -> visual
verification path is ready. Editable PPTX and language-specific font
families are reported separately so an unavailable optional path cannot be
mistaken for a clean check or for a broken core install.
"""
dependencies = [
_probe_dependency(
"weasyprint", "weasyprint", "HTML to PDF rendering",
require_weasyprint_html, required=True,
),
_probe_dependency(
"pypdf", "pypdf", "PDF text, metadata, and page checks",
require_pypdf_reader, required=True,
),
_probe_dependency(
"pymupdf", "PyMuPDF", "page screenshots, density, and orphan checks",
require_pymupdf, required=True,
),
_probe_dependency(
"python-pptx", "python-pptx", "editable PPTX fallback",
lambda: _probe_module("pptx"), required=False,
),
_probe_dependency(
"pygments", "Pygments", "build-time code highlighting",
lambda: _probe_module("pygments"), required=False,
),
]
fonts = [
_probe_font(
"JetBrains Mono", ("JetBrainsMono.woff2",),
"code and metadata labels",
),
_probe_font(
"TsangerJinKai02", ("TsangerJinKai02-W04.ttf", "TsangerJinKai02-W05.ttf"),
"primary Chinese editorial serif",
),
_probe_font(
"Source Han Serif KR",
("SourceHanSerifKR-Regular.otf", "SourceHanSerifKR-Medium.otf"),
"Korean editorial serif fallback",
),
]
required_ready = all(
item["status"] == "available"
for item in dependencies
if item["required"]
)
return {
"version": kami_version(),
"ok": required_ready,
"dependencies": dependencies,
"fonts": fonts,
"capabilities": {
"pdf_render": all(
item["status"] == "available"
for item in dependencies
if item["name"] in {"weasyprint", "pypdf"}
),
"pdf_visual_review": all(
item["status"] == "available"
for item in dependencies
if item["name"] in {"pypdf", "pymupdf"}
),
"editable_pptx": next(
item["status"] == "available"
for item in dependencies
if item["name"] == "python-pptx"
),
},
}
def run_doctor() -> int:
report = doctor_report()
print(f"Kami doctor {report['version']}")
for item in report["dependencies"]:
label = "OK" if item["status"] == "available" else item["status"].upper()
version = f" {item['version']}" if item.get("version") else ""
print(f"{label}: {item['name']}{version}: {item['purpose']}")
if item.get("detail"):
print(f" {item['detail']}")
for item in report["fonts"]:
label = "OK" if item["status"] == "available" else item["status"].upper()
source = "bundled" if item["bundled"] else (item.get("fontconfig_match") or "not confirmed")
print(f"{label}: font {item['name']}: {source}")
print("OK: core render and visual verification ready" if report["ok"] else "ERROR: core capability missing or degraded")
return 0 if report["ok"] else 1
+1 -1
View File
@@ -60,7 +60,7 @@ awk '
# allowlist above or named in the repo-only exclusion below. Without this, a
# new runtime module that build.py imports would silently miss the zip and
# the installed skill would ImportError while every local check stays green.
SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|package-skill\.sh|tests/)'
SCRIPTS_REPO_ONLY_RE='^scripts/(build_metadata\.py|draft-release-notes\.py|release_gate\.py|package-skill\.sh|tests/)'
unaccounted="$(grep '^scripts/' "$MANIFEST" \
| grep -Ev "$SCRIPTS_REPO_ONLY_RE" \
| grep -Fvx -f <(grep '^scripts/' "$FILTERED_MANIFEST" || true) || true)"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Fail-closed identity and artifact checks for versioned releases."""
from __future__ import annotations
import argparse
import hashlib
import re
import subprocess
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
def release_identity_issues(
tag: str,
version: str,
head_sha: str,
tag_sha: str,
) -> list[str]:
"""Return identity mismatches that make a versioned release unsafe."""
issues: list[str] = []
if not VERSION_RE.fullmatch(version):
issues.append(f"VERSION must be x.y.z, got {version!r}")
expected_tag = f"V{version}"
if tag != expected_tag:
issues.append(f"tag {tag!r} does not match VERSION ({expected_tag})")
if head_sha != tag_sha:
issues.append(f"tag commit {tag_sha} does not match checkout HEAD {head_sha}")
return issues
def _zip_payloads(path: Path) -> dict[str, str | None]:
"""Return the exact ZIP manifest and payloads, rejecting ambiguous names."""
with zipfile.ZipFile(path) as archive:
payloads: dict[str, str | None] = {}
for info in archive.infolist():
name = info.filename
if name in payloads:
raise ValueError(f"duplicate ZIP entry: {name}")
payloads[name] = (
None if info.is_dir()
else hashlib.sha256(archive.read(info)).hexdigest()
)
return dict(sorted(payloads.items()))
def archive_payload_issues(tracked: Path, candidate: Path) -> list[str]:
"""Compare release archives by entry names and uncompressed payload bytes."""
if not tracked.is_file():
return [f"tracked archive not found: {tracked}"]
if not candidate.is_file():
return [f"candidate archive not found: {candidate}"]
try:
tracked_payloads = _zip_payloads(tracked)
candidate_payloads = _zip_payloads(candidate)
except (OSError, ValueError, zipfile.BadZipFile) as exc:
return [f"could not read release archive: {exc}"]
issues: list[str] = []
tracked_names = set(tracked_payloads)
candidate_names = set(candidate_payloads)
for name in sorted(tracked_names - candidate_names):
issues.append(f"candidate archive is missing {name}")
for name in sorted(candidate_names - tracked_names):
issues.append(f"candidate archive has extra entry {name}")
for name in sorted(tracked_names & candidate_names):
if tracked_payloads[name] != candidate_payloads[name]:
issues.append(f"candidate payload differs: {name}")
return issues
def _git(*args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout.strip()
def resolve_tag_commit(tag: str) -> str:
"""Resolve only the tag namespace, never a same-named branch or revision."""
return _git("rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", required=True, help="version tag being published")
parser.add_argument("--tracked-archive", type=Path)
parser.add_argument("--candidate-archive", type=Path)
args = parser.parse_args(argv)
try:
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
head_sha = _git("rev-parse", "HEAD")
tag_sha = resolve_tag_commit(args.tag)
except (OSError, RuntimeError) as exc:
print(f"ERROR: release identity could not be resolved: {exc}")
return 2
issues = release_identity_issues(args.tag, version, head_sha, tag_sha)
if bool(args.tracked_archive) != bool(args.candidate_archive):
issues.append("provide both --tracked-archive and --candidate-archive")
elif args.tracked_archive and args.candidate_archive:
issues.extend(archive_payload_issues(args.tracked_archive, args.candidate_archive))
if issues:
for issue in issues:
print(f"ERROR: {issue}")
return 1
print(f"OK: release identity matches {args.tag} at {head_sha}")
if args.tracked_archive:
print("OK: candidate archive payloads match tracked dist/kami.zip")
return 0
if __name__ == "__main__":
sys.exit(main())
+16 -4
View File
@@ -13,6 +13,7 @@ import functools
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from highlight import highlight_code_blocks
@@ -108,11 +109,22 @@ def render_pdf(src: Path, out: Path) -> int:
HTML = require_weasyprint_html()
PdfReader = require_pypdf_reader()
html_text = highlight_code_blocks(src.read_text(encoding="utf-8"))
out.parent.mkdir(parents=True, exist_ok=True)
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out))
set_pdf_metadata(out, author=infer_author())
return len(PdfReader(str(out)).pages)
html_text = highlight_code_blocks(src.read_text(encoding="utf-8"))
# Build and validate beside the destination, then atomically replace it.
# Metadata stamping and the final page read can still fail after WeasyPrint
# succeeds; writing straight to `out` would destroy the last good artifact
# before the caller learns that the render failed.
with tempfile.TemporaryDirectory(
dir=out.parent,
prefix=f".{out.name}-",
) as staging_dir:
candidate = Path(staging_dir) / out.name
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(candidate))
set_pdf_metadata(candidate, author=infer_author())
page_count = len(PdfReader(str(candidate)).pages)
os.replace(candidate, out)
return page_count
def build_slides(name: str = "slides") -> bool:
+492 -8
View File
@@ -20,6 +20,7 @@ import shutil
import subprocess
import sys
import tempfile
import warnings
import zipfile
from pathlib import Path
@@ -100,6 +101,7 @@ from verify import ( # noqa: E402
_PASS = 0
_FAIL = 0
_SKIP = 0
def check(name: str, predicate: bool, detail: str = "") -> None:
@@ -112,6 +114,17 @@ def check(name: str, predicate: bool, detail: str = "") -> None:
print(f"ERROR: {name}{(' - ' + detail) if detail else ''}")
def skip(name: str, detail: str = "", *, ci_required: bool = False) -> None:
"""Record an unavailable optional-dependency test without calling it a pass."""
global _SKIP, _FAIL
_SKIP += 1
if ci_required and os.environ.get("CI"):
_FAIL += 1
print(f"ERROR: required CI test skipped: {name}{(' - ' + detail) if detail else ''}")
else:
print(f"SKIP: {name}{(' - ' + detail) if detail else ''}")
def write_temp_html(body: str, suffix: str = "-en.html") -> Path:
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8")
f.write(body)
@@ -755,8 +768,9 @@ def test_density_scans_the_only_page_of_a_single_page_pdf() -> None:
"""
try:
fitz = require_pymupdf()
except MissingDepError:
return # PyMuPDF absent (the lint-and-test CI job); density suite skipped
except MissingDepError as exc:
skip("single-page density regression", str(exc), ci_required=True)
return
with tempfile.TemporaryDirectory() as tmp:
single = Path(tmp) / "one-page.pdf"
@@ -783,6 +797,24 @@ def test_density_scans_the_only_page_of_a_single_page_pdf() -> None:
f"scan: {explicit_scan} (fixture leaves ~64% of the page empty)")
def test_ci_required_skip_is_a_failure_not_a_pass() -> None:
"""A missing heavy dependency in CI must turn the suite red."""
global _FAIL, _SKIP
original_fail, original_skip = _FAIL, _SKIP
original_ci = os.environ.get("CI")
try:
os.environ["CI"] = "1"
silently(skip, "negative-control fixture", ci_required=True)
rejected = _FAIL == original_fail + 1 and _SKIP == original_skip + 1
finally:
_FAIL, _SKIP = original_fail, original_skip
if original_ci is None:
os.environ.pop("CI", None)
else:
os.environ["CI"] = original_ci
check("CI-required skip increments failure and skip counters", rejected)
def test_chinese_slides_mono_has_cjk_fallback() -> None:
"""Slide labels may mix mono Latin and CJK; the mono stack needs CJK fallback."""
text = (TEMPLATES / "slides-weasy.html").read_text(encoding="utf-8")
@@ -1480,6 +1512,117 @@ def test_highlight_without_pygments_dependency() -> None:
f"warning: {warning.getvalue()}")
def test_render_pdf_preserves_last_good_output_on_post_render_failure() -> None:
"""A failed metadata/page validation must not replace the prior PDF."""
import render as render_mod
class FakeHTML:
def __init__(self, **_kwargs):
pass
def write_pdf(self, path: str) -> None:
Path(path).write_bytes(b"new-candidate")
original_html = render_mod.require_weasyprint_html
original_reader = render_mod.require_pypdf_reader
original_metadata = render_mod.set_pdf_metadata
try:
render_mod.require_weasyprint_html = lambda: FakeHTML
render_mod.require_pypdf_reader = lambda: object
def fail_metadata(*_args, **_kwargs) -> None:
raise RuntimeError("injected metadata failure")
render_mod.set_pdf_metadata = fail_metadata
with tempfile.TemporaryDirectory() as d:
root = Path(d)
src = root / "source.html"
out = root / "output.pdf"
src.write_text("<html><body>candidate</body></html>", encoding="utf-8")
out.write_bytes(b"last-good")
try:
render_mod.render_pdf(src, out)
except RuntimeError as exc:
failed = "metadata failure" in str(exc)
else:
failed = False
staged = list(root.glob(".output.pdf-*"))
check("render failure preserves the last good PDF",
failed and out.read_bytes() == b"last-good",
f"failed={failed} bytes={out.read_bytes()!r}")
check("render failure cleans staged candidates", staged == [], str(staged))
finally:
render_mod.require_weasyprint_html = original_html
render_mod.require_pypdf_reader = original_reader
render_mod.set_pdf_metadata = original_metadata
def test_release_gate_rejects_identity_mismatches() -> None:
from release_gate import release_identity_issues
good = release_identity_issues("V1.2.3", "1.2.3", "same", "same")
wrong_tag = release_identity_issues("V1.2.4", "1.2.3", "same", "same")
wrong_sha = release_identity_issues("V1.2.3", "1.2.3", "head", "tag")
check("release identity accepts an exact tag, version, and SHA", good == [], str(good))
check("release identity rejects a tag/version mismatch",
any("does not match VERSION" in issue for issue in wrong_tag), str(wrong_tag))
check("release identity rejects a tag/checkout SHA mismatch",
any("does not match checkout HEAD" in issue for issue in wrong_sha), str(wrong_sha))
def test_release_gate_resolves_only_the_tag_namespace() -> None:
import release_gate as release_gate_mod
calls = []
original_git = release_gate_mod._git
try:
release_gate_mod._git = lambda *args: calls.append(args) or "tag-sha"
resolved = release_gate_mod.resolve_tag_commit("V1.2.3")
finally:
release_gate_mod._git = original_git
check("release gate cannot resolve a same-named branch as a tag",
resolved == "tag-sha"
and calls == [("rev-parse", "--verify", "refs/tags/V1.2.3^{commit}")],
str(calls))
def test_release_gate_compares_zip_payloads_not_container_bytes() -> None:
from release_gate import archive_payload_issues
with tempfile.TemporaryDirectory() as d:
root = Path(d)
tracked = root / "tracked.zip"
equivalent = root / "equivalent.zip"
changed = root / "changed.zip"
duplicated = root / "duplicated.zip"
with zipfile.ZipFile(tracked, "w") as archive:
archive.writestr("kami/VERSION", "1.2.3")
with zipfile.ZipFile(equivalent, "w") as archive:
info = zipfile.ZipInfo("kami/VERSION", date_time=(2026, 1, 2, 3, 4, 6))
archive.writestr(info, "1.2.3")
with zipfile.ZipFile(changed, "w") as archive:
archive.writestr("kami/VERSION", "1.2.4")
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
with zipfile.ZipFile(duplicated, "w") as archive:
archive.writestr("kami/VERSION", "first")
archive.writestr("kami/VERSION", "second")
same_issues = archive_payload_issues(tracked, equivalent)
changed_issues = archive_payload_issues(tracked, changed)
duplicate_issues = archive_payload_issues(duplicated, tracked)
check("release gate ignores ZIP container timestamp differences",
tracked.read_bytes() != equivalent.read_bytes() and same_issues == [],
str(same_issues))
check("release gate rejects changed entry payloads",
changed_issues == ["candidate payload differs: kami/VERSION"],
str(changed_issues))
check("release gate rejects duplicate ZIP entry names",
len(duplicate_issues) == 1
and "duplicate ZIP entry: kami/VERSION" in duplicate_issues[0],
str(duplicate_issues))
def test_marp_themes_token_synced() -> None:
"""Marp theme CSS keeps its :root tokens in sync with tokens.json.
@@ -1740,6 +1883,9 @@ def test_validate_node_flags_structural_defects() -> None:
"too long" in text and "too few items" in text
and "missing required field 'value'" in text and "'hero' not in" in text,
text)
numeric = validate_node(0, {"type": "integer", "minimum": 1, "maximum": 3}, "page")
check("validate_node enforces numeric bounds",
numeric == ["page: too small (0 < 1)"], str(numeric))
def test_coverage_issues_catch_dropped_values() -> None:
@@ -1793,6 +1939,28 @@ def test_check_content_cli_validates_and_covers() -> None:
try:
rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage passes when atomic values present", rc == 0)
payload["brief"] = {
"audience": "Technical collaborator",
"job": "Secure review",
"template": "letter-en",
"formats": ["html", "pdf"],
"required_assets": ["must-appear-logo.svg"],
"acceptance_checks": ["required logo is embedded"],
}
content_path.write_text(json.dumps(payload), encoding="utf-8")
missing_asset_rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage rejects a missing required brief asset",
missing_asset_rc == 1)
html.write_text(
html.read_text(encoding="utf-8").replace(
"</body>", '<img src="must-appear-logo.svg" alt=""></body>'
),
encoding="utf-8",
)
embedded_asset_rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage accepts an embedded required brief asset",
embedded_asset_rc == 0)
finally:
html.unlink()
del payload["content"]["signature"]
@@ -1803,6 +1971,162 @@ def test_check_content_cli_validates_and_covers() -> None:
check("check_content usage error returns 2", rc == 2)
def test_content_ir_rejects_invalid_envelope() -> None:
from content import validate_content_file
body = {
"sender": "Ada Lovelace, London",
"date": "2026-07-13",
"recipient": "Charles Babbage",
"salutation": "Dear Charles,",
"paragraphs": [
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
"My ask is specific: review the table this month so we can test it on the mill.",
],
"signoff": "Sincerely,",
"signature": "Ada",
}
valid_type, valid_issues = validate_content_file({
"type": "letter", "lang": "zh-TW", "content": body,
})
_, invalid_issues = validate_content_file({
"type": "letter", "lang": "not_a_locale", "content": body,
"unexpected": True,
})
check("content IR accepts a strict language-tagged envelope",
valid_type == "letter" and valid_issues == [], str(valid_issues))
check("content IR rejects invalid lang and unknown top-level fields",
any("'lang'" in issue for issue in invalid_issues)
and any("unknown field 'unexpected'" in issue for issue in invalid_issues),
str(invalid_issues))
def test_content_ir_validates_optional_artifact_brief() -> None:
from content import _brief_contract_issues, validate_content_file
body = {
"sender": "Ada Lovelace, London",
"date": "2026-07-13",
"recipient": "Charles Babbage",
"salutation": "Dear Charles,",
"paragraphs": [
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
"My ask is specific: review the table this month so we can test it on the mill.",
],
"signoff": "Sincerely,",
"signature": "Ada",
}
brief = {
"audience": "Technical collaborator",
"job": "Secure a review of the program table",
"template": "letter-en",
"formats": ["html", "pdf"],
"page_target": 1,
"acceptance_checks": ["one page", "specific ask remains visible"],
"target": {"surface": "letter body", "page": 1},
"preserve": ["letterhead", "signature"],
"evidence": ["rendered page 1"],
}
_, valid_issues = validate_content_file({
"type": "letter", "lang": "en", "brief": brief, "content": body,
})
invalid = dict(brief)
invalid["formats"] = ["docx"]
invalid["page_target"] = 0
invalid["mystery"] = True
_, invalid_issues = validate_content_file({
"type": "letter", "lang": "en", "brief": invalid, "content": body,
})
text = "\n".join(invalid_issues)
check("content IR accepts a structured artifact brief", valid_issues == [], str(valid_issues))
check("content IR rejects unknown brief fields, formats, and page bounds",
"brief: unknown field 'mystery'" in text
and "'docx' not in" in text
and "brief.page_target: too small" in text,
text)
conflicting = dict(brief, template="one-pager", page_target=2)
_, conflict_issues = validate_content_file({
"type": "one-pager", "lang": "cn", "brief": conflicting,
"content": {
"title": "A concise product claim",
"subtitle": "A subtitle long enough to explain the intended audience and outcome",
"metrics": [
{"value": "10x", "label": "faster"},
{"value": "99%", "label": "coverage"},
{"value": "2m", "label": "setup"},
],
"argument": [
"A focused paragraph that explains the user problem, the proposed change, and why it matters now in concrete terms."
],
"evidence": ["Measured result", "Observed behavior", "Verified source"],
"next_step": "Review the evidence and approve the next test.",
},
})
check("content IR rejects a page target beyond the template ceiling",
any("exceeds template 'one-pager' maximum 1" in issue for issue in conflict_issues),
str(conflict_issues))
one_page_resume_issues = _brief_contract_issues(
{"template": "resume-en", "formats": ["html", "pdf"], "page_target": 1},
"resume",
"en",
)
check("brief contract enforces the two-page resume hard stop",
any("require exactly 2 pages" in issue for issue in one_page_resume_issues),
str(one_page_resume_issues))
landing_issues = _brief_contract_issues(
{"template": "landing-page", "page_target": 2}, "landing-page"
)
slides_issues = _brief_contract_issues(
{"template": "slides", "page_target": 12}, "slides"
)
editable_slides_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["pptx"], "page_target": 12}, "slides"
)
impossible_resume_issues = _brief_contract_issues(
{"template": "resume", "formats": ["pptx"], "page_target": 2}, "resume"
)
wrong_language_issues = _brief_contract_issues(
{"template": "resume-en", "formats": ["html", "pdf"], "page_target": 2},
"resume",
"ko",
)
korean_pptx_fallback_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf", "pptx"]},
"slides",
"ko",
)
korean_pdf_wrong_variant_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf"]},
"slides",
"ko",
)
check("brief contract accepts screen, generic slide, and editable PPTX keys",
landing_issues == [] and slides_issues == [] and editable_slides_issues == [],
f"landing={landing_issues} slides={slides_issues} "
f"editable={editable_slides_issues}")
check("brief contract rejects a format the selected template cannot produce",
any("does not support pptx" in issue for issue in impossible_resume_issues),
str(impossible_resume_issues))
check("brief contract rejects a known language/template variant mismatch",
any("does not match language 'ko'" in issue for issue in wrong_language_issues),
str(wrong_language_issues))
check("brief contract keeps the documented Korean editable-PPTX fallback",
korean_pptx_fallback_issues == [], str(korean_pptx_fallback_issues))
check("brief contract limits the Korean slides-en fallback to editable PPTX",
any("does not match language 'ko'" in issue
for issue in korean_pdf_wrong_variant_issues),
str(korean_pdf_wrong_variant_issues))
_, legacy_issues = validate_content_file({
"type": "letter", "lang": "en", "content": body,
})
check("content IR keeps pre-brief files valid", legacy_issues == [], str(legacy_issues))
def test_build_cli_dispatches_new_checks() -> None:
rc, out = run_build_args(["--check-content"])
check("build.py --check-content without args is a usage error",
@@ -1810,6 +2134,36 @@ def test_build_cli_dispatches_new_checks() -> None:
rc, out = run_build_args(["--check-visual"])
check("build.py --check-visual without args is a usage error",
rc == 2 and "usage" in out, out.strip()[:120])
rc, out = run_build_args(["--doctor"])
check("build.py --doctor reports capability status",
rc in {0, 1} and "Kami doctor" in out
and "visual verification" in out,
out.strip()[:300])
def test_skill_routes_visual_repairs_and_generated_assets_without_losing_contracts() -> None:
skill = (REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
diagrams = (REPO_ROOT / "references" / "diagrams.md").read_text(encoding="utf-8")
for mode in ("New document", "Content-only", "Visual repair", "Generated asset"):
check(f"SKILL work mode keeps {mode}", mode in skill)
check("visual repair locks target, preserve, evidence, and artifact matrices",
all(term in skill for term in (
"`target`", "`preserve`", "PDF: target page", "Screen: 1280px",
"PPTX: editable source", "Generated asset: target slot",
)),
"visual feedback contract incomplete")
check("image generation routes from observed capability",
"Route from observed capability" in skill
and "Claude, Codex, most coding agents" not in skill,
"host-name capability list still present")
check("illustration brief keeps old visual system and adds semantic anchors",
all(term in diagrams for term in (
"1. Claim:", "2. Placement:", "3. Reference:", "4. Exclusions:",
"5. Canvas:", "6. Accent:", "7. Strokes and icons:",
"8. Labels:", "9. Content spec:",
))
and "After two look-based rejections" in diagrams,
"illustration brief lost a field")
def test_visual_checklist_and_output_dir() -> None:
@@ -1893,6 +2247,24 @@ def test_coverage_checks_asset_attributes() -> None:
)
check("coverage ignores assets in templates and plain links",
len(hidden) == 2, f"issues={hidden} attrs={attrs}")
wrong_origin, _, _ = coverage_issues(
["https://brand.example/logo.svg"],
"",
html_resource_attributes('<img src="https://other.example/logo.svg">'),
root_path="brief.required_assets",
force_assets=True,
)
same_origin, _, _ = coverage_issues(
["https://brand.example/logo.svg?v=approved"],
"",
html_resource_attributes('<img src="https://brand.example/logo.svg?v=cache">'),
root_path="brief.required_assets",
force_assets=True,
)
check("required absolute assets cannot be impersonated by the same path on another host",
len(wrong_origin) == 1, str(wrong_origin))
check("required absolute assets tolerate cache-query changes on the same origin and path",
same_origin == [], str(same_origin))
def test_coverage_caps_adversarial_reports() -> None:
@@ -1920,6 +2292,8 @@ def test_mcp_server_stdio_protocol() -> None:
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "kami_templates", "arguments": {}}},
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "kami_doctor", "arguments": {}}},
{"jsonrpc": "2.0", "id": 5, "method": "tools/call",
"params": {"name": "nope", "arguments": {}}},
]
stdin = "".join(json.dumps(m) + "\n" for m in msgs)
@@ -1938,8 +2312,8 @@ def test_mcp_server_stdio_protocol() -> None:
and init.get("serverInfo", {}).get("name") == "kami",
json.dumps(init)[:200])
tools = [t["name"] for t in replies.get(2, {}).get("result", {}).get("tools", [])]
check("mcp tools/list exposes the four kami tools",
tools == ["kami_templates", "kami_render", "kami_check", "kami_screenshot"],
check("mcp tools/list exposes the five kami tools",
tools == ["kami_templates", "kami_doctor", "kami_render", "kami_check", "kami_screenshot"],
str(tools))
body = replies.get(3, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
payload = json.loads(body)
@@ -1947,9 +2321,49 @@ def test_mcp_server_stdio_protocol() -> None:
set(payload.get("document_templates", {})) == set(HTML_TEMPLATES)
and payload.get("content_schema_types"),
body[:200])
doctor_body = replies.get(4, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
doctor = json.loads(doctor_body)
check("mcp kami_doctor reports dependencies, fonts, and capabilities",
isinstance(doctor.get("ok"), bool)
and len(doctor.get("dependencies", [])) >= 3
and len(doctor.get("fonts", [])) >= 3
and "pdf_visual_review" in doctor.get("capabilities", {}),
doctor_body[:300])
check("mcp unknown tool returns a JSON-RPC error",
"error" in replies.get(4, {}), json.dumps(replies.get(4, {}))[:200])
check("mcp notification produced no reply", len(replies) == 4, str(sorted(replies)))
"error" in replies.get(5, {}), json.dumps(replies.get(5, {}))[:200])
check("mcp notification produced no reply", len(replies) == 5, str(sorted(replies)))
def test_mcp_check_returns_stable_findings_and_coverage() -> None:
from mcp_server import CHECK_REGISTRY, tool_check
with tempfile.TemporaryDirectory() as d:
clean = Path(d) / "clean.html"
broken = Path(d) / "broken.html"
clean.write_text("<html><body><p>Ready</p></body></html>", encoding="utf-8")
broken.write_text("<html><body><p>{{ missing }}</p></body></html>", encoding="utf-8")
clean_result = tool_check({"path": str(clean)})
broken_result = tool_check({"path": str(broken)})
check("MCP check registry carries unique stable rule IDs",
len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
and all({"scope", "severity", "required_engine", "explanation"} <= set(rule)
for rule in CHECK_REGISTRY.values()),
str(CHECK_REGISTRY))
check("MCP clean check returns coverage without findings",
clean_result["ok"] is True
and clean_result["degraded"] is False
and clean_result["findings"] == []
and [item["id"] for item in clean_result["coverage"]]
== ["html.placeholders", "html.markdown-residue"]
and clean_result["report"],
json.dumps(clean_result)[:500])
check("MCP failed check returns a stable finding and legacy report",
broken_result["ok"] is False
and broken_result["findings"][0]["id"] == "html.placeholders"
and broken_result["findings"][0]["status"] == "failed"
and "placeholder" in broken_result["report"].lower(),
json.dumps(broken_result)[:500])
def test_mcp_server_rejects_bad_frames_without_exiting() -> None:
@@ -1978,6 +2392,76 @@ def test_mcp_server_rejects_bad_frames_without_exiting() -> None:
result.stdout[:400])
def test_mcp_all_tools_succeed_over_stdio() -> None:
"""Exercise render, check, and screenshot through the installed protocol path."""
try:
from optional_deps import require_pypdf_reader, require_weasyprint_html
require_weasyprint_html()
require_pypdf_reader()
require_pymupdf()
except MissingDepError as exc:
skip("MCP all-tools stdio success path", str(exc), ci_required=True)
return
script = REPO_ROOT / "scripts" / "mcp_server.py"
with tempfile.TemporaryDirectory() as d:
root = Path(d)
html = root / "source.html"
pdf = root / "output.pdf"
html.write_text(
"<!doctype html><html><head><style>"
"@page{size:A4;margin:20mm}body{font-family:serif}"
"</style></head><body><h1>Kami MCP smoke</h1><p>Rendered.</p></body></html>",
encoding="utf-8",
)
msgs = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18"}},
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "kami_render", "arguments": {
"html": str(html), "out": str(pdf)}}},
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "kami_check", "arguments": {"path": str(html)}}},
{"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {"name": "kami_screenshot", "arguments": {"pdf": str(pdf)}}},
]
result = subprocess.run(
[sys.executable, str(script)],
input="".join(json.dumps(m) + "\n" for m in msgs),
capture_output=True, text=True, cwd=REPO_ROOT, timeout=120,
)
try:
replies = {m.get("id"): m for m in map(json.loads, result.stdout.strip().splitlines())}
payloads = {
reply_id: json.loads(
replies[reply_id]["result"]["content"][0]["text"]
)
for reply_id in (2, 3, 4)
}
except (KeyError, json.JSONDecodeError) as exc:
check("MCP all-tools success path returns JSON results", False,
f"{exc}: {(result.stdout + result.stderr)[:500]}")
return
render_result = payloads[2]
check_result = payloads[3]
screenshot_result = payloads[4]
check("MCP render succeeds over stdio",
result.returncode == 0 and render_result.get("pages") == 1 and pdf.is_file(),
json.dumps(render_result)[:300])
check("MCP check succeeds over stdio",
check_result.get("ok") is True and check_result.get("exit_code") == 0,
json.dumps(check_result)[:300])
page_paths = [Path(path) for path in screenshot_result.get("pages", [])]
check("MCP screenshot returns evidence without a false perceptual verdict",
"ok" not in screenshot_result
and screenshot_result.get("rasterized") is True
and screenshot_result.get("review_pending") is True
and screenshot_result.get("font_check", {}).get("ok") is True
and len(page_paths) == 1 and all(path.is_file() for path in page_paths),
json.dumps(screenshot_result)[:500])
def test_mcp_render_guards_source_and_output_types() -> None:
from mcp_server import tool_render
@@ -2009,7 +2493,7 @@ def test_visual_rejects_empty_pdf_and_bad_dpi() -> None:
from pypdf import PdfWriter
from visual import render_pages
except ImportError:
check("visual empty-PDF guard skipped without pypdf", True)
skip("visual empty-PDF guard", "pypdf unavailable", ci_required=True)
return
with tempfile.TemporaryDirectory() as d:
@@ -2086,7 +2570,7 @@ def main() -> int:
continue
func()
print()
print(f"Passed: {_PASS} | Failed: {_FAIL}")
print(f"Passed: {_PASS} | Skipped: {_SKIP} | Failed: {_FAIL}")
return 0 if _FAIL == 0 else 1
+4
View File
@@ -824,6 +824,10 @@
}
.code .k { color: var(--brand); }
.code .c { color: var(--stone); }
.code.code-wrap {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* List */
ul.dash {