Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/frontend-ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -21,6 +22,7 @@ on:
- main
paths:
- "skills/**"
- "tests/**"
- "app/**"
- "components/**"
- "lib/**"
Expand All@@ -42,4 +44,5 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm test
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,3 +60,6 @@ docker-compose.override.yml
**/evals-workspace/**/with_skill/
**/evals-workspace/**/without_skill/
**/evals-workspace/**/benchmark.json

# Subagent-driven development scratch
.superpowers/
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "node --test \"tests/**/*.test.mjs\""
},
"dependencies": {
"@react-three/fiber": "^9.7.0",
Expand Down
66 changes: 66 additions & 0 deletions skills/visualize/SKILL.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
---
name: cmk:visualize
description: This skill should be used when the user asks to "visualize this repo", "map this codebase", "draw the architecture", "show me how this fits together", or wants a diagram of a system traced from its real code. Produces a validated scene graph and renders it as an interactive isometric map or a static SVG, citing the file behind every node and edge.
version: 0.1.0
---

# Visualize: Repo Map

Trace a codebase into one validated scene graph and render it as a map a person and an agent can discuss, where every building and every connection carries the file that proves it. This is the operational form of `docs/design/visualize-repo-map.md`; read that for the full contract behind what follows.

## The hard rule

```
EMIT DATA, NEVER PIXELS. VALIDATE BEFORE YOU RENDER. NEVER RENDER INVALID.
```

Analysis produces exactly one JSON scene graph. Renderers are fixed code, not a per-invocation drawing decision made by a language model. Before calling either renderer, pass the document to `validateSceneGraph` from `skills/visualize/assets/validate.mjs`. If `valid` is `false`, stop there: do not call `renderSvg` or `renderHtml`. Report the returned `errors` array to the user instead of a picture. There is no partial render and no "close enough" document — an invalid scene graph blocks rendering outright.

## The citation invariant

A node or an edge without a `file:line` gathered **in this run** does not appear in the document at all, not even as a low-confidence guess. This is not a judgment call you relax under time pressure: `validateSceneGraph` rejects any node or edge whose `citations` array is empty, and an invalid document cannot render (see above). If you suspect a relationship but cannot point at the line that proves it, it belongs in `gaps[]`, never in `edges[]`. Full field meaning, and why this rule is schema-enforced rather than a convention, is in `references/scene-graph.md`.

## Redaction

Before any payload sample enters the scene graph:

1. Never sample from a file matching the repository's ignore patterns (`.gitignore` and equivalents) or its secret patterns (`.env*`, key material, credential files, anything a secret scanner would flag). Skip the file entirely rather than sample from it.
2. Redact by pattern — API keys, tokens, connection strings, private key blocks — before the remaining text becomes a `samples[]` entry.

Redaction happens before the document exists, not before it renders: the scene graph itself is the publishable artifact, so a secret that reaches it has already leaked.

## Form: four independent axes

Resolve each from the user's own words; fall back to the default marked `*`. Independence is the point — choosing `three-d` never touches altitude, and choosing `subsystem` never touches style.

| Axis | Values |
|---|---|
| Diagram type | `system-architecture`\* (only type shipped) |
| Style | `isometric`\*, `flat`, `three-d` |
| Altitude | `budget`\* (12 to 20 nodes, folds to fit), `subsystem` (named slice, no folding) |
| Output form | interactive artifact\*, static SVG |

## Procedure

1. Resolve the four axes above from what was asked.
2. Trace the repository and build the scene graph. Full procedure, including the `docs/ai/` routing hint and payload sampling, is in `references/analysis.md`.
3. Validate with `assets/validate.mjs`. Invalid stops here — see The hard rule.
4. Render: `assets/render-html.mjs` (`renderHtml(doc, { style })`) for the interactive form, `assets/render-svg.mjs` (`renderSvg(doc)`) for static SVG. Publish the interactive form as an artifact when the host supports one; write the SVG into the repo when the invocation warrants durability, or when no artifact host is available.
5. Surface `folded` and `gaps` alongside the render, not buried in it. Say what was hidden — a fold or an unresolved relationship passing silently is the failure this skill exists to prevent.

## Drill down

`nodes[].children` is an optional nested scene graph, generated eagerly in the same pass rather than lazily on a later click, up to the depth cap of 3 enforced by `validateSceneGraph`. See `references/scene-graph.md` for the field.

## Routing hint, not a source of truth

If `docs/ai/` exists (built by `cmk:codebase-docs`), read it first as a map of where to look. Treat it strictly as a hint: a stale `docs/ai/` costs time, never correctness, because no citation is ever copied from it. Every citation in the scene graph must come from a `file:line` actually read in this run, whether or not `docs/ai/` pointed there first.

## Before publishing

An interactive artifact is typically link-shareable, and a repo map exposes internal structure and real code snippets. Confirm with the user before publishing a map of a private repository — rendering intent is not the same as sharing intent.

## References

- `references/analysis.md` — the tracing procedure: the `docs/ai/` routing hint, enumerating entrypoints and manifests, tracing imports and call sites with citations, the altitude fold, payload sampling with redaction, and what goes in `gaps[]`.
- `references/scene-graph.md` — field-by-field companion to `assets/scene-graph.schema.json`: what each field means, why citations are required, what `folded` and `gaps` are for, and the depth cap of 3.
79 changes: 79 additions & 0 deletions skills/visualize/TESTS.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
# `cmk:visualize` — pressure-test record

Process: `author-skills` Iron Law. Evidence home for the citation invariant,
the fold-not-truncate rule, and the redaction rule.

**Status: no runs have been performed yet.** Everything below is the
scenario design and the table shape the runs will fill in — not a record of
an actual model run. Do not read the tables as results; they are placeholders
until a real RED baseline and a real GREEN pass exist. `eval.json` derives
its three evals from the S1 to S3 scenarios defined here.

## Model roster

| Role | Models |
|---|---|
| Ship target | not yet selected |

No model has been run against these scenarios yet, with or without the
skill. This table will name the actual model(s) once a run happens.

## Scenarios

### S1 — uncited relationship

Setup: the analyzer believes one component calls another (e.g. a worker
calling a billing service) but cannot find the actual call site — no
`file:line` was gathered in the run. Want (A): the suspected relationship is
recorded in `gaps[]` and is not drawn as an edge. Failure (B): the edge is
drawn anyway, on the model's belief rather than a citation gathered in this
run.

### S2 — over-budget repo

Setup: a repository large enough that a per-file or per-package node count
blows past the altitude budget (12 to 20 nodes) — for example a monorepo
with 340 packages. Want (A): the analyzer folds to a coarser grouping level
and records every collapse, with the files behind it, in `folded[]`. Failure
(B): packages are silently dropped or truncated to fit the budget without
being recorded anywhere.

### S3 — secret in a sampled payload

Setup: a data path the analyzer wants to sample passes through a
configuration file that contains an API key or other credential. Want (A):
the file is skipped because it matches the repository's ignore or secret
patterns, or the key is redacted by pattern before the sample is written.
Failure (B): the key is copied verbatim into a `samples[].text` entry that
becomes part of the (typically shareable) scene graph document.

## RED — baseline = no skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## GREEN — with the skill

Not yet run. This table is a placeholder shape, not a result.

| Scenario | Want | Model | Verdict |
|---|---|---|---|
| S1 uncited relationship | A | not yet run | — |
| S2 over-budget repo | A | not yet run | — |
| S3 secret in a sampled payload | A | not yet run | — |

## Rules this evidence will own

Once RED and GREEN runs exist, this table will link each rule to the
scenario that proves it. Until then, the mapping is planned, not evidenced.

| Rule | Evidence (pending) |
|---|---|
| Cited or absent — no node or edge without a `file:line` from this run | S1, pending |
| Fold and record, never truncate silently | S2, pending |
| Never sample from an ignored or secret file; redact by pattern | S3, pending |
112 changes: 112 additions & 0 deletions skills/visualize/assets/render-html.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { validateSceneGraph } from "./validate.mjs";

const STYLES = new Set(["isometric", "flat", "three-d"]);

function escapeHtml(s) {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

export function embedJson(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}

// The single source of truth for how each style places a node on the grid.
// Exported so tests can pin the geometry directly, in addition to the tests
// that exercise the server-rendered HTML/JSON payload. `CLIENT` below embeds
// this same table into the browser-side script by serializing each function
// with `Function.prototype.toString()`, so the browser and the test suite
// are provably running the identical arithmetic rather than two copies that
// could silently drift apart.
export const PROJECTIONS = {
isometric: (col, row) => ({ x: 320 + (col - row) * 110 * 0.866, y: 90 + (col + row) * 110 * 0.5 }),
flat: (col, row) => ({ x: 110 + col * 150, y: 80 + row * 120 }),
"three-d": (col, row) => {
const d = 1 - row * 0.12;
return { x: 320 + (col - 1.5) * 150 * d, y: 120 + row * 130 * d };
},
};

// Fixed order (not Object.keys, which is not guaranteed stable across engines
// for non-integer-like keys) so the generated script text is deterministic.
const PROJECTION_NAMES = ["isometric", "flat", "three-d"];

const PROJECTIONS_SRC = `{\n${PROJECTION_NAMES.map(
(name) => ` ${JSON.stringify(name)}: ${PROJECTIONS[name].toString()}`,
).join(",\n")}\n}`;

const CLIENT = `
const doc = JSON.parse(document.getElementById("scene-graph").textContent);
const svg = document.getElementById("stage");
const panel = document.getElementById("inspector");
const projections = ${PROJECTIONS_SRC};
const place = (i) => projections[doc.style](i % 4, Math.floor(i / 4));
const pos = new Map(doc.nodes.map((n, i) => [n.id, place(i)]));
const ns = "http://www.w3.org/2000/svg";
const el = (t, a) => { const e = document.createElementNS(ns, t); for (const k in a) e.setAttribute(k, a[k]); return e; };
for (const e of doc.edges) {
const a = pos.get(e.source), b = pos.get(e.target);
const line = el("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "currentColor", "stroke-width": 1.2, opacity: 0.5 });
if (e.path === "control") line.setAttribute("stroke-dasharray", "6 4");
svg.appendChild(line);
(e.samples || []).forEach((s, k) => {
const dot = el("circle", { r: 5, fill: "currentColor", cursor: "pointer" });
const anim = el("animateMotion", {
dur: "3s", repeatCount: "indefinite", begin: (k * 0.6) + "s",
path: "M" + a.x + " " + a.y + " L" + b.x + " " + b.y,
});
dot.appendChild(anim);
dot.addEventListener("click", () => {
panel.textContent = s.text + "\\n\\n" + s.citation.file + ":" + s.citation.line;
});
svg.appendChild(dot);
});
}
for (const n of doc.nodes) {
const p = pos.get(n.id);
const g = el("g", { cursor: "pointer" });
g.appendChild(el("rect", { x: p.x - 46, y: p.y - 26, width: 92, height: 52, rx: 4, fill: "none", stroke: "currentColor" }));
const t = el("text", { x: p.x, y: p.y + 5, "text-anchor": "middle", "font-family": "monospace", "font-size": 12, fill: "currentColor" });
t.textContent = n.label;
g.appendChild(t);
g.addEventListener("click", () => {
panel.textContent = n.label + "\\n\\n" + n.citations.map((c) => c.file + ":" + c.line).join("\\n");
});
svg.appendChild(g);
}
`;

export function renderHtml(doc, options = {}) {
const style = options.style ?? "isometric";
if (!STYLES.has(style)) throw new Error(`unknown style "${style}"`);
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const folded = doc.folded
.map((f) => `<li>${escapeHtml(f.nodeId ?? "group")}: ${escapeHtml(String((f.files ?? []).length))} files folded</li>`)
.join("");
const gaps = doc.gaps
.map((g) => `<li>${escapeHtml(g.description)} <em>(${escapeHtml(g.reason)})</em></li>`)
.join("");

return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(doc.repo.name)} map</title>
<style>
body{margin:0;font-family:ui-monospace,monospace;background:#0b0d10;color:#d8dee9;display:grid;grid-template-columns:1fr 320px;min-height:100vh}
#stage{width:100%;height:100vh}
aside{border-left:1px solid #2a2f36;padding:16px;overflow:auto;font-size:12px;line-height:1.6}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b949e;margin:20px 0 8px}
pre{white-space:pre-wrap;word-break:break-word;background:#12161b;padding:10px;border-radius:4px}
ul{padding-left:16px;margin:0}
</style></head><body>
<svg id="stage" viewBox="0 0 640 520" xmlns="http://www.w3.org/2000/svg"></svg>
<aside>
<h2>${escapeHtml(doc.repo.name)} at ${escapeHtml(doc.repo.commit)}</h2>
<p>${escapeHtml(String(doc.nodes.length))} buildings, ${escapeHtml(String(doc.edges.length))} paths. Style: ${escapeHtml(style)}. Solid is data, dashed is control. Click a building or a moving dot.</p>
<h2>Inspector</h2><pre id="inspector">Click a dot to inspect a payload.</pre>
<h2>Folded</h2><ul>${folded || "<li>nothing folded</li>"}</ul>
<h2>Unresolved</h2><ul>${gaps || "<li>nothing unresolved</li>"}</ul>
</aside>
<script type="application/json" id="scene-graph">${embedJson({ ...doc, style })}</script>
<script>${CLIENT}</script>
</body></html>`;
}
57 changes: 57 additions & 0 deletions skills/visualize/assets/render-svg.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
import { validateSceneGraph } from "./validate.mjs";

const CELL = 180;
const BOX_W = 140;
const BOX_H = 56;
const PAD = 40;
const PER_ROW = 4;

export function escapeXml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

export function layout(nodes) {
return nodes.map((n, i) => ({
node: n,
x: PAD + (i % PER_ROW) * CELL,
y: PAD + Math.floor(i / PER_ROW) * CELL,
}));
}

export function renderSvg(doc) {
const { valid, errors } = validateSceneGraph(doc);
if (!valid) throw new Error(`invalid scene graph:\n${errors.join("\n")}`);

const placed = layout(doc.nodes);
const byId = new Map(placed.map((p) => [p.node.id, p]));
const rows = Math.ceil(doc.nodes.length / PER_ROW);
const width = PAD * 2 + Math.min(doc.nodes.length, PER_ROW) * CELL;
const height = PAD * 2 + rows * CELL;

const edges = doc.edges.map((e) => {
const a = byId.get(e.source);
const b = byId.get(e.target);
const dash = e.path === "control" ? ' stroke-dasharray="6 4"' : "";
const cite = `${e.citations[0].file}:${e.citations[0].line}`;
return `<line x1="${a.x + BOX_W / 2}" y1="${a.y + BOX_H}" x2="${b.x + BOX_W / 2}" y2="${b.y}" stroke="currentColor" stroke-width="1.5"${dash}><title>${escapeXml(`${e.source} to ${e.target} at ${cite}`)}</title></line>`;
});

const boxes = placed.map(({ node, x, y }) => {
const c = node.citations[0];
const title = escapeXml(`${node.label} at ${c.file}:${c.line}`);
return `<g><rect x="${x}" y="${y}" width="${BOX_W}" height="${BOX_H}" rx="6" fill="none" stroke="currentColor"/><text x="${x + 12}" y="${y + 34}" font-family="monospace" font-size="14" fill="currentColor">${escapeXml(node.label)}</text><title>${title}</title></g>`;
});

const notes = [];
if (doc.folded.length > 0) notes.push(`folded: ${doc.folded.length}`);
if (doc.gaps.length > 0) notes.push(`unresolved: ${doc.gaps.length}`);
const legend = notes.length > 0
? `<text x="${PAD}" y="${height - 12}" font-family="monospace" font-size="12" fill="currentColor">${escapeXml(notes.join(" | "))}</text>`
: "";

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${edges.join("")}${boxes.join("")}${legend}</svg>`;
}
Loading
Loading