返回 AiToEarn
context.mjs
1 /**
2 * Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
3 * markdown block on stdout, or exits with empty stdout when no PRODUCT.md
4 * is found anywhere. The skill keys off "empty stdout" to branch into the
5 * init flow.
6 *
7 * Path resolution (first match wins):
8 * 1. cwd, if PRODUCT.md or DESIGN.md is there
9 * 2. .agents/context/ then docs/
10 * 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
11 * escape hatch, only consulted when defaults are empty
12 * 4. cwd as a "nothing found" default
13 *
14 * `resolveContextDir()` and `loadContext()` are also exported for the
15 * server-side scripts (live.mjs, live-server.mjs) that need the structured
16 * shape rather than the markdown block.
17 */
18 import fs from 'node:fs';
19 import os from 'node:os';
20 import path from 'node:path';
21 import { fileURLToPath } from 'node:url';
22
23 const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
24 const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
25 const FALLBACK_DIRS = ['.agents/context', 'docs'];
26
27 // ─── Update check ──────────────────────────────────────────────────────────
28 // Piggyback a lightweight skill-version check on the once-per-session boot.
29 // When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
30 // can offer `npx impeccable skills update`. Everything here is best-effort and
31 // silent on failure: a network problem, sandbox, or missing cache must never
32 // block context output or print an error.
33
34 const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, '');
35 const UPDATE_CACHE_PATH =
36 process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json');
37 const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day
38 const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
39 const FETCH_TIMEOUT_MS = 1200;
40
41 export function resolveContextDir(cwd = process.cwd()) {
42 if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
43 return cwd;
44 }
45 for (const rel of FALLBACK_DIRS) {
46 const candidate = path.resolve(cwd, rel);
47 if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
48 return candidate;
49 }
50 }
51 const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
52 if (envDir && envDir.trim()) {
53 const trimmed = envDir.trim();
54 return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
55 }
56 return cwd;
57 }
58
59 export function loadContext(cwd = process.cwd()) {
60 const contextDir = resolveContextDir(cwd);
61 const productPath = firstExisting(contextDir, PRODUCT_NAMES);
62 const designPath = firstExisting(contextDir, DESIGN_NAMES);
63 const product = productPath ? safeRead(productPath) : null;
64 const design = designPath ? safeRead(designPath) : null;
65 return {
66 hasProduct: !!product,
67 product,
68 productPath: productPath ? path.relative(cwd, productPath) : null,
69 hasDesign: !!design,
70 design,
71 designPath: designPath ? path.relative(cwd, designPath) : null,
72 contextDir,
73 };
74 }
75
76 function firstExisting(dir, names) {
77 for (const name of names) {
78 const abs = path.join(dir, name);
79 if (fs.existsSync(abs)) return abs;
80 }
81 return null;
82 }
83
84 function safeRead(p) {
85 try {
86 return fs.readFileSync(p, 'utf-8');
87 } catch {
88 return null;
89 }
90 }
91
92 /**
93 * Pull the register (`brand` or `product`) out of PRODUCT.md by looking
94 * for a `## Register` section and reading the first non-empty line that
95 * follows it. Returns null when the file is legacy / register-less.
96 */
97 export function extractRegister(product) {
98 if (!product) return null;
99 const lines = product.split('\n');
100 for (let i = 0; i < lines.length; i++) {
101 if (/^##\s+Register\b/i.test(lines[i].trim())) {
102 for (let j = i + 1; j < lines.length; j++) {
103 const next = lines[j].trim();
104 if (!next) continue;
105 const word = next.toLowerCase();
106 if (word === 'brand' || word === 'product') return word;
107 return null;
108 }
109 }
110 }
111 return null;
112 }
113
114 /**
115 * Read the installed skill's own version from the sibling SKILL.md frontmatter
116 * (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
117 * frontmatter is missing or unreadable.
118 */
119 function readLocalSkillVersion() {
120 try {
121 const here = path.dirname(fileURLToPath(import.meta.url));
122 const skillMd = path.join(here, '..', 'SKILL.md');
123 const content = fs.readFileSync(skillMd, 'utf-8');
124 const match = content.match(/^version:\s*(.+)$/m);
125 return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
126 } catch {
127 return null;
128 }
129 }
130
131 function readUpdateCache() {
132 try {
133 return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));
134 } catch {
135 return {};
136 }
137 }
138
139 function writeUpdateCache(cache) {
140 try {
141 fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
142 fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
143 } catch {
144 // Best-effort: a read-only home dir just means we re-poll next session.
145 }
146 }
147
148 /** Compare dotted numeric versions. Returns >0 when a is newer than b. */
149 function compareSemver(a, b) {
150 const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
151 const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
152 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
153 const diff = (pa[i] || 0) - (pb[i] || 0);
154 if (diff !== 0) return diff;
155 }
156 return 0;
157 }
158
159 async function fetchLatestSkillVersion() {
160 try {
161 const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
162 if (!res.ok) return null;
163 const data = await res.json();
164 return typeof data?.skills === 'string' ? data.skills : null;
165 } catch {
166 return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal
167 }
168 }
169
170 function buildUpdateDirective(localVersion, latestVersion) {
171 return (
172 `UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
173 `(installed v${localVersion}, latest v${latestVersion}). ` +
174 `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
175 `Update now? It runs \`npx impeccable skills update\`." ` +
176 `If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
177 `Either way, continue the current task without waiting, and do not raise this again.`
178 );
179 }
180
181 /**
182 * Best-effort update directive for the boot output. Returns a string to append
183 * or null. Polls the version endpoint at most once per day (cached globally in
184 * the user's home dir) and re-surfaces a given version at most once per week so
185 * the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
186 */
187 async function computeUpdateDirective(now = Date.now()) {
188 try {
189 if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
190 const localVersion = readLocalSkillVersion();
191 if (!localVersion) return null;
192
193 const cache = readUpdateCache();
194
195 // Poll the network only when the throttle window has elapsed. Stamp
196 // lastCheck even on failure so an offline machine doesn't poll every boot.
197 if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {
198 const latest = await fetchLatestSkillVersion();
199 cache.lastCheck = now;
200 if (latest) cache.latestVersion = latest;
201 writeUpdateCache(cache);
202 }
203
204 const latest = cache.latestVersion;
205 if (!latest || compareSemver(latest, localVersion) <= 0) return null;
206
207 // Anti-nag: surface a given version at most once per RENOTIFY window.
208 if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {
209 return null;
210 }
211 cache.notifiedVersion = latest;
212 cache.notifiedAt = now;
213 writeUpdateCache(cache);
214
215 return buildUpdateDirective(localVersion, latest);
216 } catch {
217 return null;
218 }
219 }
220
221 async function cli() {
222 const ctx = loadContext(process.cwd());
223 const updateDirective = await computeUpdateDirective();
224
225 if (!ctx.hasProduct) {
226 // Direct stdout message instead of relying on empty output as a signal
227 // — cheap models miss the empty case more often than the explicit one.
228 const parts = [
229 'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
230 'Stop the current task, load reference/init.md, and follow its ' +
231 'instructions to write PRODUCT.md before resuming.',
232 ];
233 if (updateDirective) parts.push(updateDirective);
234 process.stdout.write(parts.join('\n\n---\n\n') + '\n');
235 process.exit(0);
236 }
237 const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
238 if (ctx.hasDesign) {
239 parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
240 }
241 const register = extractRegister(ctx.product);
242 const next = register
243 ? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
244 : `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
245 parts.push(next);
246 if (updateDirective) parts.push(updateDirective);
247 process.stdout.write(parts.join('\n\n---\n\n') + '\n');
248 }
249
250 // Run cli() only when this module is the entry point. Compare realpaths
251 // rather than endsWith(): a loose suffix match also fires for unrelated
252 // scripts like `load-context.mjs`, and realpath tolerates symlinked
253 // invocation (the test harness symlinks the skill dir).
254 function invokedAsScript() {
255 const arg = process.argv[1];
256 if (!arg) return false;
257 try {
258 return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
259 } catch {
260 return false;
261 }
262 }
263
264 if (invokedAsScript()) {
265 cli();
266 }
267
267 lines Plain Text