Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/context-signals.mjs
1#!/usr/bin/env node2/**3* Context-signals gatherer for the bare `{{command_prefix}}impeccable`4* (no-argument) path. Collects cheap, deterministic signals about the current5* project and emits them as JSON.6*7* It does NOT score or rank. The agent reasons over the raw signals using its8* knowledge of the command catalog (see SKILL.md routing rule 1). Deliberately9* light: no LLM calls, no detector run (`npx impeccable detect` is heavier and10* opt-in), no file writes. Every probe is best-effort and never throws; the11* output is always valid JSON.12*13* Signals:14* - setup: PRODUCT.md / DESIGN.md presence, register, whether code exists15* - critique: the latest cached critique score (.impeccable/critique)16* - git: branch + files changed vs the default branch (a scope hint)17* - devServer: whether a local dev server answers on a common port (gates live)18*/19import fs from 'node:fs';20import net from 'node:net';21import path from 'node:path';22import { fileURLToPath } from 'node:url';23import { execFileSync } from 'node:child_process';24import { loadContext, extractRegister } from './context.mjs';25import { getCritiqueDir } from './lib/impeccable-paths.mjs';2627/** Is there code here at all, or just context files / an empty repo? */28function hasCode(cwd) {29if (fs.existsSync(path.join(cwd, 'package.json'))) return true;30for (const d of ['src', 'app', 'pages', 'site', 'public', 'components', 'lib']) {31if (fs.existsSync(path.join(cwd, d))) return true;32}33return false;34}3536/**37* The most recent critique snapshot across all targets. Filenames are38* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.39* Parses the small frontmatter for score + P0/P1 counts.40*/41function latestCritique(cwd) {42try {43const dir = getCritiqueDir(cwd);44if (!fs.existsSync(dir)) return null;45const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();46if (!files.length) return null;47const newest = files[files.length - 1];48const text = fs.readFileSync(path.join(dir, newest), 'utf-8');49const front = text.split('---')[1] || '';50const get = (k) => {51const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));52return m ? m[1].trim() : null;53};54const num = (v) => {55const n = Number(v);56return Number.isFinite(n) ? n : null;57};58return {59slug: get('slug'),60score: num(get('score')),61p0: num(get('p0')),62p1: num(get('p1')),63timestamp: get('timestamp'),64file: path.relative(cwd, path.join(dir, newest)),65};66} catch {67return null;68}69}7071/** Branch + a scope hint: files changed vs the default branch, else working tree. */72function gitSignals(cwd) {73const run = (args, { trim = true } = {}) => {74try {75const out = execFileSync('git', args, {76cwd,77encoding: 'utf-8',78stdio: ['ignore', 'pipe', 'ignore'],79});80return trim ? out.trim() : out;81} catch {82return null;83}84};85if (run(['rev-parse', '--is-inside-work-tree']) !== 'true') {86return { isRepo: false, branch: null, base: null, changedFiles: [], changedCount: 0 };87}88const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);89let base = null;90for (const b of ['main', 'master']) {91if (run(['rev-parse', '--verify', '--quiet', b]) !== null) {92base = b;93break;94}95}96const diffBase = base && branch && branch !== base ? base : null;97const fromDiff = diffBase ? run(['diff', '--name-only', `${diffBase}...HEAD`]) : null;98// porcelain lines are `XY PATH`: a 2-char status + a space, then the path.99// Don't trim the combined output — an unstaged-modified line starts with a100// leading space (` M path`), and a global trim would eat the first line's101// status column and shift the slice. Renames render as `old -> new`.102const fromStatus = run(['-c', 'core.quotepath=false', 'status', '--porcelain'], { trim: false });103let changed = [];104if (fromDiff) {105changed = fromDiff.split('\n').filter(Boolean);106} else if (fromStatus) {107changed = fromStatus.split(/\r?\n/).filter(Boolean).map((l) => {108const p = l.slice(3);109const arrow = p.indexOf(' -> ');110return arrow === -1 ? p : p.slice(arrow + 4);111});112}113return {114isRepo: true,115branch,116base: diffBase,117changedFiles: changed.slice(0, 50),118changedCount: changed.length,119};120}121122const COMMON_DEV_PORTS = [4321, 3000, 5173, 5174, 8080, 8000, 4200];123124function probePort(port, timeout = 250) {125return new Promise((resolve) => {126const sock = new net.Socket();127let settled = false;128const finish = (ok) => {129if (settled) return;130settled = true;131try { sock.destroy(); } catch { /* ignore */ }132resolve(ok);133};134sock.setTimeout(timeout);135sock.once('connect', () => finish(true));136sock.once('timeout', () => finish(false));137sock.once('error', () => finish(false));138sock.connect(port, '127.0.0.1');139});140}141142async function devServerSignals() {143const open = [];144await Promise.all(145COMMON_DEV_PORTS.map(async (p) => {146if (await probePort(p)) open.push(p);147}),148);149open.sort((a, b) => a - b);150return { running: open.length > 0, ports: open };151}152153// Extensions the detector scans (mirrors the engine's walkDir set + HTML).154const SCANNABLE_EXT = new Set([155'.html', '.htm', '.css', '.scss',156'.jsx', '.tsx', '.js', '.ts', '.vue', '.svelte', '.astro',157]);158// Where UI source typically lives. The detector walks these and skips159// node_modules / dist / build / .next / .nuxt automatically.160const SOURCE_DIRS = ['src', 'app', 'components', 'pages', 'public'];161162/**163* Local paths the agent should point the bundled detector at — never a URL.164* A URL means a costly Puppeteer browser render, and a probed dev-server port165* may not even belong to this project. An HTML *file* or a source tree is166* scanned by the cheap, jsdom-free static engine. This script does NOT run the167* detector; it just surfaces the target(s) so the agent can run168* `node <scripts>/detect.mjs --json <targets>` and fold the hits in.169*/170function scanTargets(cwd, git) {171// 1. Dirty tree wins: scan exactly the markup/style files in flight. It's172// what the user is working on, it's a small set, and it's local.173if (git.isRepo && git.changedFiles.length) {174const changed = git.changedFiles175.filter((f) => SCANNABLE_EXT.has(path.extname(f).toLowerCase()))176.filter((f) => fs.existsSync(path.join(cwd, f)));177if (changed.length) return { targets: changed.slice(0, 50), via: 'git-changes' };178}179// 2. Otherwise scan the local source dirs that exist.180const dirs = SOURCE_DIRS.filter((d) => fs.existsSync(path.join(cwd, d)));181if (dirs.length) return { targets: dirs, via: 'source-dir' };182// 3. A root HTML entry, or the project root as a last resort when there's183// code but no conventional source dir (walkDir still skips heavy dirs).184if (fs.existsSync(path.join(cwd, 'index.html'))) return { targets: ['index.html'], via: 'html' };185if (hasCode(cwd)) return { targets: ['.'], via: 'root' };186return { targets: [], via: null };187}188189export async function gatherSignals(cwd = process.cwd()) {190const ctx = loadContext(cwd);191const git = gitSignals(cwd);192return {193setup: {194hasProduct: ctx.hasProduct,195productPath: ctx.productPath,196hasDesign: ctx.hasDesign,197designPath: ctx.designPath,198hasCode: hasCode(cwd),199register: extractRegister(ctx.product),200},201critique: { latest: latestCritique(cwd) },202git,203devServer: await devServerSignals(),204scan: scanTargets(cwd, git),205};206}207208async function cli() {209const signals = await gatherSignals(process.cwd());210process.stdout.write(`${JSON.stringify(signals, null, 2)}\n`);211}212213function invokedAsScript() {214const arg = process.argv[1];215if (!arg) return false;216try {217return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));218} catch {219return false;220}221}222223if (invokedAsScript()) {224cli();225}226