mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
Reshape of the "/impeccable suggest" proposal in #159. Instead of adding a 24th command (menu pollution + the command-add tax + its own discoverability problem), upgrade the path users already hit: bare `/impeccable` with no argument. - New skill/scripts/context-signals.mjs gathers cheap, deterministic signals (setup gaps, register, latest cached critique score, git change scope, a dev-server port probe, and a `scan.detectTarget` for the detector) and emits JSON. It does NOT score or rank, and it does NOT run the detector itself (the engine isn't importable in an installed skill, and shelling npx+jsdom would risk a hang) — the agent reasons over the raw signals. - SKILL.md routing rule 1 now leads with the 2-3 highest-value next commands, each with a reason from the signals, then the full menu. Never auto-runs; always confirms. Reuses init's "Recommend starting points" vocabulary. When a project has never been critiqued it offers critique; when scan.detectTarget is set it runs `npx impeccable detect --fast --json` and folds the hits in. - Export extractRegister from context.mjs for reuse. Stays 23 commands; no metadata/pin/site-data changes. Unit-tested, including a regression guard for porcelain leading-space path parsing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
7.2 KiB
JavaScript
216 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Context-signals gatherer for the bare `{{command_prefix}}impeccable`
|
|
* (no-argument) path. Collects cheap, deterministic signals about the current
|
|
* project and emits them as JSON.
|
|
*
|
|
* It does NOT score or rank. The agent reasons over the raw signals using its
|
|
* knowledge of the command catalog (see SKILL.md routing rule 1). Deliberately
|
|
* light: no LLM calls, no detector run (`npx impeccable detect` is heavier and
|
|
* opt-in), no file writes. Every probe is best-effort and never throws; the
|
|
* output is always valid JSON.
|
|
*
|
|
* Signals:
|
|
* - setup: PRODUCT.md / DESIGN.md presence, register, whether code exists
|
|
* - critique: the latest cached critique score (.impeccable/critique)
|
|
* - git: branch + files changed vs the default branch (a scope hint)
|
|
* - devServer: whether a local dev server answers on a common port (gates live)
|
|
*/
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { loadContext, extractRegister } from './context.mjs';
|
|
import { getCritiqueDir } from './impeccable-paths.mjs';
|
|
|
|
/** Is there code here at all, or just context files / an empty repo? */
|
|
function hasCode(cwd) {
|
|
if (fs.existsSync(path.join(cwd, 'package.json'))) return true;
|
|
for (const d of ['src', 'app', 'pages', 'site', 'public', 'components', 'lib']) {
|
|
if (fs.existsSync(path.join(cwd, d))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The most recent critique snapshot across all targets. Filenames are
|
|
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
|
|
* Parses the small frontmatter for score + P0/P1 counts.
|
|
*/
|
|
function latestCritique(cwd) {
|
|
try {
|
|
const dir = getCritiqueDir(cwd);
|
|
if (!fs.existsSync(dir)) return null;
|
|
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
|
|
if (!files.length) return null;
|
|
const newest = files[files.length - 1];
|
|
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
|
|
const front = text.split('---')[1] || '';
|
|
const get = (k) => {
|
|
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
|
|
return m ? m[1].trim() : null;
|
|
};
|
|
const num = (v) => {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : null;
|
|
};
|
|
return {
|
|
slug: get('slug'),
|
|
score: num(get('score')),
|
|
p0: num(get('p0')),
|
|
p1: num(get('p1')),
|
|
timestamp: get('timestamp'),
|
|
file: path.relative(cwd, path.join(dir, newest)),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Branch + a scope hint: files changed vs the default branch, else working tree. */
|
|
function gitSignals(cwd) {
|
|
const run = (args, { trim = true } = {}) => {
|
|
try {
|
|
const out = execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return trim ? out.trim() : out;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
if (run(['rev-parse', '--is-inside-work-tree']) !== 'true') {
|
|
return { isRepo: false, branch: null, base: null, changedFiles: [], changedCount: 0 };
|
|
}
|
|
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
let base = null;
|
|
for (const b of ['main', 'master']) {
|
|
if (run(['rev-parse', '--verify', '--quiet', b]) !== null) {
|
|
base = b;
|
|
break;
|
|
}
|
|
}
|
|
const diffBase = base && branch && branch !== base ? base : null;
|
|
const fromDiff = diffBase ? run(['diff', '--name-only', `${diffBase}...HEAD`]) : null;
|
|
// porcelain lines are `XY PATH`: a 2-char status + a space, then the path.
|
|
// Don't trim the combined output — an unstaged-modified line starts with a
|
|
// leading space (` M path`), and a global trim would eat the first line's
|
|
// status column and shift the slice. Renames render as `old -> new`.
|
|
const fromStatus = run(['-c', 'core.quotepath=false', 'status', '--porcelain'], { trim: false });
|
|
let changed = [];
|
|
if (fromDiff) {
|
|
changed = fromDiff.split('\n').filter(Boolean);
|
|
} else if (fromStatus) {
|
|
changed = fromStatus.split(/\r?\n/).filter(Boolean).map((l) => {
|
|
const p = l.slice(3);
|
|
const arrow = p.indexOf(' -> ');
|
|
return arrow === -1 ? p : p.slice(arrow + 4);
|
|
});
|
|
}
|
|
return {
|
|
isRepo: true,
|
|
branch,
|
|
base: diffBase,
|
|
changedFiles: changed.slice(0, 50),
|
|
changedCount: changed.length,
|
|
};
|
|
}
|
|
|
|
const COMMON_DEV_PORTS = [4321, 3000, 5173, 5174, 8080, 8000, 4200];
|
|
|
|
function probePort(port, timeout = 250) {
|
|
return new Promise((resolve) => {
|
|
const sock = new net.Socket();
|
|
let settled = false;
|
|
const finish = (ok) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { sock.destroy(); } catch { /* ignore */ }
|
|
resolve(ok);
|
|
};
|
|
sock.setTimeout(timeout);
|
|
sock.once('connect', () => finish(true));
|
|
sock.once('timeout', () => finish(false));
|
|
sock.once('error', () => finish(false));
|
|
sock.connect(port, '127.0.0.1');
|
|
});
|
|
}
|
|
|
|
async function devServerSignals() {
|
|
const open = [];
|
|
await Promise.all(
|
|
COMMON_DEV_PORTS.map(async (p) => {
|
|
if (await probePort(p)) open.push(p);
|
|
}),
|
|
);
|
|
open.sort((a, b) => a - b);
|
|
return { running: open.length > 0, ports: open };
|
|
}
|
|
|
|
/**
|
|
* What the agent could point the bundled detector (`detect.mjs`) at. The
|
|
* detector is HTML/CSS oriented, so a rendered page (dev server) or a static
|
|
* HTML entry is a far better target than a raw source tree. This script does
|
|
* NOT run the detector itself — it just surfaces the target so the agent can
|
|
* run `node <scripts>/detect.mjs --json <target>` (bundled, dep-free, fast)
|
|
* and fold the hits into its recommendation.
|
|
*/
|
|
function scanTarget(cwd, devServer) {
|
|
if (devServer.running && devServer.ports.length) {
|
|
return { detectTarget: `http://localhost:${devServer.ports[0]}`, via: 'dev-server' };
|
|
}
|
|
for (const c of ['index.html', 'public/index.html', 'dist/index.html', 'build/index.html']) {
|
|
if (fs.existsSync(path.join(cwd, c))) return { detectTarget: c, via: 'html' };
|
|
}
|
|
for (const dir of ['.', 'public', 'dist', 'build']) {
|
|
try {
|
|
const abs = path.join(cwd, dir);
|
|
if (!fs.existsSync(abs)) continue;
|
|
const html = fs.readdirSync(abs).find((f) => f.endsWith('.html'));
|
|
if (html) return { detectTarget: path.join(dir, html), via: 'html' };
|
|
} catch { /* ignore unreadable dir */ }
|
|
}
|
|
return { detectTarget: null, via: null };
|
|
}
|
|
|
|
export async function gatherSignals(cwd = process.cwd()) {
|
|
const ctx = loadContext(cwd);
|
|
const devServer = await devServerSignals();
|
|
return {
|
|
setup: {
|
|
hasProduct: ctx.hasProduct,
|
|
productPath: ctx.productPath,
|
|
hasDesign: ctx.hasDesign,
|
|
designPath: ctx.designPath,
|
|
hasCode: hasCode(cwd),
|
|
register: extractRegister(ctx.product),
|
|
},
|
|
critique: { latest: latestCritique(cwd) },
|
|
git: gitSignals(cwd),
|
|
devServer,
|
|
scan: scanTarget(cwd, devServer),
|
|
};
|
|
}
|
|
|
|
async function cli() {
|
|
const signals = await gatherSignals(process.cwd());
|
|
process.stdout.write(`${JSON.stringify(signals, null, 2)}\n`);
|
|
}
|
|
|
|
function invokedAsScript() {
|
|
const arg = process.argv[1];
|
|
if (!arg) return false;
|
|
try {
|
|
return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (invokedAsScript()) {
|
|
cli();
|
|
}
|