返回 AiToEarn
context-signals.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / context-signals.mjs
1 #!/usr/bin/env node
2 /**
3 * Context-signals gatherer for the bare `{{command_prefix}}impeccable`
4 * (no-argument) path. Collects cheap, deterministic signals about the current
5 * project and emits them as JSON.
6 *
7 * It does NOT score or rank. The agent reasons over the raw signals using its
8 * knowledge of the command catalog (see SKILL.md routing rule 1). Deliberately
9 * light: no LLM calls, no detector run (`npx impeccable detect` is heavier and
10 * opt-in), no file writes. Every probe is best-effort and never throws; the
11 * output is always valid JSON.
12 *
13 * Signals:
14 * - setup: PRODUCT.md / DESIGN.md presence, register, whether code exists
15 * - 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 */
19 import fs from 'node:fs';
20 import net from 'node:net';
21 import path from 'node:path';
22 import { fileURLToPath } from 'node:url';
23 import { execFileSync } from 'node:child_process';
24 import { loadContext, extractRegister } from './context.mjs';
25 import { getCritiqueDir } from './impeccable-paths.mjs';
26
27 /** Is there code here at all, or just context files / an empty repo? */
28 function hasCode(cwd) {
29 if (fs.existsSync(path.join(cwd, 'package.json'))) return true;
30 for (const d of ['src', 'app', 'pages', 'site', 'public', 'components', 'lib']) {
31 if (fs.existsSync(path.join(cwd, d))) return true;
32 }
33 return false;
34 }
35
36 /**
37 * The most recent critique snapshot across all targets. Filenames are
38 * timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
39 * Parses the small frontmatter for score + P0/P1 counts.
40 */
41 function latestCritique(cwd) {
42 try {
43 const dir = getCritiqueDir(cwd);
44 if (!fs.existsSync(dir)) return null;
45 const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
46 if (!files.length) return null;
47 const newest = files[files.length - 1];
48 const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
49 const front = text.split('---')[1] || '';
50 const get = (k) => {
51 const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
52 return m ? m[1].trim() : null;
53 };
54 const num = (v) => {
55 const n = Number(v);
56 return Number.isFinite(n) ? n : null;
57 };
58 return {
59 slug: get('slug'),
60 score: num(get('score')),
61 p0: num(get('p0')),
62 p1: num(get('p1')),
63 timestamp: get('timestamp'),
64 file: path.relative(cwd, path.join(dir, newest)),
65 };
66 } catch {
67 return null;
68 }
69 }
70
71 /** Branch + a scope hint: files changed vs the default branch, else working tree. */
72 function gitSignals(cwd) {
73 const run = (args, { trim = true } = {}) => {
74 try {
75 const out = execFileSync('git', args, {
76 cwd,
77 encoding: 'utf-8',
78 stdio: ['ignore', 'pipe', 'ignore'],
79 });
80 return trim ? out.trim() : out;
81 } catch {
82 return null;
83 }
84 };
85 if (run(['rev-parse', '--is-inside-work-tree']) !== 'true') {
86 return { isRepo: false, branch: null, base: null, changedFiles: [], changedCount: 0 };
87 }
88 const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
89 let base = null;
90 for (const b of ['main', 'master']) {
91 if (run(['rev-parse', '--verify', '--quiet', b]) !== null) {
92 base = b;
93 break;
94 }
95 }
96 const diffBase = base && branch && branch !== base ? base : null;
97 const 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 a
100 // leading space (` M path`), and a global trim would eat the first line's
101 // status column and shift the slice. Renames render as `old -> new`.
102 const fromStatus = run(['-c', 'core.quotepath=false', 'status', '--porcelain'], { trim: false });
103 let changed = [];
104 if (fromDiff) {
105 changed = fromDiff.split('\n').filter(Boolean);
106 } else if (fromStatus) {
107 changed = fromStatus.split(/\r?\n/).filter(Boolean).map((l) => {
108 const p = l.slice(3);
109 const arrow = p.indexOf(' -> ');
110 return arrow === -1 ? p : p.slice(arrow + 4);
111 });
112 }
113 return {
114 isRepo: true,
115 branch,
116 base: diffBase,
117 changedFiles: changed.slice(0, 50),
118 changedCount: changed.length,
119 };
120 }
121
122 const COMMON_DEV_PORTS = [4321, 3000, 5173, 5174, 8080, 8000, 4200];
123
124 function probePort(port, timeout = 250) {
125 return new Promise((resolve) => {
126 const sock = new net.Socket();
127 let settled = false;
128 const finish = (ok) => {
129 if (settled) return;
130 settled = true;
131 try { sock.destroy(); } catch { /* ignore */ }
132 resolve(ok);
133 };
134 sock.setTimeout(timeout);
135 sock.once('connect', () => finish(true));
136 sock.once('timeout', () => finish(false));
137 sock.once('error', () => finish(false));
138 sock.connect(port, '127.0.0.1');
139 });
140 }
141
142 async function devServerSignals() {
143 const open = [];
144 await Promise.all(
145 COMMON_DEV_PORTS.map(async (p) => {
146 if (await probePort(p)) open.push(p);
147 }),
148 );
149 open.sort((a, b) => a - b);
150 return { running: open.length > 0, ports: open };
151 }
152
153 // Extensions the detector scans (mirrors the engine's walkDir set + HTML).
154 const 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 skips
159 // node_modules / dist / build / .next / .nuxt automatically.
160 const SOURCE_DIRS = ['src', 'app', 'components', 'pages', 'public'];
161
162 /**
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 port
165 * may not even belong to this project. An HTML *file* or a source tree is
166 * scanned by the cheap, jsdom-free static engine. This script does NOT run the
167 * detector; it just surfaces the target(s) so the agent can run
168 * `node <scripts>/detect.mjs --json <targets>` and fold the hits in.
169 */
170 function scanTargets(cwd, git) {
171 // 1. Dirty tree wins: scan exactly the markup/style files in flight. It's
172 // what the user is working on, it's a small set, and it's local.
173 if (git.isRepo && git.changedFiles.length) {
174 const changed = git.changedFiles
175 .filter((f) => SCANNABLE_EXT.has(path.extname(f).toLowerCase()))
176 .filter((f) => fs.existsSync(path.join(cwd, f)));
177 if (changed.length) return { targets: changed.slice(0, 50), via: 'git-changes' };
178 }
179 // 2. Otherwise scan the local source dirs that exist.
180 const dirs = SOURCE_DIRS.filter((d) => fs.existsSync(path.join(cwd, d)));
181 if (dirs.length) return { targets: dirs, via: 'source-dir' };
182 // 3. A root HTML entry, or the project root as a last resort when there's
183 // code but no conventional source dir (walkDir still skips heavy dirs).
184 if (fs.existsSync(path.join(cwd, 'index.html'))) return { targets: ['index.html'], via: 'html' };
185 if (hasCode(cwd)) return { targets: ['.'], via: 'root' };
186 return { targets: [], via: null };
187 }
188
189 export async function gatherSignals(cwd = process.cwd()) {
190 const ctx = loadContext(cwd);
191 const git = gitSignals(cwd);
192 return {
193 setup: {
194 hasProduct: ctx.hasProduct,
195 productPath: ctx.productPath,
196 hasDesign: ctx.hasDesign,
197 designPath: ctx.designPath,
198 hasCode: hasCode(cwd),
199 register: extractRegister(ctx.product),
200 },
201 critique: { latest: latestCritique(cwd) },
202 git,
203 devServer: await devServerSignals(),
204 scan: scanTargets(cwd, git),
205 };
206 }
207
208 async function cli() {
209 const signals = await gatherSignals(process.cwd());
210 process.stdout.write(`${JSON.stringify(signals, null, 2)}\n`);
211 }
212
213 function invokedAsScript() {
214 const arg = process.argv[1];
215 if (!arg) return false;
216 try {
217 return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
218 } catch {
219 return false;
220 }
221 }
222
223 if (invokedAsScript()) {
224 cli();
225 }
226
226 lines Plain Text