| 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 |