Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ plugin manifests and kept in sync by CI. Never hand-edit it; the category vocabu
## Presentation

- [`playgrounds`](../plugins/playgrounds): One-step access to Anthropic's first-party playground plugin: declares the cross-marketplace dependency, routes playground-shaped requests to the upstream skill when it is installed, emits the install commands when it is not, and carries field-tested prompt recipes, cloud-session delivery guidance, and consumer cautions. Generates nothing itself.
- [`visualization`](../plugins/visualization): On-demand visualization router (visualization:visualize): infers what in the conversation to show, then picks the form (mermaid diagram, markdown table, SVG/CSS chart, ASCII/Unicode art, or a rendered page) and the medium (inline terminal, local HTML file, or published Artifact) from content shape and a configurable preference. Asks only when the target is ambiguous and no form was named. Routes chart craft and artifact design to those capabilities when installed.
- [`visualization`](../plugins/visualization): Visualization skills. visualize picks the form (mermaid diagram, markdown table, SVG/CSS chart, ASCII/Unicode art, or a rendered page) and the medium (terminal, local HTML file, or published Artifact) for what is in the conversation, asking only when the target is ambiguous. present builds a slide deck from the account's claude.ai Slides Artifact type behind a publish gate, with a markdown outline as the record. Chart craft and artifact design route to those capabilities when installed.
- [`writing`](../plugins/writing): Prose a scanning reader can use. /writing:be-concise puts the bottom line first, cuts words the meaning does not need, and keeps structure scannable and tone factual. Bare, it sets a standing posture; given a target, it reshapes it and reports word counts. For tickets, PR descriptions, changelogs, READMEs, and status updates. Never drops a decision, number, ask, error, or warning. Paraphrases NN/g, GOV.UK, US plain-language guidelines, Google and Microsoft style guides, and BLUF.

## Project Management
Expand Down
14 changes: 14 additions & 0 deletions docs/conventions/rendered-views/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
Notable changes to the rendered-views contract. The contract is not
versioned; this log records each change to it.

## Decks use the account's Slides Artifact type, 2026-10-03

- **New section, Artifact types (#5867).** It says when a producer uses a claude.ai Artifact type
instead of the shared builder, finds the type at run time through the Artifact tool's
`quickstart`, falls back to the markdown record when the account has no such type, keeps K2
content to escaped text, and runs the shared publish gate before the type's create call.
- **`visualization:present` is the deck lane and the second `artifact` default.** It is listed
under Emitters through an Artifact type and in Default ladder and its reconciliation.
- **The publish gate is a shared library.** `lib/publish-gate.mjs` holds the credential patterns
and the gate that `review:explain-change` used inline; both lanes carry a generated copy. It
also resolves the trusted medium layers, so a team file can keep a deck local but never publish it.
- **A K2 deck is held to an allowlist**, read by a quote-aware tokenizer that fails closed, and the
create call's title is the one the gate read.

## The Claude-interactive tier opens to builder pages, 2026-10-03

- **`session-bridge` meets rule 9, and the triage board and plan view adopt the tier (#5868).** Every wait
Expand Down
47 changes: 44 additions & 3 deletions docs/conventions/rendered-views/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,16 +357,20 @@ Two sentences reconcile this with the local-first residence decision:
priced fleet sweep deliberately migrates them (tracked as a deferred-work issue).

One new lane is an exception to sentence 1, recorded here: the pull-request digest
lane (`review:explain-change`) ships `medium: artifact` as its default. Its page is
lane (`/review:explain-change`) ships `medium: artifact` as its default. Its page is
built only by the shared builder from a checked-in template, and the artifact stays
private to the reader until they share it. The default publishes only a public
repository's diff with no credential-shaped hunk; any other diff falls back to `file`
and the reader is told to set `medium: artifact` to publish it anyway. An operator who wants the digest local sets
`medium: file` in their personal layer (`~/.claude/rendered-views.md` or the repo
overlay); the cascade below resolves it like any other key.

The deck lane (`/visualization:present`) is the second exception: a deck exists only as an
Artifact made from the account's Slides type, so it publishes behind the same gate (see
Artifact types), and anything the gate keeps local stays the markdown outline.

Rendered views are untracked by default; publishing anywhere else is optional and
configured, never the default, except for the digest's `artifact` default.
configured, never the default, except for the digest's and the deck's `artifact` default.

A plan that depends on sharing or editing a rendered view across accounts or subscriptions
does not assume it works: it checks the live Share dialog first.
Expand All @@ -377,6 +381,40 @@ does not assume it works: it checks the live Share dialog first.
- **Recheck trigger**: a Claude Code version bump, or a plan about to rely on cross-account or
cross-subscription sharing or editing (present or absent).

## Artifact types

A claude.ai Artifact type is a ready-made page that takes content as data, such as the Slides
type for decks. A producer uses a type instead of the shared builder when all three hold:

- The deliverable is the genre the type was made for: a deck uses the Slides type.
- The view is meant to be published: a type exists only as an Artifact on claude.ai.
- The type renders its content from a closed format, so the session writes data and never script.

Otherwise the producer builds a local page with the shared builder, or keeps the markdown record.
Rules for a producer on a type:

- **The record stays markdown.** The type's data files are the view. They are written in a scratch
folder outside any working tree and outside the record's bundle, then sent to the Artifact.
- **Types are per account.** The producer finds the type at run time through the Artifact tool's
`quickstart` and never hard-codes a type URL. With no such type, or no Artifact tool, it delivers
the markdown record and says why: that is the fallback.
- **Content classes still bind.** K2 text enters the type's store as escaped text only. The
producer's check script holds a K2 deck to an allowlist of text and layout elements, attributes,
and uploaded image sources, read by a quote-aware tokenizer that refuses whatever it cannot parse,
so no live embed, script, link, inline SVG, CSS function, or image taken from the source is sent.
- **The publish gate decides first.** `lib/publish-gate.mjs` (shared with `/review:explain-change`)
runs before the type's create call, which already publishes the title, so the create call takes
the title the gate read. The check script resolves the layers itself, not the model: only
`medium: artifact` from a layer a checked-out branch cannot write (the argument, the plugin's
option, `~/.claude/rendered-views.md`, or an untracked, gitignored overlay) publishes as is.
Otherwise the producer names the destination ("a private Artifact on claude.ai") and keeps the
view local when the source repository is not `PUBLIC` or any file looks like a credential, naming
`medium: artifact` in `~/.claude/rendered-views.md` as the opt-in.
- **A design system is optional.** It is used only when the user names one or the `quickstart`
attaches the account's default.

Producers on a type: `/visualization:present` (Slides).

## Genre rubric and stopping rule

A skill's deliverable is in scope for an HTML-view lane only when it falls in one of the
Expand Down Expand Up @@ -450,6 +488,9 @@ the same way (`plugins/debugging/scripts/build-view.mjs`, `plugins/discovery/scr
`architecture` `map-*` skills, each offering a view of its JSON record from one checked-in template
(`plugins/architecture/scripts/build-view.mjs`).

Emitters through an Artifact type (see Artifact types): `/visualization:present`, a deck made from
the account's Slides type, gated by `plugins/visualization/skills/present/scripts/check-deck.mjs`.

Retrofit list (existing lanes rendering untrusted-ish content, aligned to the security
baseline by the tracked retrofit issue, not silently): `adhd:clarify`,
`architecture:improve`. Both were retrofitted by #3609: each HTML lane repeats the
Expand Down Expand Up @@ -631,7 +672,7 @@ which is another cost of copying.
- It never makes a view the record: the markdown record stays authoritative everywhere.
- It adds no generic HTML skill, one whose job is "make a page" for any content. Thin
intent-named skills are allowed: a skill named for what the reader is trying to do
(`review:explain-change` explains a pull request) may emit a view as its deliverable,
(`/review:explain-change` explains a pull request) may emit a view as its deliverable,
owning its genre's page shape and reusing the shared builder and chrome.
`visualization:visualize` stays a router that owns no craft.
- It does not migrate the grandfathered surfaces' ladder or `medium`: that sweep is
Expand Down
1 change: 1 addition & 0 deletions docs/skill-cheat-sheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ owned by [docs/catalog-taxonomy.md](catalog-taxonomy.md).
| [`/testing:check`](../plugins/testing/skills/check/SKILL.md) | `testing` | Report whether node and jq resolve for the testing hooks. Never installs. |
| [`/toolchain:check-prerequisites`](../plugins/toolchain/skills/check-prerequisites/SKILL.md) | `toolchain` | Report whether the tools toolchain declares resolve. Never installs. |
| [`/typos-format:check`](../plugins/typos-format/skills/check/SKILL.md) | `typos-format` | Report whether typos and node are installed. Never installs. |
| [`/visualization:present`](../plugins/visualization/skills/present/SKILL.md) | `visualization` | Slide deck through the claude.ai Slides Artifact type, outline markdown as the record |
| [`/visualization:visualize`](../plugins/visualization/skills/visualize/SKILL.md) | `visualization` | Pick the best visual form for what is in the conversation and render it |
| [`/wizard:generate`](../plugins/wizard/skills/generate/SKILL.md) | `wizard` | Author a hardened interactive bash wizard for human-only setup, credential, and cutover steps |
| [`/wizard:unattended`](../plugins/wizard/skills/unattended/SKILL.md) | `wizard` | Author an unattended script a human launches once for a privilege or policy boundary |
Expand Down
184 changes: 184 additions & 0 deletions lib/publish-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env node
// Decide whether a rendered view may be published as a claude.ai Artifact or must
// stay on this machine. An explicit `medium: artifact` from a trusted layer
// publishes. Otherwise the view publishes only when its source repository is
// PUBLIC (or it has no repository source) and nothing in it is shaped like a
// credential. A miss proves nothing; a hit keeps the view local.
//
// publish-gate.mjs <PUBLIC|PRIVATE|INTERNAL|UNKNOWN|NONE> [--explicit] [--subject <word>] < text
// Prints one JSON object. Exit 0 decided, 2 usage.

import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

/** Text shaped like a credential. Conservative: a hit keeps the page local, a miss proves nothing. */
export const SECRET_PATTERNS = Object.freeze([
["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
["AWS access key", /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/],
["GitHub token", /\b(?:gh[pousr]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{82})/],
["Anthropic key", /\bsk-ant-[A-Za-z0-9_-]{20,}/],
["OpenAI key", /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/],
["Slack token", /\bxox[abposr]-[0-9A-Za-z-]{10,}/],
["Stripe key", /\b[sr]k_(?:test|live|prod)_[0-9A-Za-z]{10,}/],
["password or secret assignment", /\b(?:password|passwd|pwd|secret|client_secret|api_?key|token)["']?\s*[:=]\s*["'][^"'\s$<>{}]{8,}["']/i],
]);

/** The first credential-shaped pattern in `text`, as [label, 1-based line], or null. Never the match itself. */
export function findSecret(text) {
const lines = String(text ?? "").split(/\r?\n/);
for (let i = 0; i < lines.length; i += 1) {
for (const [label, re] of SECRET_PATTERNS) if (re.test(lines[i])) return [label, i + 1];
}
return null;
}

export const DESTINATION = "a private Artifact on claude.ai";
export const OPT_IN = "set medium: artifact in ~/.claude/rendered-views.md to publish anyway";
export const VISIBILITIES = Object.freeze(["PUBLIC", "PRIVATE", "INTERNAL", "UNKNOWN", "NONE"]);

/**
* Where an `artifact` view actually goes. `visibility` is the source repository's
* (`gh repo view --json visibility`), `NONE` when the view draws on no repository,
* and anything unrecognized is treated as not PUBLIC.
* @param {{explicit: boolean, visibility: string, text: string, subject?: string}} input
*/
export function publishGate({ explicit, visibility, text, subject = "content" }) {
if (explicit) return { medium: "artifact", destination: DESTINATION, reason: "a layer sets medium: artifact" };
if (visibility !== "PUBLIC" && visibility !== "NONE") {
return { medium: "file", reason: `repository visibility is ${visibility || "unknown"}, not PUBLIC`, opt_in: OPT_IN };
}
const secret = findSecret(text);
if (secret) return { medium: "file", reason: `${subject} line ${secret[1]} looks like a ${secret[0]}`, opt_in: OPT_IN };
const source = visibility === "NONE" ? "no repository source" : "public repository";
return { medium: "artifact", destination: DESTINATION, reason: `${source} and nothing credential-shaped in the ${subject}` };
}

// ------------------------------------------------------------ trusted layers

/** The session's project: CLAUDE_PROJECT_DIR, else the nearest directory above the cwd holding `.git`. */
export function findRoot() {
if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR);
for (let at = process.cwd(); ; at = dirname(at)) {
if (existsSync(join(at, ".git"))) return at;
if (dirname(at) === at) return null;
}
}

const real = (p) => {
try {
return realpathSync(p);
} catch {
return resolve(p);
}
};
const within = (child, parent) => {
const rel = relative(parent, child);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
};
const isLink = (p) => {
try {
return lstatSync(p).isSymbolicLink();
} catch {
return false;
}
};
function gitOut(root, args) {
try {
return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
} catch {
return null;
}
}

/**
* An overlay applies only untracked, so a pull request cannot ship one. Not
* gitignored, it warns, or with `requireIgnored` is ignored too.
*/
export function overlayApplies(root, path, warnings, { requireIgnored = false } = {}) {
// Any tracked case variant counts: on a case-insensitive filesystem it is this file.
const rel = relative(root, path).split("\\").join("/");
if (gitOut(root, ["ls-files", "--", `:(icase)${rel}`])) {
warnings.push(`overlay ${path}: tracked in git, so a pull request could set it; layer ignored`);
return false;
}
const dir = join(root, ".claude");
// A submodule or tracked file at .claude itself is content a pull request controls.
const entries = (gitOut(root, ["ls-files", "-s", "-z", "--", ":(icase).claude"]) ?? "").split("\0");
if (entries.some((e) => e.split("\t")[1]?.toLowerCase() === ".claude") || existsSync(join(dir, ".git"))) {
warnings.push(`overlay ${path}: .claude is a submodule or tracked entry; layer ignored`);
return false;
}
if (isLink(dir) || isLink(path) || !within(real(path), join(real(root), ".claude"))) {
warnings.push(`overlay ${path}: .claude or the overlay is a symlink or resolves outside ${dir}; layer ignored`);
return false;
}
if (gitOut(root, ["check-ignore", "-q", "--", path]) === null) {
warnings.push(`overlay ${path}: not gitignored, so it can reach team history${requireIgnored ? "; layer ignored" : ""}`);
return !requireIgnored;
}
return true;
}

export const MEDIUMS = Object.freeze(["terminal", "file", "artifact"]);
const mediumIn = (text) => /^[ \t]*medium:[ \t]*["']?([a-z]+)["']?[ \t]*$/m.exec(text ?? "")?.[1];
const readText = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null);

/**
* The medium the user's own layers set, resolved here rather than by the model
* reading them. The user's argument decides alone. Otherwise any layer setting
* `terminal` or `file` keeps the view local, and `artifact` counts only from the
* plugin option, `~/.claude/rendered-views.md`, or an untracked, gitignored
* `<project>/.claude/rendered-views.local.md`. The team `.claude/rendered-views.md`
* can arrive in a checked-out branch, so it may keep a view local but never publish it.
* @returns {{medium: string|null, source: string|null, warnings: string[]}} medium null: no layer decided
*/
export function trustedMedium({ argument = null, option = null, home = homedir(), project = findRoot() } = {}) {
const warnings = [];
if (MEDIUMS.includes(argument)) return { medium: argument, source: "the user's argument", warnings };
const user = join(home, ".claude", "rendered-views.md");
const layers = [
["the plugin option", MEDIUMS.includes(option) ? option : null, true],
[user, mediumIn(readText(user)), true],
];
const inTree = project && !within(real(home), real(project)) && gitOut(project, ["rev-parse", "--is-inside-work-tree"]) !== null;
if (inTree) {
const team = join(project, ".claude", "rendered-views.md");
if (real(team) !== real(user)) layers.push([team, mediumIn(readText(team)), false]);
const overlay = join(project, ".claude", "rendered-views.local.md");
if (real(overlay) !== real(user) && existsSync(overlay) && overlayApplies(project, overlay, warnings, { requireIgnored: true })) {
layers.push([overlay, mediumIn(readText(overlay)), true]);
}
}
const local = layers.find(([, medium]) => medium === "terminal" || medium === "file");
if (local) return { medium: local[1], source: local[0], warnings };
const publish = layers.find(([, medium, trusted]) => medium === "artifact" && trusted);
return publish ? { medium: "artifact", source: publish[0], warnings } : { medium: null, source: null, warnings };
}

/** CLI: the argument vector after the script path; returns the exit code. */
export function main(argv, input = () => readFileSync(0, "utf8")) {
const [visibility, ...rest] = argv;
if (!visibility || visibility.startsWith("--")) return usage();
let explicit = false;
let subject = "content";
for (let i = 0; i < rest.length; i += 1) {
if (rest[i] === "--explicit") explicit = true;
else if (rest[i] === "--subject" && /^[a-z]{1,20}$/.test(rest[i + 1] ?? "")) subject = rest[++i];
else return usage();
}
const result = publishGate({ explicit, visibility: visibility.toUpperCase(), text: input(), subject });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return 0;
}

function usage() {
process.stderr.write("usage: publish-gate.mjs <PUBLIC|PRIVATE|INTERNAL|UNKNOWN|NONE> [--explicit] [--subject <word>] < text\n");
return 2;
}

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
Loading
Loading