critique-storage.mjs

  1#!/usr/bin/env node
  2/**
  3 * Critique persistence helper.
  4 *
  5 * Each run of /impeccable critique writes a per-target snapshot to
  6 *   .impeccable/critique/<timestamp>__<slug>.md
  7 * with a small YAML frontmatter carrying the score + P0/P1 counts.
  8 *
  9 * /impeccable polish reads the latest matching snapshot at start as its
 10 * fix backlog. No other skill auto-reads critique output.
 11 *
 12 * The slug is derived mechanically from the *resolved* primary artifact
 13 * (file path or URL), never from the user's natural-language phrasing.
 14 * Slug stability across runs is what lets the trend display work.
 15 *
 16 * CLI entry points (called from skill instructions):
 17 *   node critique-storage.mjs slug <resolved-target>
 18 *   node critique-storage.mjs write <slug> <snapshot-body-file>
 19 *   node critique-storage.mjs latest <slug>
 20 *   node critique-storage.mjs trend <slug> [limit]
 21 *
 22 * Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
 23 * markdown file; the model reads it directly with its file-read tool. This
 24 * helper only exists for operations the model can't trivially do inline
 25 * (normalizing paths, generating filenames, globbing + parsing frontmatter).
 26 */
 27
 28import fs from 'node:fs';
 29import path from 'node:path';
 30import { fileURLToPath, pathToFileURL } from 'node:url';
 31import { getCritiqueDir } from './impeccable-paths.mjs';
 32
 33const SLUG_MAX = 50;
 34
 35/**
 36 * Mechanically derive a slug from a resolved target. Returns null if the
 37 * input doesn't look like a stable identifier (empty, project root, etc).
 38 *
 39 * Accepts file paths and URLs. The model resolves "the homepage" to a
 40 * concrete artifact before calling this — we never slug a natural-language
 41 * phrase.
 42 */
 43export function slugFromTarget(resolved, { cwd = process.cwd() } = {}) {
 44  if (!resolved || typeof resolved !== 'string') return null;
 45  const trimmed = resolved.trim();
 46  if (!trimmed) return null;
 47
 48  // URL
 49  if (/^https?:\/\//i.test(trimmed)) {
 50    let url;
 51    try { url = new URL(trimmed); } catch { return null; }
 52    const hostPath = `${url.hostname}${url.pathname}`;
 53    return kebab(hostPath);
 54  }
 55
 56  // File path. Make it project-relative so two devs critiquing the same
 57  // checkout get the same slug regardless of where their repo is cloned.
 58  const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
 59  let rel = path.relative(cwd, abs);
 60  // If the target is outside cwd, fall back to the basename so we still
 61  // produce a stable slug (vs the absolute path, which would include
 62  // home dirs / usernames).
 63  if (rel.startsWith('..') || path.isAbsolute(rel)) {
 64    rel = path.basename(abs);
 65  }
 66  if (!rel || rel === '.' || rel === '') return null;
 67  return kebab(rel);
 68}
 69
 70function kebab(s) {
 71  const slug = s
 72    .toLowerCase()
 73    .replace(/[/\\.]+/g, '-')
 74    .replace(/[^a-z0-9-]+/g, '-')
 75    .replace(/-+/g, '-')
 76    .replace(/^-|-$/g, '');
 77  if (!slug) return null;
 78  // Cap from the tail — the tail (filename) is more identifying than the
 79  // top-level directory.
 80  return slug.length <= SLUG_MAX ? slug : slug.slice(slug.length - SLUG_MAX).replace(/^-/, '');
 81}
 82
 83/**
 84 * Filename-safe UTC ISO timestamp: hyphens for separators, trailing Z.
 85 * Plain colons aren't allowed on Windows filesystems.
 86 */
 87export function nowFilenameStamp(date = new Date()) {
 88  const iso = date.toISOString();           // 2026-05-12T18:30:00.123Z
 89  return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
 90}
 91
 92/**
 93 * Write a snapshot for `slug`. `meta` carries the small structured frontmatter
 94 * keys read back by readTrend(). `body` is the human-readable critique
 95 * report (everything below the frontmatter).
 96 *
 97 * Returns the absolute path written.
 98 */
 99export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new Date() }) {
100  if (!slug) throw new Error('writeSnapshot requires a slug');
101  const dir = getCritiqueDir(cwd);
102  fs.mkdirSync(dir, { recursive: true });
103  const timestamp = nowFilenameStamp(now);
104  const filePath = path.join(dir, `${timestamp}__${slug}.md`);
105  // Spread `meta` first so internally computed `timestamp` and `slug`
106  // always win. Otherwise a caller-supplied meta blob (parsed from the
107  // IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
108  // filename in disagreement with its frontmatter and corrupting trends.
109  const front = serializeFrontmatter({ ...meta, timestamp, slug });
110  fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
111  return filePath;
112}
113
114function serializeFrontmatter(obj) {
115  const lines = ['---'];
116  for (const [key, value] of Object.entries(obj)) {
117    if (value === undefined || value === null) continue;
118    const str = typeof value === 'string' ? value : String(value);
119    // Quote strings that contain : or # to keep parsing simple.
120    const needsQuotes = typeof value === 'string' && /[:#]/.test(str);
121    lines.push(`${key}: ${needsQuotes ? JSON.stringify(str) : str}`);
122  }
123  lines.push('---');
124  return lines.join('\n');
125}
126
127function parseFrontmatter(text) {
128  const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
129  if (!match) return {};
130  const out = {};
131  for (const line of match[1].split(/\r?\n/)) {
132    const colon = line.indexOf(':');
133    if (colon < 0) continue;
134    const key = line.slice(0, colon).trim();
135    let value = line.slice(colon + 1).trim();
136    if (/^".*"$/.test(value)) {
137      try { value = JSON.parse(value); } catch { /* leave as-is */ }
138    } else if (/^-?\d+$/.test(value)) {
139      value = Number(value);
140    }
141    out[key] = value;
142  }
143  return out;
144}
145
146/**
147 * Return all snapshot files for `slug`, sorted oldest → newest.
148 */
149function listSnapshotsForSlug(slug, cwd) {
150  const dir = getCritiqueDir(cwd);
151  if (!fs.existsSync(dir)) return [];
152  const suffix = `__${slug}.md`;
153  return fs.readdirSync(dir)
154    .filter((f) => f.endsWith(suffix))
155    .sort()
156    .map((f) => path.join(dir, f));
157}
158
159/**
160 * Return the most recent snapshot for `slug`, or null. Polish reads this
161 * to find its fix backlog when the slug matches.
162 */
163export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
164  const all = listSnapshotsForSlug(slug, cwd);
165  if (!all.length) return null;
166  const latest = all[all.length - 1];
167  const body = fs.readFileSync(latest, 'utf-8');
168  return { path: latest, body, meta: parseFrontmatter(body) };
169}
170
171/**
172 * Return the last `limit` snapshots' frontmatter, oldest → newest.
173 * Critique appends a one-line trend to its output using this.
174 */
175export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
176  const all = listSnapshotsForSlug(slug, cwd);
177  const slice = all.slice(-limit);
178  return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
179}
180
181// ---- CLI ---------------------------------------------------------------
182
183function main(argv) {
184  const [cmd, ...args] = argv;
185  switch (cmd) {
186    case 'slug': {
187      const slug = slugFromTarget(args[0]);
188      if (!slug) { process.stderr.write('no stable slug for input\n'); process.exit(1); }
189      process.stdout.write(`${slug}\n`);
190      return;
191    }
192    case 'write': {
193      const [slug, bodyFile] = args;
194      if (!slug || !bodyFile) { process.stderr.write('usage: write <slug> <body-file>\n'); process.exit(1); }
195      const raw = fs.readFileSync(bodyFile, 'utf-8');
196      // The body file may be a full report. The caller passes the meta as
197      // a JSON object on stdin if it wants structured frontmatter; otherwise
198      // we write with minimal metadata.
199      let meta = {};
200      const metaArg = process.env.IMPECCABLE_CRITIQUE_META;
201      if (metaArg) {
202        try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
203      }
204      const out = writeSnapshot({ slug, meta, body: raw });
205      process.stdout.write(`${out}\n`);
206      return;
207    }
208    case 'latest': {
209      const latest = readLatestSnapshot(args[0]);
210      if (!latest) { process.exit(2); }
211      process.stdout.write(latest.body);
212      return;
213    }
214    case 'trend': {
215      const rows = readTrend(args[0], { limit: args[1] ? Number(args[1]) : 5 });
216      process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
217      return;
218    }
219    default:
220      process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
221      process.exit(1);
222  }
223}
224
225function isMainModule() {
226  if (!process.argv[1]) return false;
227  try {
228    return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
229  } catch {
230    // pathToFileURL normalizes Windows paths; keep it as a fallback for any
231    // environment where realpath is unavailable.
232    return import.meta.url === pathToFileURL(process.argv[1]).href;
233  }
234}
235
236// Why the realpath check: generated skills are often reached through symlinked
237// harness directories (for example a demo repo's `.agents` -> source `.agents`).
238// Node resolves import.meta.url to the real file, while process.argv[1] keeps
239// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
240if (isMainModule()) {
241  main(process.argv.slice(2));
242}