From 825e607bf62455408b5394d5b4b2149af078ec0f Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Thu, 17 Sep 2026 09:59:18 -0700 Subject: [PATCH 1/4] fix: generate the README skills table, so a synced skill needs no hand edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools table is regenerated when a sync is applied; the skills table was not, so every skill added in the monorepo failed `host/readme.test.ts` here until someone wrote its row by hand. It has happened twice — `browser-evidence` (currents-dev/currents#3779) is the second — and each time it reds the sync check on the monorepo PR that adds the skill, for a reason in this repository. `sync-readme-tools.mjs` now writes both tables. The skills rows come from `loadSkills()`, which the build and the tests already read, rather than from a regex over source: it resolves the name off the directory and refuses a skill whose frontmatter disagrees, so a generated link cannot point at a directory that is not there. The row text is the description's first sentence, the rule the tools table already applies. A skill's description carries the phrases that make an agent reach for it after that sentence, which is guidance for a model rather than a table of contents. This rewrites the existing `collect-evidence` row, which had been shortened by hand. `--check` covers both tables and names the one that drifted. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +- mcp-server/scripts/sync-readme-tools.mjs | 132 +++++++++++++++-------- 2 files changed, 91 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 7adc4a8..5c0a87f 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,10 @@ Give your AI coding agents full visibility into your CI test results. The Curren Agent skills that teach AI agents multi-step Currents workflows. -| Skill | Description | -| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| [`collect-evidence`](skills/collect-evidence) | Show that implemented work works, or demo it, using CI test artifacts — before/after screenshots, text attachments, videos, traces. | -| [`browser-evidence`](skills/browser-evidence) | Reproduce a bug in a browser you drive yourself, fix it, and post before/after evidence — for a change no CI test covers. | +| Skill | Description | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`browser-evidence`](skills/browser-evidence) | Prove a change works in a browser you drove yourself, when there is no test to run — reproduce a bug, fix it, and post before/after evidence on a pull request or issue. | +| [`collect-evidence`](skills/collect-evidence) | Show that work you implemented actually works, or demo it, using artifacts from tests running in CI via Currents — before/after screenshots, text and JSON attachments, videos, traces, and GIFs. | The server publishes every skill as an MCP resource, so a connected agent can list and read them without a local copy: diff --git a/mcp-server/scripts/sync-readme-tools.mjs b/mcp-server/scripts/sync-readme-tools.mjs index f57c3ed..3779d44 100644 --- a/mcp-server/scripts/sync-readme-tools.mjs +++ b/mcp-server/scripts/sync-readme-tools.mjs @@ -1,5 +1,12 @@ /** - * Regenerates the tools table in README.md from the actual registered tools. + * Regenerates the tools and skills tables in README.md from the source of + * truth for each: the registrations in `server.ts`, and the skill directories + * `loadSkills` reads. + * + * Both tables are generated because both are checked. `host/readme.test.ts` + * fails when either drifts from what the server carries, and a sync applies + * this script, so a tool or skill arriving from the monorepo updates its row + * here rather than needing one written by hand in this repository. * * Usage: node scripts/sync-readme-tools.mjs [--check] * --check exit with code 1 if the README is out of date (useful in CI) @@ -8,7 +15,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { register } from "node:module"; +import { loadSkills } from "./load-skills.mjs"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const readmePath = join(root, "..", "README.md"); @@ -62,15 +69,25 @@ const serverSrc = readFileSync(join(root, "src", "server.ts"), "utf-8"); const toolRegex = /(['"])(currents-[A-Za-z0-9_./-]+)\1\s*,\s*\{\s*description:\s*(['"])((?:\\.|(?!\3)[^\\])*)\3/g; +/** + * The lead sentence of a description, for a table cell. Both catalogs carry + * more than that in their descriptions — a tool's names its arguments, a + * skill's continues into the phrases that make an agent reach for it — and + * neither belongs in a table of contents. + * + * @param {string} description + * @returns {string} + */ +function firstSentence(description) { + const lead = description.split(/\.\s/)[0]; + return lead.endsWith(".") ? lead : lead + "."; +} + let match; while ((match = toolRegex.exec(serverSrc)) !== null) { const name = match[2]; const description = match[4].replace(/\\(['"])/g, '$1'); - const firstSentence = description.split(/\.\s/)[0]; - const shortDesc = firstSentence.endsWith(".") - ? firstSentence - : firstSentence + "."; - registeredTools.push({ name, shortDesc }); + registeredTools.push({ name, shortDesc: firstSentence(description) }); } if (registeredTools.length === 0) { @@ -78,56 +95,81 @@ if (registeredTools.length === 0) { process.exit(1); } -// ── Build the markdown table ──────────────────────────────────── -const nameColWidth = Math.max( - "Tool".length, - ...registeredTools.map((t) => `\`${t.name}\``.length) -); -const descColWidth = Math.max( - "Description".length, - ...registeredTools.map((t) => t.shortDesc.length) -); - +// ── Build the markdown tables ─────────────────────────────────── +// Padded to the widest cell, which is what the tables in this README already +// look like. Nothing reformats them afterwards: `npm run format` covers `src` +// and `../skills`, not the README. const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length)); -const header = `| ${pad("Tool", nameColWidth)} | ${pad("Description", descColWidth)} |`; -const separator = `| ${"-".repeat(nameColWidth)} | ${"-".repeat(descColWidth)} |`; -const rows = registeredTools.map( - (t) => - `| ${pad(`\`${t.name}\``, nameColWidth)} | ${pad(t.shortDesc, descColWidth)} |` -); +function markdownTable(headings, rows) { + const widths = headings.map((heading, column) => + Math.max(heading.length, ...rows.map((row) => row[column].length)), + ); + const line = (cells) => + `| ${cells.map((cell, column) => pad(cell, widths[column])).join(" | ")} |`; + return [ + line(headings), + `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`, + ...rows.map(line), + ].join("\n"); +} -const table = [header, separator, ...rows].join("\n"); +const toolsTable = markdownTable( + ["Tool", "Description"], + registeredTools.map((tool) => [`\`${tool.name}\``, tool.shortDesc]), +); -// ── Splice the table into README.md ───────────────────────────── -const readme = readFileSync(readmePath, "utf-8"); -const tableStart = readme.indexOf("| Tool"); +// The name is the directory name — `load-skills.mjs` refuses a skill whose +// frontmatter disagrees with it — so the link cannot point at a directory that +// is not there. `host/readme.test.ts` reads the name back out of this link. +const skillsTable = markdownTable( + ["Skill", "Description"], + loadSkills().map((skill) => [ + `[\`${skill.name}\`](skills/${skill.name})`, + firstSentence(skill.description), + ]), +); -if (tableStart === -1) { - const msg = 'Cannot find tools table anchor ("| Tool") in README.md'; - if (checkOnly) { - console.error(`${msg} — cannot verify table freshness.`); - } else { - console.error(`${msg} — cannot update.`); +// ── Splice the tables into README.md ──────────────────────────── +let readme = readFileSync(readmePath, "utf-8"); +const stale = []; + +for (const [heading, table, label] of [ + ["Tool", toolsTable, "tools"], + ["Skill", skillsTable, "skills"], +]) { + // Anchored to the start of a line, so a description that happens to contain + // the heading cannot be mistaken for the table it belongs to. + const anchor = new RegExp(`^\\| ${heading}[ |]`, "m").exec(readme); + if (!anchor) { + console.error( + `Cannot find the ${label} table header ("| ${heading}") in README.md.`, + ); + process.exit(1); } - process.exit(1); + const start = anchor.index; + const blankLine = readme.indexOf("\n\n", start); + const end = blankLine === -1 ? readme.length : blankLine; + if (readme.slice(start, end) === table) { + continue; + } + stale.push(label); + readme = readme.slice(0, start) + table + readme.slice(end); } -const tableEndMarker = readme.indexOf("\n\n", tableStart); -const tableEnd = tableEndMarker === -1 ? readme.length : tableEndMarker; - -const oldTable = readme.slice(tableStart, tableEnd); - -if (oldTable === table) { - console.log("README.md tools table is up to date."); +if (stale.length === 0) { + console.log("README.md tools and skills tables are up to date."); process.exit(0); } if (checkOnly) { - console.error("README.md tools table is out of date. Run: npm run sync-readme"); + console.error( + `README.md ${stale.join(" and ")} table out of date. Run: npm run sync-readme`, + ); process.exit(1); } -const updated = readme.slice(0, tableStart) + table + readme.slice(tableEnd); -writeFileSync(readmePath, updated); -console.log(`README.md updated with ${registeredTools.length} tools.`); +writeFileSync(readmePath, readme); +console.log( + `README.md updated: ${registeredTools.length} tools, ${loadSkills().length} skills.`, +); From 0c8a6b6cee372226c550a164e91363e8dcedf43a Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Thu, 17 Sep 2026 10:18:23 -0700 Subject: [PATCH 2/4] fix: harden the README generator against cells and names it cannot render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases the tables would render wrong, none of which any current description or skill reaches — so no row changes. A `|` in a description closes its cell early and adds a column. Nothing catches it: `host/readme.test.ts` reads names out of the first cell and never looks at the shape of the row. Cells are escaped before the widths are measured. `firstSentence` broke on every period followed by a space, so a description reading "Use e.g. the runId" became the row "Use e.g.", and `!` or `?` ended no sentence at all. A break is now punctuation, space, then a capital — which keeps "git branch, etc. Requires projectId" splitting where it should, and leaves an abbreviation or a decimal alone, because those continue in lower case or in a digit. A skill named `visual#diff` rendered a link to `skills/visual`, the `#` being a fragment. Rejected at load rather than encoded at use: `encodeURIComponent` leaves `(` and `)`, which close a markdown link destination, and the name also goes into a `skill://` URI. The class is the one `host/readme.test.ts` already reads a name back with, so a name outside it would be missing from the table it was just written to. Co-Authored-By: Claude Opus 5 (1M context) --- mcp-server/scripts/load-skills.mjs | 12 ++++++++++++ mcp-server/scripts/sync-readme-tools.mjs | 25 ++++++++++++++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/mcp-server/scripts/load-skills.mjs b/mcp-server/scripts/load-skills.mjs index 725a062..2a6fd71 100644 --- a/mcp-server/scripts/load-skills.mjs +++ b/mcp-server/scripts/load-skills.mjs @@ -91,6 +91,18 @@ export function loadSkills() { `skills/${dirName}/SKILL.md declares name "${name}"; it must match the directory`, ); } + // The name is put into a `skill://` URI and into a markdown link in the + // README, so it has to be safe in both. Letters, digits, `_` and `-` are, + // and encoding at the point of use would not be enough: `encodeURIComponent` + // leaves `(` and `)` alone and those close a markdown link destination + // early. It is also the class `host/readme.test.ts` reads a name back with, + // so a name outside it would be absent from the table it was just written + // to. + if (!/^[\w-]+$/.test(name)) { + throw new Error( + `skills/${dirName} must be named with letters, digits, "_" or "-"`, + ); + } return { name, description, files }; }); diff --git a/mcp-server/scripts/sync-readme-tools.mjs b/mcp-server/scripts/sync-readme-tools.mjs index 3779d44..d135358 100644 --- a/mcp-server/scripts/sync-readme-tools.mjs +++ b/mcp-server/scripts/sync-readme-tools.mjs @@ -75,12 +75,20 @@ const toolRegex = * skill's continues into the phrases that make an agent reach for it — and * neither belongs in a table of contents. * + * A break is `.`, `!` or `?`, then space, then a capital. The capital is what + * keeps "based on conditions like test title, file path, git branch, etc. + * Requires projectId" splitting where it should while leaving "e.g. the runId" + * and "1.5 times" alone — an abbreviation and a decimal continue in lower case + * or in a digit, where a new sentence does not. It is a rule about the shape of + * the text rather than a list of abbreviations, which the next abbreviation + * would not be on. + * * @param {string} description * @returns {string} */ function firstSentence(description) { - const lead = description.split(/\.\s/)[0]; - return lead.endsWith(".") ? lead : lead + "."; + const lead = description.split(/(?<=[.!?])\s+(?=[A-Z])/)[0].trimEnd(); + return /[.!?]$/.test(lead) ? lead : lead + "."; } let match; @@ -102,15 +110,20 @@ if (registeredTools.length === 0) { const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length)); function markdownTable(headings, rows) { + // A `|` in a cell would close it early and add a column, which no test here + // would catch: `host/readme.test.ts` reads names out of the first cell and + // never looks at the shape of the row. Nothing in either catalog carries one + // today, and a description is free text that one day will. + const cells = rows.map((row) => row.map((cell) => cell.replaceAll("|", "\\|"))); const widths = headings.map((heading, column) => - Math.max(heading.length, ...rows.map((row) => row[column].length)), + Math.max(heading.length, ...cells.map((row) => row[column].length)), ); - const line = (cells) => - `| ${cells.map((cell, column) => pad(cell, widths[column])).join(" | ")} |`; + const line = (row) => + `| ${row.map((cell, column) => pad(cell, widths[column])).join(" | ")} |`; return [ line(headings), `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`, - ...rows.map(line), + ...cells.map(line), ].join("\n"); } From bb5a2694e13086350db0f1131f500f45ef5b4faa Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Thu, 17 Sep 2026 10:32:44 -0700 Subject: [PATCH 3/4] fix: check the generated tables in verify, and escape a cell's backslashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing ran `--check`. `npm run verify`, CI and the sync all regenerate or ignore the tables rather than failing on a stale one, and `host/readme.test.ts` compares names, not the description beside them — so editing a frontmatter description, or editing a generated row by hand, left a README that disagreed with its source and passed everything. `verify` now runs the check, which is where a developer meets it and where the sync meets it after applying the script. The cell escape put a backslash before `|` without escaping the backslashes already there, so a description reading `a \| b` became `a \\| b` — a literal backslash and then a live delimiter, which is the corruption the escape exists to prevent. Backslashes are escaped first. No description in either catalog carries one today, so no row changes. Co-Authored-By: Claude Opus 5 (1M context) --- mcp-server/package.json | 3 ++- mcp-server/scripts/sync-readme-tools.mjs | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mcp-server/package.json b/mcp-server/package.json index 42469e4..434c554 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -38,6 +38,7 @@ "rm": "rm -rf dist", "prepare": "git config core.hooksPath mcp-server/scripts/hooks", "sync-readme": "node scripts/sync-readme-tools.mjs", + "sync-readme:check": "node scripts/sync-readme-tools.mjs --check", "test": "vitest", "test:ui": "vitest --ui", "test:run": "npm run build && vitest run", @@ -46,7 +47,7 @@ "release:dry": "release-it --dry-run", "format": "prettier --check src ../skills", "format-fix": "prettier --write src ../skills", - "verify": "npm run format && tsc --noEmit && npm run build && vitest run" + "verify": "npm run format && npm run sync-readme:check && tsc --noEmit && npm run build && vitest run" }, "author": "Currents Software Inc", "homepage": "https://currents.dev", diff --git a/mcp-server/scripts/sync-readme-tools.mjs b/mcp-server/scripts/sync-readme-tools.mjs index d135358..49f683c 100644 --- a/mcp-server/scripts/sync-readme-tools.mjs +++ b/mcp-server/scripts/sync-readme-tools.mjs @@ -114,7 +114,13 @@ function markdownTable(headings, rows) { // would catch: `host/readme.test.ts` reads names out of the first cell and // never looks at the shape of the row. Nothing in either catalog carries one // today, and a description is free text that one day will. - const cells = rows.map((row) => row.map((cell) => cell.replaceAll("|", "\\|"))); + // + // The backslash goes first, or escaping a description that already reads + // `a\|b` would write `a\\|b`, which is a literal backslash followed by a + // live delimiter — the corruption this is here to prevent. + const cells = rows.map((row) => + row.map((cell) => cell.replaceAll("\\", "\\\\").replaceAll("|", "\\|")), + ); const widths = headings.map((heading, column) => Math.max(heading.length, ...cells.map((row) => row[column].length)), ); From 8223cb7c70af81a0501aace3ba7b15201742116e Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Thu, 17 Sep 2026 10:41:37 -0700 Subject: [PATCH 4/4] fix: read a tool description as the string it declares, not as its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex captures what sits between the quotes, so every escape in it is still two characters, and only the quotes were being decoded. A description declaring `C:\Users` was carried as a backslash pair, which the markdown escape added last commit then doubled again — the README would have shown two backslashes where the tool declares one. Before that escape the pair rendered as one backslash by accident, two mistakes cancelling; now the literal is decoded first and escaped once. One pass rather than chained replaces, so a decoded backslash is not read again as the start of the next escape. Decoding also makes `\n` a real newline, which would end a table row wherever it fell, so a cell's whitespace runs collapse to a single space. No description in either catalog contains a backslash, so no row changes. Co-Authored-By: Claude Opus 5 (1M context) --- mcp-server/scripts/sync-readme-tools.mjs | 33 ++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/mcp-server/scripts/sync-readme-tools.mjs b/mcp-server/scripts/sync-readme-tools.mjs index 49f683c..2f4b3fd 100644 --- a/mcp-server/scripts/sync-readme-tools.mjs +++ b/mcp-server/scripts/sync-readme-tools.mjs @@ -91,10 +91,30 @@ function firstSentence(description) { return /[.!?]$/.test(lead) ? lead : lead + "."; } +/** + * A string literal's source text as the string it declares. + * + * The regex above captures what is between the quotes, so every escape in it + * is still two characters. Decoding the quotes alone left `\\` as a pair, which + * the markdown escape below then doubled again — a description declaring + * `C:\Users` reached the README as two backslashes. + * + * One pass rather than chained replaces, so a decoded backslash is not read + * again as the start of the next escape. + * + * @param {string} literal + * @returns {string} + */ +function decodeStringLiteral(literal) { + return literal.replace(/\\(.)/g, (_, char) => + char === "n" ? "\n" : char === "t" ? "\t" : char, + ); +} + let match; while ((match = toolRegex.exec(serverSrc)) !== null) { const name = match[2]; - const description = match[4].replace(/\\(['"])/g, '$1'); + const description = decodeStringLiteral(match[4]); registeredTools.push({ name, shortDesc: firstSentence(description) }); } @@ -118,8 +138,17 @@ function markdownTable(headings, rows) { // The backslash goes first, or escaping a description that already reads // `a\|b` would write `a\\|b`, which is a literal backslash followed by a // live delimiter — the corruption this is here to prevent. + // + // A run of whitespace becomes one space last: a row is one line, and a + // newline in a cell ends the row wherever it falls. const cells = rows.map((row) => - row.map((cell) => cell.replaceAll("\\", "\\\\").replaceAll("|", "\\|")), + row.map((cell) => + cell + .replaceAll("\\", "\\\\") + .replaceAll("|", "\\|") + .replace(/\s+/g, " ") + .trim(), + ), ); const widths = headings.map((heading, column) => Math.max(heading.length, ...cells.map((row) => row[column].length)),