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}