Skills に戻る
warpdotdev/common-skillsチェック済み

SKILL DETAIL

readout

warpdotdev/common-skills/readout

The readout skill turns an investigation into a durable HTML document that can be read weeks later without original context. It supports two modes: snapshot mode, which mines the current conversation for findings, and research mode, which sharpens scope with clarifying questions and investigates the codebase. The skill is orchestrated by the main agent, which launches a single child agent to perform the mining/research and writing, keeping the main context clean. Use this skill whenever the user invokes /readout, says "write this up", "turn this into a doc/page", "make a readout", or asks for a readable, shareable document capturing findings or explaining how something works.

インストール · 153出典を見る

Installation

npx skills add https://github.com/warpdotdev/common-skills --skill readout

スキルファイル

SKILL.md

最終同期 · 2026/08/29

assets/code-pane.html
<!--
  readout code pane (bundled asset from the readout skill).
  Inline this ENTIRE block verbatim immediately before </body>.

  It enhances every GitHub blob link (https://github.com/<org>/<repo>/blob/<commit>/<path>#L10-L20)
  with an in-document source viewer: click opens the code in a split pane on the right (the
  document content is pushed aside, not overlaid), clicking another reference reuses the pane,
  and a close button (or Escape) dismisses it. The pane is resizable by dragging its left edge
  (min width, persisted; double-click the edge to reset). Code is syntax-highlighted via
  highlight.js, lazily loaded from a CDN only when the pane opens; offline it degrades to plain text.
  Progressive enhancement: with JS disabled, or on modified clicks, links open GitHub normally.

  Source resolution order:
    1. snippets embedded at generation time in a script[type="application/json"][data-code-snippets]
       block, keyed "org/repo@commit:path" -> {"start": first line number, "text": "lines", "full": bool}
       or, for whole files compressed at generation time, {"gz": "base64 gzip", "full": true}
       (inflated in the browser via DecompressionStream). Use scripts/embed_snippets.py to generate.
    2. runtime fetch of raw.githubusercontent.com (user-initiated; works for public repos)
    3. reader-supplied GitHub token: on an HTTP error the pane offers a paste-a-token form and
       retries via api.github.com (fine-grained PAT, read-only Contents). The token lives in
       sessionStorage for this tab only and is never written into the document.
    4. fallback message with an "open on GitHub" link
-->
<style data-code-pane>
  :root { --crp-w: min(44rem, 46vw); }
  body { transition: margin-right 0.18s ease; }
  html.crp-open body { margin-right: var(--crp-w); }
  @media (max-width: 900px) {
    :root { --crp-w: 100vw; }
    html.crp-open body { margin-right: 0; }
  }
  .crp-pane {
    position: fixed; top: 0; right: 0; bottom: 0;
    width: var(--crp-w);
    display: flex; flex-direction: column;
    background: #f5f2eb; color: #2a2622;
    border-left: 1px solid #d8d2c6;
    z-index: 1000;
    font-size: 0.8rem;
  }
  @media (prefers-color-scheme: dark) {
    .crp-pane { background: #211f1c; color: #e8e4dc; border-left-color: #3a3733; }
  }
  .crp-head {
    display: flex; align-items: center; gap: 0.7rem;
    padding: 0.6rem 1rem;
    border-bottom: 1px solid rgba(128, 118, 100, 0.3);
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.76rem;
  }
  .crp-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
  .crp-lines { flex: none; opacity: 0.6; }
  .crp-gh { flex: none; color: inherit; opacity: 0.75; text-decoration: underline; white-space: nowrap; }
  .crp-close {
    flex: none; width: 1.7rem; height: 1.7rem;
    border: 1px solid rgba(128, 118, 100, 0.4); border-radius: 6px;
    background: transparent; color: inherit;
    font-size: 1rem; line-height: 1; cursor: pointer;
  }
  .crp-close:hover { background: rgba(128, 118, 100, 0.15); }
  .crp-resize {
    position: absolute; left: -3px; top: 0; bottom: 0; width: 7px;
    cursor: col-resize; z-index: 1001;
  }
  .crp-resize:hover, html.crp-resizing .crp-resize { background: rgba(128, 118, 100, 0.3); }
  html.crp-resizing body { transition: none; }
  html.crp-resizing, html.crp-resizing * { user-select: none; cursor: col-resize !important; }
  .crp-body {
    flex: 1 1 auto; overflow: auto;
    padding: 0.5rem 0 2rem;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    line-height: 1.5; tab-size: 4;
  }
  .crp-line { display: flex; }
  .crp-ln { flex: none; width: 3.4rem; padding-right: 0.9rem; text-align: right; opacity: 0.45; user-select: none; }
  .crp-code { flex: 1 1 auto; white-space: pre; padding-right: 1rem; }
  .crp-line.crp-hit { background: rgba(163, 148, 84, 0.22); }
  .crp-msg { padding: 1.1rem 1.2rem; font-family: inherit; }
  .crp-msg a { color: inherit; }
  .crp-note { padding: 0.5rem 1.2rem 0.7rem; opacity: 0.65; font-size: 0.72rem; font-family: inherit; }
  .crp-token { display: flex; gap: 0.5rem; margin: 0.8rem 0 0.4rem; }
  .crp-token input {
    flex: 1 1 auto; min-width: 0;
    font: inherit; color: inherit;
    background: rgba(128, 118, 100, 0.08);
    border: 1px solid rgba(128, 118, 100, 0.4); border-radius: 6px;
    padding: 0.35rem 0.6rem;
  }
  .crp-token button {
    flex: none;
    font: inherit; color: inherit; cursor: pointer;
    background: rgba(128, 118, 100, 0.15);
    border: 1px solid rgba(128, 118, 100, 0.4); border-radius: 6px;
    padding: 0.35rem 0.8rem;
  }
  .crp-token button:hover { background: rgba(128, 118, 100, 0.25); }
  .crp-token-note { opacity: 0.65; font-size: 0.72rem; margin: 0.4rem 0 0.8rem; }
  /* muted-primaries highlight.js theme on warm paper, scoped to the pane (light + dark) */
  .crp-pane .hljs-comment, .crp-pane .hljs-doctag, .crp-pane .hljs-quote { color: #91887a; font-style: italic; }
  .crp-pane .hljs-string, .crp-pane .hljs-regexp, .crp-pane .hljs-addition { color: #3d6247; }
  .crp-pane .hljs-keyword, .crp-pane .hljs-literal, .crp-pane .hljs-selector-tag, .crp-pane .hljs-section { color: #31567d; }
  .crp-pane .hljs-number, .crp-pane .hljs-symbol, .crp-pane .hljs-bullet { color: #7d6a2e; }
  .crp-pane .hljs-title, .crp-pane .hljs-name { color: #794046; }
  .crp-pane .hljs-type, .crp-pane .hljs-built_in, .crp-pane .hljs-selector-class, .crp-pane .hljs-selector-id { color: #6e5540; }
  .crp-pane .hljs-attr, .crp-pane .hljs-property, .crp-pane .hljs-variable, .crp-pane .hljs-template-variable, .crp-pane .hljs-attribute { color: #5a6b3f; }
  .crp-pane .hljs-meta, .crp-pane .hljs-params, .crp-pane .hljs-operator, .crp-pane .hljs-punctuation { color: #6d675c; }
  .crp-pane .hljs-deletion { color: #8e3b3b; }
  .crp-pane .hljs-emphasis { font-style: italic; }
  .crp-pane .hljs-strong { font-weight: 600; }
  @media (prefers-color-scheme: dark) {
    .crp-pane .hljs-comment, .crp-pane .hljs-doctag, .crp-pane .hljs-quote { color: #837a6b; }
    .crp-pane .hljs-string, .crp-pane .hljs-regexp, .crp-pane .hljs-addition { color: #9ab894; }
    .crp-pane .hljs-keyword, .crp-pane .hljs-literal, .crp-pane .hljs-selector-tag, .crp-pane .hljs-section { color: #8caec9; }
    .crp-pane .hljs-number, .crp-pane .hljs-symbol, .crp-pane .hljs-bullet { color: #c2ae76; }
    .crp-pane .hljs-title, .crp-pane .hljs-name { color: #c99a94; }
    .crp-pane .hljs-type, .crp-pane .hljs-built_in, .crp-pane .hljs-selector-class, .crp-pane .hljs-selector-id { color: #b39a7e; }
    .crp-pane .hljs-attr, .crp-pane .hljs-property, .crp-pane .hljs-variable, .crp-pane .hljs-template-variable, .crp-pane .hljs-attribute { color: #a9b98a; }
    .crp-pane .hljs-meta, .crp-pane .hljs-params, .crp-pane .hljs-operator, .crp-pane .hljs-punctuation { color: #9a9284; }
    .crp-pane .hljs-deletion { color: #c98f8f; }
  }
</style>
<script data-code-pane>
(function () {
  "use strict";
  var BLOB = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/([^#?]+)(?:#L(\d+)(?:-L(\d+))?)?$/;
  var TOKEN_KEY = "crp-gh-token";
  var WIDTH_KEY = "crp-w-px";
  var MIN_W = 320; // px — minimum pane width when dragging
  var HLJS_CDN = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js";
  var EXT_LANG = {
    go: "go", rs: "rust", py: "python", rb: "ruby", sql: "sql", lua: "lua", pl: "perl",
    js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
    ts: "typescript", tsx: "typescript",
    sh: "bash", bash: "bash", zsh: "bash",
    c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp",
    java: "java", kt: "kotlin", kts: "kotlin", swift: "swift", cs: "csharp",
    m: "objectivec", mm: "objectivec", php: "php", r: "r",
    yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini", conf: "ini",
    json: "json", jsonc: "json", xml: "xml", html: "xml", htm: "xml", svg: "xml",
    css: "css", scss: "scss", less: "less", md: "markdown", markdown: "markdown",
    makefile: "makefile", diff: "diff", patch: "diff"
  };

  var snippets = {};
  var snipEl = document.querySelector('script[type="application/json"][data-code-snippets]');
  if (snipEl) { try { snippets = JSON.parse(snipEl.textContent) || {}; } catch (e) { snippets = {}; } }

  var cache = Object.create(null); // rawUrl -> file text
  var reqId = 0;
  var memToken = "";
  var hljsPromise = null;
  var pane = null, titleEl, linesEl, ghEl, bodyEl;

  function getToken() {
    if (memToken) return memToken;
    try { return window.sessionStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }
  }
  function setToken(t) {
    memToken = t;
    try { window.sessionStorage.setItem(TOKEN_KEY, t); } catch (e) {}
  }

  function storedWidth() {
    try { return parseInt(window.localStorage.getItem(WIDTH_KEY), 10) || 0; } catch (e) { return 0; }
  }
  function storeWidth(px) {
    try { window.localStorage.setItem(WIDTH_KEY, String(px)); } catch (e) {}
  }
  function clampWidth(px) {
    var max = Math.max(MIN_W, window.innerWidth - 280); // keep some document visible
    return Math.min(Math.max(px, MIN_W), max);
  }
  function applyWidth(px) {
    document.documentElement.style.setProperty("--crp-w", px + "px");
  }
  function resetWidth() {
    try { window.localStorage.removeItem(WIDTH_KEY); } catch (e) {}
    document.documentElement.style.removeProperty("--crp-w");
  }
  // Applies the remembered width, and defers to the stylesheet (full-width pane) on
  // narrow screens — an inline --crp-w would otherwise override the media query.
  function syncWidth() {
    if (window.innerWidth <= 900) { document.documentElement.style.removeProperty("--crp-w"); return; }
    var w = storedWidth();
    if (w) applyWidth(clampWidth(w));
  }

  function langFor(path) {
    var base = (path || "").split("/").pop().toLowerCase();
    if (base === "makefile") return "makefile";
    if (base === "dockerfile") return "dockerfile";
    var m = /\.([a-z0-9_]+)$/.exec(base);
    return m ? EXT_LANG[m[1]] || null : null;
  }

  function loadHljs() {
    if (window.hljs) return Promise.resolve(window.hljs);
    if (hljsPromise) return hljsPromise;
    hljsPromise = new Promise(function (resolve) {
      var s = document.createElement("script");
      s.src = HLJS_CDN;
      s.onload = function () { resolve(window.hljs || null); };
      s.onerror = function () { resolve(null); };
      document.head.appendChild(s);
    });
    return hljsPromise;
  }

  // Split highlight.js output HTML into per-line HTML, re-opening spans that cross newlines.
  function splitHighlighted(html) {
    var lines = html.split("\n");
    var open = [];
    var out = [];
    var tagRe = /<span[^>]*>|<\/span>/g;
    for (var i = 0; i < lines.length; i++) {
      var prefix = open.join("");
      var line = lines[i];
      var m;
      tagRe.lastIndex = 0;
      while ((m = tagRe.exec(line))) {
        if (m[0] === "</span>") open.pop(); else open.push(m[0]);
      }
      var suffix = "";
      for (var k = 0; k < open.length; k++) suffix += "</span>";
      out.push(prefix + line + suffix);
    }
    return out;
  }

  function inflate(b64) {
    return new Promise(function (resolve, reject) {
      if (typeof DecompressionStream === "undefined") { reject(new Error("DecompressionStream unavailable")); return; }
      var bin = atob(b64);
      var bytes = new Uint8Array(bin.length);
      for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      var stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
      new Response(stream).text().then(resolve, reject);
    });
  }

  // Resolves an embedded snippet entry to {text, start, full} (inflating "gz" once), or null.
  function resolveSnippet(key) {
    var snip = snippets[key];
    if (!snip) return Promise.resolve(null);
    if (typeof snip.text === "string") {
      return Promise.resolve({ text: snip.text, start: snip.start || 1, full: !!snip.full });
    }
    if (typeof snip.gz === "string") {
      return inflate(snip.gz).then(function (text) {
        snippets[key] = { text: text, start: snip.start || 1, full: !!snip.full };
        return snippets[key];
      }, function () { return null; });
    }
    return Promise.resolve(null);
  }

  function ensurePane() {
    if (pane) return;
    pane = document.createElement("aside");
    pane.className = "crp-pane";
    pane.setAttribute("role", "complementary");
    pane.setAttribute("aria-label", "Source viewer");
    pane.tabIndex = -1;

    var head = document.createElement("div");
    head.className = "crp-head";
    titleEl = document.createElement("span"); titleEl.className = "crp-title";
    linesEl = document.createElement("span"); linesEl.className = "crp-lines";
    ghEl = document.createElement("a"); ghEl.className = "crp-gh"; ghEl.textContent = "open on GitHub";
    ghEl.target = "_blank"; ghEl.rel = "noopener";
    var close = document.createElement("button");
    close.className = "crp-close"; close.type = "button";
    close.setAttribute("aria-label", "Close source viewer");
    close.textContent = "\u00d7";
    close.addEventListener("click", closePane);
    head.appendChild(titleEl); head.appendChild(linesEl); head.appendChild(ghEl); head.appendChild(close);

    bodyEl = document.createElement("div");
    bodyEl.className = "crp-body";

    var grip = document.createElement("div");
    grip.className = "crp-resize";
    grip.setAttribute("aria-hidden", "true");
    grip.title = "Drag to resize · double-click to reset";
    grip.addEventListener("pointerdown", function (ev) {
      if (ev.button !== 0) return;
      ev.preventDefault();
      grip.setPointerCapture(ev.pointerId);
      document.documentElement.classList.add("crp-resizing");
      function move(e) {
        applyWidth(clampWidth(Math.round(window.innerWidth - e.clientX)));
      }
      function up(e) {
        document.documentElement.classList.remove("crp-resizing");
        storeWidth(clampWidth(Math.round(window.innerWidth - e.clientX)));
        grip.removeEventListener("pointermove", move);
        grip.removeEventListener("pointerup", up);
        grip.removeEventListener("pointercancel", up);
      }
      grip.addEventListener("pointermove", move);
      grip.addEventListener("pointerup", up);
      grip.addEventListener("pointercancel", up);
    });
    grip.addEventListener("dblclick", resetWidth);

    pane.appendChild(grip);
    pane.appendChild(head);
    pane.appendChild(bodyEl);
    document.body.appendChild(pane);
    document.documentElement.classList.add("crp-open");
    syncWidth();
  }

  function closePane() {
    if (pane && pane.parentNode) pane.parentNode.removeChild(pane);
    pane = null;
    document.documentElement.classList.remove("crp-open");
  }

  function showMessage(text, href) {
    bodyEl.textContent = "";
    var div = document.createElement("div");
    div.className = "crp-msg";
    var p = document.createElement("p");
    p.textContent = text;
    div.appendChild(p);
    if (href) {
      var a = document.createElement("a");
      a.href = href; a.target = "_blank"; a.rel = "noopener";
      a.textContent = "Open on GitHub instead";
      div.appendChild(a);
    }
    bodyEl.appendChild(div);
  }

  function showTokenForm(ref, notice) {
    bodyEl.textContent = "";
    var div = document.createElement("div");
    div.className = "crp-msg";
    var p = document.createElement("p");
    p.textContent = notice;
    div.appendChild(p);

    var form = document.createElement("form");
    form.className = "crp-token";
    var input = document.createElement("input");
    input.type = "password";
    input.placeholder = "github_pat_\u2026";
    input.setAttribute("aria-label", "GitHub token");
    input.autocomplete = "off";
    var btn = document.createElement("button");
    btn.type = "submit";
    btn.textContent = "View with token";
    form.appendChild(input); form.appendChild(btn);
    form.addEventListener("submit", function (ev) {
      ev.preventDefault();
      var t = input.value.trim();
      if (!t) return;
      setToken(t);
      show(ref);
    });
    div.appendChild(form);

    var note = document.createElement("p");
    note.className = "crp-token-note";
    note.textContent = "Use a fine-grained personal access token with read-only Contents permission, scoped to this repo. It is kept in sessionStorage for this tab only and never written into the document.";
    div.appendChild(note);

    var a = document.createElement("a");
    a.href = ref.href; a.target = "_blank"; a.rel = "noopener";
    a.textContent = "Open on GitHub instead";
    div.appendChild(a);

    bodyEl.appendChild(div);
    input.focus();
  }

  function renderLines(text, startNum, hitFrom, hitTo, note, path, live) {
    bodyEl.textContent = "";
    if (note) {
      var n = document.createElement("div");
      n.className = "crp-note";
      n.textContent = note;
      bodyEl.appendChild(n);
    }
    var lines = text.split("\n");
    if (lines.length && lines[lines.length - 1] === "") lines.pop();
    var frag = document.createDocumentFragment();
    var target = null;
    var codeEls = [];
    for (var i = 0; i < lines.length; i++) {
      var num = startNum + i;
      var row = document.createElement("div");
      row.className = "crp-line" + (hitFrom && num >= hitFrom && num <= hitTo ? " crp-hit" : "");
      if (!target && hitFrom && num >= hitFrom) target = row;
      var ln = document.createElement("span"); ln.className = "crp-ln"; ln.textContent = String(num);
      var code = document.createElement("span"); code.className = "crp-code"; code.textContent = lines[i];
      codeEls.push(code);
      row.appendChild(ln); row.appendChild(code);
      frag.appendChild(row);
    }
    bodyEl.appendChild(frag);
    if (target) {
      var y = target.offsetTop - bodyEl.clientHeight * 0.33;
      bodyEl.scrollTop = y > 0 ? y : 0;
    } else {
      bodyEl.scrollTop = 0;
    }

    // Upgrade to syntax-highlighted lines in place once highlight.js is available.
    var lang = langFor(path);
    if (!lang) return;
    loadHljs().then(function (hljs) {
      if (!hljs || !live()) return;
      if (!hljs.getLanguage(lang)) return;
      var highlighted;
      try { highlighted = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value; }
      catch (e) { return; }
      var htmlLines = splitHighlighted(highlighted);
      for (var i = 0; i < codeEls.length && i < htmlLines.length; i++) {
        codeEls[i].innerHTML = htmlLines[i];
      }
    });
  }

  function apiFetch(ref, token) {
    var url = "https://api.github.com/repos/" + ref.org + "/" + ref.repo + "/contents/" +
      encodeURIComponent(ref.path).replace(/%2F/gi, "/") + "?ref=" + encodeURIComponent(ref.commit);
    return fetch(url, {
      headers: { "Accept": "application/vnd.github.raw+json", "Authorization": "Bearer " + token }
    }).then(function (res) {
      if (!res.ok) { var e = new Error("HTTP " + res.status); e.httpStatus = res.status; throw e; }
      return res.text();
    });
  }

  function show(ref) {
    ensurePane();
    var my = ++reqId;
    titleEl.textContent = "\u200e" + ref.path;
    titleEl.title = ref.org + "/" + ref.repo + "@" + ref.commit + ":" + ref.path;
    linesEl.textContent = ref.from ? (ref.to && ref.to !== ref.from ? "L" + ref.from + "\u2013L" + ref.to : "L" + ref.from) : "";
    ghEl.href = ref.href;
    var hitFrom = ref.from || 0;
    var hitTo = ref.to || ref.from || 0;
    var key = ref.org + "/" + ref.repo + "@" + ref.commit + ":" + ref.path;
    var rawUrl = "https://raw.githubusercontent.com/" + ref.org + "/" + ref.repo + "/" + ref.commit + "/" + ref.path;
    var live = function () { return my === reqId && !!pane; };
    var resolvedSnip = null;

    function done(text, startNum, note) {
      if (!live()) return;
      renderLines(text, startNum, hitFrom, hitTo, note, ref.path, live);
    }
    function snippetCovers(s) {
      if (!s) return false;
      if (!ref.from || s.full) return true;
      var end = s.start + s.text.split("\n").length - 1;
      return ref.from >= s.start && hitTo <= end;
    }
    function fallbackAfterHttp(status) {
      if (!live()) return;
      if (resolvedSnip) {
        done(resolvedSnip.text, resolvedSnip.start, "Excerpt embedded at generation time \u2014 couldn't fetch the full file (HTTP " + status + ").");
      } else {
        showTokenForm(ref, "GitHub returned HTTP " + status + " for this file \u2014 it may be private (paste a token to view it here), or the file/commit may not exist.");
      }
    }
    function fallbackOffline() {
      if (!live()) return;
      if (resolvedSnip) {
        done(resolvedSnip.text, resolvedSnip.start, "Excerpt embedded at generation time \u2014 couldn't reach GitHub.");
      } else {
        showMessage("Couldn't reach GitHub \u2014 you may be offline.", ref.href);
      }
    }
    function fetchChain() {
      if (typeof cache[rawUrl] === "string") { done(cache[rawUrl], 1, null); return; }
      showMessage("Loading " + ref.path + " @ " + ref.commit.slice(0, 10) + "\u2026", null);
      fetch(rawUrl).then(function (res) {
        if (!res.ok) { var e = new Error("HTTP " + res.status); e.httpStatus = res.status; throw e; }
        return res.text();
      }).then(function (text) {
        cache[rawUrl] = text;
        done(text, 1, null);
      }).catch(function (err) {
        if (!live()) return;
        if (!(err && err.httpStatus)) { fallbackOffline(); return; }
        var token = getToken();
        if (!token) { fallbackAfterHttp(err.httpStatus); return; }
        apiFetch(ref, token).then(function (text) {
          cache[rawUrl] = text;
          done(text, 1, null);
        }).catch(function (err2) {
          if (!live()) return;
          if (err2 && (err2.httpStatus === 401 || err2.httpStatus === 403)) {
            showTokenForm(ref, "GitHub rejected the saved token (HTTP " + err2.httpStatus + "). Paste a token with read access to " + ref.org + "/" + ref.repo + ":");
          } else if (err2 && err2.httpStatus) {
            fallbackAfterHttp(err2.httpStatus);
          } else {
            fallbackOffline();
          }
        });
      });
    }

    resolveSnippet(key).then(function (s) {
      if (!live()) return;
      resolvedSnip = s;
      if (snippetCovers(s)) {
        done(s.text, s.start, s.full ? null : "Excerpt embedded at generation time \u2014 full file on GitHub.");
        return;
      }
      fetchChain();
    });
    pane.focus();
  }

  document.addEventListener("click", function (ev) {
    if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
    var el = ev.target && ev.target.closest ? ev.target.closest("a[href]") : null;
    if (!el) return;
    if (pane && pane.contains(el)) return; // pane-internal links always go to GitHub
    var m = BLOB.exec(el.href);
    if (!m) return;
    ev.preventDefault();
    show({
      org: m[1], repo: m[2], commit: m[3], path: m[4],
      from: m[5] ? parseInt(m[5], 10) : null,
      to: m[6] ? parseInt(m[6], 10) : null,
      href: el.href
    });
  });

  document.addEventListener("keydown", function (ev) {
    if (ev.key === "Escape" && pane) {
      var inForm = document.activeElement && pane.contains(document.activeElement) && document.activeElement.tagName === "INPUT";
      if (!inForm) closePane();
    }
  });

  window.addEventListener("resize", function () {
    if (pane) syncWidth();
  });

  if (typeof window !== "undefined" && window.__crpTest) {
    window.__crpTest({ splitHighlighted: splitHighlighted, langFor: langFor, clampWidth: clampWidth });
  }
})();
</script>
assets/template.html
<!DOCTYPE html>
<!--
  Canonical readout template (bundled asset from the readout skill).

  Start every readout from this file and fill the slots (marked with comments).
  The style[data-readout] and script[data-readout] blocks are the shared chrome —
  copy them VERBATIM and never edit them; every readout must look like every other.
  Document-specific CSS (diagram sizing, one-off content elements) goes ONLY in the
  style[data-doc] block and must not override the chrome's tokens or selectors.
  (Tag names are written selector-style in this comment on purpose: scripts that
  extract the chrome by searching for the literal opening tags must never match
  inside this comment.)

  Header anatomy (fixed): .kicker (REPO · CONTEXT · READOUT TYPE, mono uppercase),
  h1 title, .subtitle (one-line framing), .meta (date · audience · org/repo@commit pin).
  Sections: <section id="..."> with <h2>, one TOC <li> per section (class="sub" for nested).
  When the document contains GitHub blob links, inline assets/code-pane.html verbatim
  where indicated before </body>.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><!-- TITLE --></title>
<meta name="description" content="<!-- ONE-SENTENCE SUMMARY (the readouts index shows this) -->">
<style data-readout>
  :root {
    --bg: #faf8f4; --fg: #262220; --muted: #857c6e;
    --accent: #31567d; --warn: #7d6a2e;
    --hairline: #e3ddd1; --code-bg: #f0ece3;
    --tint: #f3efe7; --warn-tint: #f2eedd;
    --good: #3d6247; --bad: #8e3b3b;
    --sidebar-w: 15rem;
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --bg: #1e1c19; --fg: #e8e4dc; --muted: #948b7c;
      --accent: #8caec9; --warn: #c2ae76;
      --hairline: #383530; --code-bg: #2a2723;
      --tint: #262320; --warn-tint: #2c2a20;
      --good: #9ab894; --bad: #c98f8f;
    }
  }
  * { box-sizing: border-box; }
  html { scroll-behavior: smooth; }
  body {
    margin: 0; background: var(--bg); color: var(--fg);
    font-family: Charter, 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
    font-size: 17px; line-height: 1.65;
  }
  code, pre {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.82em;
  }
  code { background: var(--code-bg); border-radius: 4px; padding: 0.1em 0.35em; }
  pre {
    background: var(--code-bg); border: 1px solid var(--hairline); border-radius: 8px;
    padding: 0.8rem 1rem; overflow-x: auto; line-height: 1.5;
  }
  pre code { background: none; border: none; padding: 0; font-size: 1em; }
  a { color: var(--accent); text-decoration: none; }
  a:hover { text-decoration: underline; }

  .layout {
    display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
    max-width: 68rem; margin: 0 auto; gap: 2.5rem; padding: 0 1.5rem;
  }
  nav.toc {
    position: sticky; top: 0; align-self: start; height: 100vh; overflow-y: auto;
    padding: 2.75rem 0 2rem; font-size: 0.85rem;
  }
  nav.toc .toc-title {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
    font-size: 0.68rem; color: var(--muted); margin-bottom: 0.7rem;
  }
  nav.toc ol { list-style: none; margin: 0; padding: 0; }
  nav.toc li { margin: 0; }
  nav.toc a {
    display: block; color: var(--muted);
    padding: 0.28rem 0.75rem; border-left: 2px solid var(--hairline);
  }
  nav.toc a.sub { padding-left: 1.6rem; font-size: 0.8rem; }
  nav.toc a:hover { color: var(--fg); text-decoration: none; }
  nav.toc a.active { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
  main { padding: 2.75rem 0 4rem; max-width: 72ch; }

  header.doc-header { margin-bottom: 2.2rem; }
  .kicker {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    text-transform: uppercase; letter-spacing: 0.08em;
    font-size: 0.7rem; color: var(--accent); margin: 0 0 0.7rem;
  }
  h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.5rem; font-weight: 700; }
  .subtitle { color: var(--muted); font-size: 1rem; margin: 0 0 0.5rem; }
  .meta {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.72rem; color: var(--muted); margin: 0;
  }

  h2 { font-size: 1.3rem; margin: 2.6rem 0 0.9rem; padding-top: 0.8rem; border-top: 1px solid var(--hairline); }
  h3 { font-size: 1.05rem; margin: 1.6rem 0 0.5rem; }
  p { margin: 0.8rem 0; }
  ul, ol { padding-left: 1.4rem; }
  li { margin: 0.35rem 0; }

  .badge {
    display: inline-block;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.66rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
    padding: 0.12rem 0.5rem; border-radius: 999px; border: 1px solid;
    vertical-align: middle; white-space: nowrap;
  }
  .badge.good { color: var(--good); border-color: var(--good); }
  .badge.bad { color: var(--bad); border-color: var(--bad); }
  .badge.neutral { color: var(--muted); border-color: var(--hairline); }

  .callout {
    background: var(--tint); border: 1px solid var(--hairline); border-radius: 8px;
    padding: 0.85rem 1.1rem; margin: 1.3rem 0;
  }
  .callout p { margin: 0.3rem 0; }
  .callout .callout-label {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-weight: 700; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--muted); display: block; margin-bottom: 0.3rem;
  }
  .callout.warn { background: var(--warn-tint); }
  .callout.warn .callout-label { color: var(--warn); }

  details { margin: 1.2rem 0; }
  details summary { cursor: pointer; color: var(--muted); }
  details[open] summary { margin-bottom: 0.6rem; }

  .table-wrap { overflow-x: auto; margin: 1.2rem 0; }
  table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
  th, td { border: 1px solid var(--hairline); padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
  th { background: var(--code-bg); font-weight: 600; }
  td .delta { font-weight: 600; color: var(--good); white-space: nowrap; }

  figure { margin: 1.6rem 0; }
  figure svg { width: 100%; height: auto; display: block; color: var(--fg); }
  figcaption { font-size: 0.8rem; color: var(--muted); text-align: center; margin-top: 0.5rem; }

  footer.provenance {
    margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--hairline);
    font-size: 0.8rem; color: var(--muted);
  }
  footer.provenance p { margin: 0.3rem 0; }

  @media (max-width: 860px) {
    .layout { grid-template-columns: 1fr; gap: 0; }
    nav.toc {
      position: static; height: auto;
      padding: 1.5rem 0 0; border-bottom: 1px solid var(--hairline);
    }
    nav.toc a { border-left: none; padding-left: 0; }
    main { padding-top: 1.5rem; }
  }
</style>
<style data-doc>
  /* Document-specific styles ONLY (e.g. a diagram's dimensions).
     Never restyle the chrome above. */
</style>
</head>
<body>
<div class="layout">
<nav class="toc" aria-label="Table of contents">
  <div class="toc-title">Contents</div>
  <ol>
    <li><a href="#summary">Executive summary</a></li>
    <!-- one <li><a href="#section-id">…</a></li> per section; class="sub" for nested entries -->
  </ol>
</nav>
<main>
<header class="doc-header">
  <p class="kicker"><!-- REPO · CONTEXT · READOUT TYPE (e.g. WARP-SERVER · RESEARCH READOUT) --></p>
  <h1><!-- TITLE --></h1>
  <p class="subtitle"><!-- One line framing what this document covers and who it's for. --></p>
  <p class="meta"><!-- DATE · audience · code references pinned to <code>org/repo@commit</code> --></p>
</header>

<section id="summary">
  <h2>Executive summary</h2>
  <!-- A few sentences a reader can stop after. -->
</section>

<!-- <section id="..."> blocks; <h2> per section, <h3> for subsections.
     Use .callout / .badge / .table-wrap / figure per the doc guide. -->

<footer class="provenance">
  <p><strong>Provenance.</strong> <!-- date, source conversation/investigation, repos @ commits, verification caveats --></p>
</footer>
</main>
</div>
<script data-readout>
(function () {
  var links = Array.prototype.slice.call(document.querySelectorAll("nav.toc a"));
  if (!links.length || !("IntersectionObserver" in window)) return;
  var map = {};
  links.forEach(function (a) {
    var href = a.getAttribute("href") || "";
    if (href.charAt(0) === "#") map[href.slice(1)] = a;
  });
  var current = null;
  var observer = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        if (current) current.classList.remove("active");
        current = map[entry.target.id];
        if (current) current.classList.add("active");
      }
    });
  }, { rootMargin: "0px 0px -70% 0px" });
  Object.keys(map).forEach(function (id) {
    var el = document.getElementById(id);
    if (el) observer.observe(el);
  });
})();
</script>
<!-- Inline assets/code-pane.html VERBATIM here when the document contains GitHub blob links. -->
</body>
</html>
references/doc-guide.md
# Readout document guide

A readout is the durable artifact of a conversation-length investigation. The reader may be the author weeks later, or a teammate who has none of the conversation's context. Every choice below serves standalone readability.

## The one hard requirement: self-contained

One `.html` file. All CSS and JS inline; no CDN links, no external fonts, no external images (use inline SVG or data URIs). Readouts get shared over Slack and opened offline — a broken external dependency silently ruins the document. System font stacks look good everywhere and avoid font embedding entirely. (Sanctioned exceptions, all inside the bundled code pane and all click-time: fetching source from GitHub, and lazy-loading highlight.js from its CDN when the pane opens. The document itself must still render completely offline.)

## The canonical template — chrome is fixed

Readouts share one visual identity so the library reads as a set; small per-document styling drift is a bug. Start every document from `assets/template.html` (in this skill's directory, next to `references/`) and fill its slots:

- The `<style data-readout>` and `<script data-readout>` blocks are the shared chrome — copy them **verbatim** and never edit them. They fix the warm-paper serif typography and palette (light + dark), the sticky Contents rail with scrollspy, the header anatomy, section rules, callouts, badges, tables, figures, collapsibles, and the provenance footer.
- The header anatomy is fixed: a mono uppercase `.kicker` (repo · context · readout type), the `h1` title, a one-line `.subtitle` framing the document, and a mono `.meta` line (date · audience · `org/repo@commit` pin).
- Use the template's classes — `.callout` (+ `.callout-label`, `.warn`), `.badge` (`good`/`bad`/`neutral`), `.table-wrap`, `figure`/`figcaption`, `<details>` — rather than inventing parallel patterns.
- Document-specific CSS (a diagram's dimensions, a one-off content element) goes ONLY in the empty `<style data-doc>` block, and must not override the chrome's tokens or selectors.

## Linked code references

When the repository's host and the examined commit are known, make every `file.rs:123` reference a real hyperlink into the code — e.g. `https://github.com/<org>/<repo>/blob/<commit>/<path>#L123`, with `#L10-L25` for ranges, and link code-snippet captions the same way. Pin links to the commit, never a branch, so they stay valid as the code moves. Get the remote and commit from `git remote get-url origin` / `git rev-parse HEAD` when the repo is checked out; otherwise use whatever the source material states. If the host or commit is unknown, keep references as plain monospace text — never guess URLs.

### The code pane

Linked references get an in-document source viewer: clicking a GitHub blob link opens the code in a split pane on the right, pushing the document content aside rather than overlaying it; clicking another reference loads into the same pane; the pane is resizable by dragging its left edge; a close button (and Escape) dismisses it. Code is syntax-highlighted (highlight.js, loaded lazily from a CDN only when the pane opens — plain text when offline).

Do not write this viewer yourself — inline the bundled asset at `assets/code-pane.html` (in this skill's directory, next to `references/`) **verbatim** immediately before `</body>`. It auto-attaches to every GitHub blob link in the document, so references need no extra markup — which is also why they must stay ordinary anchors: with JS disabled (or on cmd/ctrl-click) the links simply open GitHub.

How the pane finds source, in order:
1. **Files embedded at generation time** — the only path that works offline and for private repos with zero reader setup, so do it whenever a referenced repo is checked out locally. After writing the doc, run the bundled helper:
   `python3 <skill-dir>/scripts/embed_snippets.py <doc.html> --repo <checkout> [--repo <another-checkout>]`
   It scans the doc's blob links, extracts each referenced file *at its pinned commit* (`git show <commit>:<path>`), embeds whole files (gzip+base64 for anything non-trivial — the pane inflates in-browser), and injects the `data-code-snippets` blob. Mind the audience before embedding: it ships that source inside a shareable file. Only hand-write the JSON blob (`{"org/repo@commit:path": {"start": N, "text": "..."}}` windows) if git isn't available.
2. **Runtime fetch** of `raw.githubusercontent.com` when the reader clicks — works for public repos online.
3. **Reader-supplied token** — when the fetch fails with an HTTP error, the pane offers a paste-a-token form and retries via `api.github.com`, so teammates with repo access can view private code even without embedded snippets. The token stays in the reader's `sessionStorage` for that tab; it is never part of the document — never embed tokens or credentials of any kind in a readout.
4. **Graceful fallback** — a cause-specific message plus an "open on GitHub" link.
The asset handles 2–4 itself; your job is 1.

## Content

- **Document the refined end-state, not the chronology.** Conversations wander and self-correct; the readout presents the final, corrected understanding. Keep a discarded belief only when it's an instructive gotcha — those earn callouts.
- **Curate.** Length should track information density, not conversation length. Cut anything the reader doesn't need; a readout is not a transcript.
- **Ground every claim.** Cite `file.go:123`-style references, function and type names, endpoints. Verify references against the codebase before asserting them — a doc with a wrong line number loses the reader's trust for all the right ones too.
- **Distinguish verified from inferred.** If something was concluded in conversation but not confirmed in code, say so.
- **Open with an executive summary** — a few sentences a reader can stop after and still have the headline understanding.
- **Give the document a one-sentence `<meta name="description" content="...">`** — the readouts index page (`~/.readouts/index.html`, regenerated by `scripts/update_index.py`) uses it as the entry's summary line.
- **Close with a provenance footer**: date, what conversation/investigation it came from, which repos (and commit, if relevant) were examined.

## Structure and layout

The chrome is fixed; the sectioning is yours. Let the material choose it:

- Cross-platform / cross-system findings → per-system sections plus a comparison matrix
- A "how does X work" investigation → narrative explainer following the data flow
- A decision or tradeoff discussion → options, criteria, recommendation

Elements that usually earn their place in longer documents (all already styled by the template):

- Tables for anything the reader will want to compare across columns — for before/after numbers, add an explicit delta column (`<span class="delta">`)
- `<details>` collapsibles for deep-dive appendices that would bloat the main read
- `.badge` chips for statuses, HTTP codes, platform names
- `.callout` with a `.callout-label` for gotchas, cautions, and instructive discarded theories — the template styles these as a soft tint with a full hairline border; never restyle them into a left-accent stripe (a thick colored left border as the sole edge treatment is banned in readouts)
- Diagrams as inline SVG when a flow or topology is central to understanding — draw with `currentColor` so both palettes work, and size them in the `data-doc` style block

## Reading experience

The template handles column width, responsive collapse, and both palettes. What remains your responsibility:

- Wrap wide tables in `.table-wrap` so they scroll instead of overflowing on phones
- Every TOC entry must point at a real `<section id>`; nested entries use `class="sub"`
- Monospace for code, paths, and identifiers; if you add syntax highlighting to code blocks in the document body, it must be inline (the code pane handles its own highlighting via its lazy CDN loader)

## JavaScript

Progressive enhancement only — scrollspy, collapsibles, theme toggles, and the bundled code pane. The document must read fine with JS disabled.

## Before you finish

Sanity-check the artifact: parse the file (e.g. with Python's `html.parser`) to catch unclosed tags, confirm there are zero external `http(s)://` resource references outside the bundled code-pane asset, and skim the rendered structure for empty sections or placeholder text left behind.
scripts/embed_snippets.py
#!/usr/bin/env python3
"""Embed referenced source files into a readout's code-pane snippet blob.

Scans an HTML readout for GitHub blob links, extracts each referenced file at its
pinned commit from a local git checkout (git show <commit>:<path>), and injects a
<script type="application/json" data-code-snippets> blob that the bundled code
pane reads. Files are embedded whole; anything larger than a couple of KB is
gzip+base64'd ("gz"), which the pane inflates in the browser via
DecompressionStream. This makes the pane work offline and for private repos with
zero reader setup.

Usage:
  python3 embed_snippets.py <doc.html> --repo <checkout-path> [--repo <path> ...]

Each --repo must be a git checkout; its origin remote determines which org/repo
links it can satisfy. Re-running replaces any existing snippet blob.
"""
import argparse
import base64
import gzip
import json
import re
import subprocess
import sys
from pathlib import Path

BLOB_RE = re.compile(r'https://github\.com/([^/"\s<>]+)/([^/"\s<>]+)/blob/([^/"\s<>]+)/([^"#\s<>?]+)')
# Requires "{" right after the tag so the mention of this tag inside the code-pane
# asset's header comment can never match (a real blob always starts with JSON).
SNIPPET_BLOCK_RE = re.compile(
    r'<script type="application/json" data-code-snippets>\s*\{.*?</script>\n?', re.S
)
GZ_THRESHOLD = 2048


def repo_slug(checkout: Path):
    """(org, repo) from the checkout's origin remote, or None."""
    r = subprocess.run(
        ["git", "-C", str(checkout), "remote", "get-url", "origin"],
        capture_output=True, text=True,
    )
    if r.returncode != 0:
        return None
    m = re.search(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?/?$", r.stdout.strip())
    return (m.group(1), m.group(2)) if m else None


def file_at(checkout: Path, commit: str, path: str):
    """File contents at the pinned commit, falling back to HEAD. -> (text, ref) or None."""
    for ref in (commit, "HEAD"):
        r = subprocess.run(
            ["git", "-C", str(checkout), "show", f"{ref}:{path}"],
            capture_output=True,
        )
        if r.returncode == 0:
            try:
                return r.stdout.decode("utf-8"), ref
            except UnicodeDecodeError:
                return None  # binary file; nothing sensible to embed
    return None


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("doc", type=Path, help="readout HTML file to modify in place")
    ap.add_argument("--repo", action="append", type=Path, default=[], required=True,
                    help="git checkout that can satisfy links (repeatable)")
    args = ap.parse_args()

    html = args.doc.read_text()
    refs = {m for m in BLOB_RE.findall(html)}
    if not refs:
        print("No GitHub blob links found; nothing to embed.")
        return 0

    slugs = {}
    for checkout in args.repo:
        slug = repo_slug(checkout)
        if slug is None:
            print(f"warning: {checkout} has no GitHub origin remote; skipping", file=sys.stderr)
            continue
        slugs.setdefault(slug, checkout)

    snippets, missing, from_head = {}, [], []
    for org, repo, commit, path in sorted(refs):
        label = f"{org}/{repo}@{commit[:10]}:{path}"
        checkout = slugs.get((org, repo))
        if checkout is None:
            missing.append(f"{label} (no --repo checkout for {org}/{repo})")
            continue
        got = file_at(checkout, commit, path)
        if got is None:
            missing.append(f"{label} (not found in {checkout}, or binary)")
            continue
        text, ref_used = got
        if ref_used != commit:
            from_head.append(label)
        entry = {"start": 1, "full": True}
        raw = text.encode("utf-8")
        if len(raw) > GZ_THRESHOLD:
            entry["gz"] = base64.b64encode(gzip.compress(raw, 9)).decode("ascii")
        else:
            entry["text"] = text
        snippets[f"{org}/{repo}@{commit}:{path}"] = entry

    if not snippets:
        print("Nothing could be embedded:", file=sys.stderr)
        for m in missing:
            print(f"  - {m}", file=sys.stderr)
        return 1

    # "</" must not appear literally inside a <script> block; "<\/" is the same string in JSON.
    payload = json.dumps(snippets, ensure_ascii=False).replace("</", "<\\/")
    block = f'<script type="application/json" data-code-snippets>\n{payload}\n</script>\n'

    if SNIPPET_BLOCK_RE.search(html):
        html = SNIPPET_BLOCK_RE.sub(lambda _: block, html, count=1)
        where = "replaced existing blob"
    elif "<style data-code-pane>" in html:
        html = html.replace("<style data-code-pane>", block + "<style data-code-pane>", 1)
        where = "inserted before code-pane asset"
    elif "</body>" in html:
        html = html.replace("</body>", block + "</body>", 1)
        where = "inserted before </body>"
    else:
        html = html + "\n" + block
        where = "appended (no </body> found)"

    args.doc.write_text(html)

    print(f"Embedded {len(snippets)} file(s) into {args.doc} ({where}); blob is {len(payload):,} bytes.")
    if from_head:
        print("warning: pinned commit not found locally; embedded from HEAD instead (content may drift from links):", file=sys.stderr)
        for m in from_head:
            print(f"  - {m}", file=sys.stderr)
    if missing:
        print("warning: could not embed (pane will fall back to fetch/token):", file=sys.stderr)
        for m in missing:
            print(f"  - {m}", file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())
scripts/update_index.py
#!/usr/bin/env python3
"""Regenerate the readouts index page.

Scans a readouts directory for .html files (excluding index.html), pulls each
document's <title>, <meta name="description">, and date (filename YYYY-MM-DD
prefix, else mtime), and writes a styled index.html listing every readout
newest-first. The index is fully regenerated on every run, so it is always
safe to re-run and never drifts from the directory contents.

Usage:
  python3 update_index.py [readouts_dir]     (default: ~/.readouts)
"""
import html
import re
import sys
from datetime import datetime
from pathlib import Path

TITLE_RE = re.compile(r"<title>(.*?)</title>", re.S | re.I)
DESC_RE = re.compile(r'<meta\s+name="description"\s+content="(.*?)"', re.S | re.I)
DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")

PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Readouts</title>
<style>
  :root { --bg: #faf8f4; --fg: #262220; --muted: #857c6e; --hairline: #e3ddd1; --accent: #31567d; }
  @media (prefers-color-scheme: dark) {
    :root { --bg: #1e1c19; --fg: #e8e4dc; --muted: #948b7c; --hairline: #383530; --accent: #8caec9; }
  }
  body {
    margin: 0; background: var(--bg); color: var(--fg);
    font-family: Charter, 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
    line-height: 1.6;
  }
  main { max-width: 44rem; margin: 0 auto; padding: 3rem 1.25rem 4rem; }
  h1 { font-size: 1.6rem; margin: 0 0 0.3rem; }
  .sub { color: var(--muted); font-size: 0.9rem; margin: 0 0 2rem; }
  ol { list-style: none; margin: 0; padding: 0; }
  li { padding: 1rem 0; border-top: 1px solid var(--hairline); }
  li:last-child { border-bottom: 1px solid var(--hairline); }
  .date {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.72rem; color: var(--muted); letter-spacing: 0.04em;
  }
  a.title { color: var(--fg); font-size: 1.05rem; font-weight: 600; text-decoration: none; display: block; margin: 0.15rem 0; }
  a.title:hover { color: var(--accent); }
  p.desc { margin: 0.1rem 0 0; color: var(--muted); font-size: 0.9rem; }
  p.empty { color: var(--muted); }
  footer { margin-top: 2.5rem; color: var(--muted); font-size: 0.78rem; }
</style>
</head>
<body>
<main>
<h1>Readouts</h1>
<p class="sub">__COUNT__ document__PLURAL__ &middot; newest first</p>
<ol>
__ITEMS__
</ol>
<footer>Index regenerated __UPDATED__ by the readout skill. Run <code>update_index.py</code> to refresh.</footer>
</main>
</body>
</html>
"""

ITEM = """  <li>
    <span class="date">__DATE__</span>
    <a class="title" href="__FILE__">__TITLE__</a>
__DESC__  </li>"""


def entry(p: Path):
    src = p.read_text(errors="replace")
    m = TITLE_RE.search(src)
    title = html.unescape(m.group(1).strip()) if m else p.stem
    m = DESC_RE.search(src)
    desc = html.unescape(m.group(1).strip()) if m else ""
    m = DATE_RE.match(p.name)
    date = m.group(1) if m else datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d")
    return {"file": p.name, "title": title, "desc": desc, "date": date, "mtime": p.stat().st_mtime}


def main() -> int:
    d = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else Path.home() / ".readouts"
    d.mkdir(parents=True, exist_ok=True)
    docs = [entry(p) for p in sorted(d.glob("*.html")) if p.name != "index.html"]
    docs.sort(key=lambda e: (e["date"], e["mtime"]), reverse=True)

    items = []
    for e in docs:
        desc_html = f'    <p class="desc">{html.escape(e["desc"])}</p>\n' if e["desc"] else ""
        items.append(
            ITEM.replace("__DATE__", e["date"])
                .replace("__FILE__", html.escape(e["file"], quote=True))
                .replace("__TITLE__", html.escape(e["title"]))
                .replace("__DESC__", desc_html)
        )
    body = "\n".join(items) if items else '  <p class="empty">No readouts yet.</p>'

    page = (PAGE.replace("__COUNT__", str(len(docs)))
                .replace("__PLURAL__", "" if len(docs) == 1 else "s")
                .replace("__ITEMS__", body)
                .replace("__UPDATED__", datetime.now().strftime("%Y-%m-%d %H:%M")))
    (d / "index.html").write_text(page)
    print(f"Indexed {len(docs)} readout(s) -> {d / 'index.html'}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
SKILL.md
---
name: readout
description: Produce a polished, self-contained HTML "readout" document under ~/.readouts (with an auto-maintained index page), either by snapshotting the findings accumulated in the current conversation or — when invoked fresh, e.g. "/readout on how github webhook events are processed" — by sharpening scope with clarifying questions and researching the codebase before documenting. The work runs in a child agent so the main conversation's context stays clean. Use whenever the user invokes /readout, says "write this up", "turn this into a doc/page", "make a readout", or asks for a readable, shareable document capturing findings or explaining how something works.
---

# Readout

A readout turns an investigation into a durable HTML document someone can read weeks later without any of the original context. It starts one of two ways:

- **Snapshot mode** — invoked mid-conversation ("write this up"): the conversation's accumulated findings are the source material.
- **Research mode** — invoked fresh ("/readout on how github webhook events are processed in the server"): there is no conversation to mine, so the investigation itself is part of the job.

Either way, invoking this skill is a **side task**. Your job as the main agent is to sharpen the scope, launch a child agent with a good brief, and get out of the way — the child does the mining/research and the writing, keeping that (often large) work out of your context window.

## Orchestrator workflow

### 1. Sharpen the scope — ask before launching

A vague brief produces a vague document. Before launching you should be able to list the specific questions the document will answer; if you can't, interview the user first:

- Ask 2–4 targeted questions, offering concrete options rather than open prompts — take a quick look at the code or topic first so the options are real (subsystems, entry points, competing concerns). For "/readout on how github webhook events are processed": which direction matters — inbound triggers, post-back, or both? a current-state reference or a gotcha hunt? which repo(s)?
- Always pin down **depth and audience**: high-level orientation vs. deep mechanics with line-level grounding; personal notes vs. shared with the team.
- Respect a shrug. "Just a high-level overview" is a valid answer — record it in the brief and move on rather than interrogating. Even then, try to extract the two or three questions the reader most needs answered; specificity is what makes a readout useful.
- Skip the interview when the scope is already specific — a snapshot of a focused conversation, or a precise research request, needs no questions. In snapshot mode the conversation usually supplies the questions; ask only when the invocation is ambiguous about which threads to include.

### 2. Compose the brief

Write a short brief (roughly 10–20 lines) carrying **pointers, not payloads**:

- A working title / topic, and the mode (snapshot or research)
- The specific questions the document must answer (from the conversation or the interview), plus depth and audience
- Scope: which threads/subsystems to cover, and anything to explicitly exclude
- Snapshot mode: headline conclusions worth centering the doc on, one line each — the child pulls the full content from conversation history itself, so don't paste findings wholesale
- Research mode: starting pointers — entry-point files, symbols, or directories you already know about
- Absolute paths to the repos/directories that ground the work
- Each repo's hosted URL and the examined commit when known (e.g. `github.com/org/repo @ abc123`), so the document can hyperlink code references

### 3. Launch one local child agent

Spawn exactly one child agent via `run_agents`, **local** execution. Local matters: the document lands on the user's filesystem and opens in their browser. Name the child `readout-<topic-slug>`.

Build the child's prompt from the template below. It must include:

- The brief
- The source-material block matching the mode (snapshot mode also needs your agent run ID — `current_run_id` from the orchestration runtime context — so the child can mine the parent conversation with `search_conversation_history`)
- The instruction to read `references/doc-guide.md` from this skill's directory before writing
- The output path convention and completion protocol

### 4. Get back to work

After launching, resume whatever you were doing, or end your turn — the child's completion message arrives on its own; relay the file path to the user with a one-line description when it does. In research mode a fresh conversation may have nothing else pending; just end the turn. Don't sit in a wait loop unless the user asked to wait for the document.

## Child agent prompt template

Adapt this; keep the structure, and include the source-material block that matches the mode.

```
You are producing a "readout": a single self-contained HTML document that answers a
specific set of questions about <topic>, for a reader who has none of this context.

Brief:
<brief — including the questions to answer, depth, and audience>

Source material (snapshot mode):
- The parent conversation: agent run ID <current_run_id>. Use search_conversation_history
  with agent_run_id set to that ID. Make several targeted queries — one per question in
  the brief — rather than one broad query; targeted queries surface far more usable detail.
- The codebase(s) at <absolute paths>. The conversation is your starting point, not a cage:
  verify file references before asserting them, and where a section needs more depth to
  stand on its own, go read the code and fill the gap.

Source material (research mode):
- Investigate directly in the codebase(s) at <absolute paths>. Let the brief's questions
  drive the investigation: trace the actual code paths, read the real implementations, and
  ground every claim in file:line references. Distinguish verified from inferred. Do not
  pad the document with generic knowledge — its value is what's true of THIS codebase.

- Repo host + commit for linked code references, if known: <github.com/org/repo @ commit>
  (otherwise derive from git; see the doc guide's "Linked code references").

Start from the canonical template at <skill-directory>/assets/template.html — its
data-readout chrome blocks must be copied verbatim so every readout looks like every
other. Before writing, read <skill-directory>/references/doc-guide.md and follow it.

Output:
- Write ONE self-contained HTML file to ~/.readouts/<YYYY-MM-DD>-<topic-slug>.html
  (create ~/.readouts if it doesn't exist; suffix -2, -3, ... if the name is taken;
  get the date from `date +%F`).
- Embed referenced source per the doc guide when a repo is checked out
  (<skill-directory>/scripts/embed_snippets.py).
- Refresh the readouts index: python3 <skill-directory>/scripts/update_index.py
  (fully regenerates ~/.readouts/index.html listing every readout).
- When the file is written, open it with `open <path>` (skip this if the environment is
  headless).
- Report back to your orchestrator: the absolute file path, a 2–3 sentence summary of what
  the document covers, and anything you could not verify.
```

## Fallbacks

- **Child spawning unavailable or denied**: produce the document yourself, following `references/doc-guide.md`. If a research subagent is available, delegate the conversation-mining or code investigation to it so your context still stays lean.
- **Child can't search conversation history** (snapshot mode; it will report this back): reply to the child with a distilled dump of the findings so it can proceed — this is the one case where payload-in-prompt is the right call.
- **User-provided material instead of a conversation** (transcripts, files, links): treat that material as the source; everything else in the workflow is unchanged.