|
| 1 | +#!/usr/bin/env bun |
| 2 | +/** |
| 3 | + * CollectSignals — deterministic, read-only signal collector for skill-gap discovery. |
| 4 | + * |
| 5 | + * Emits a normalized corpus (JSON to stdout) so the LLM step only clusters and judges; |
| 6 | + * it never gathers. Run at a fixed point in time over unchanged stores, it is deterministic |
| 7 | + * (output ordering is fully tie-broken). Writes nothing. |
| 8 | + * |
| 9 | + * Paths are DISCOVERED, never hardcoded to a person's home. Resolution order per store: |
| 10 | + * 1. explicit CLI flag 2. env var 3. a conventional default under --root |
| 11 | + * An explicit flag pointing at a nonexistent path is reported (never silently replaced by a |
| 12 | + * default). Anything missing or malformed is reported in `warnings`/`missing`, never fatal. |
| 13 | + * |
| 14 | + * Usage: |
| 15 | + * bun CollectSignals.ts [--root <dir>] [--days <n>] [--max-rating <n>] |
| 16 | + * [--ratings <file>] [--work <dir>] [--skills <dir>] [--loops <dir>] |
| 17 | + * env: SKILLSCAN_MEMORY_ROOT, SKILLSCAN_RATINGS_FILE, SKILLSCAN_WORK_DIR, |
| 18 | + * SKILLSCAN_SKILLS_DIR, SKILLSCAN_LOOPS_DIR |
| 19 | + * |
| 20 | + * Contract (stdout JSON): |
| 21 | + * { |
| 22 | + * window: { days, since }, |
| 23 | + * sessions: { slug, mtime }[], // recent work-session dirs (. and _ prefixed skipped as system dirs) |
| 24 | + * frustrations: { date, rating, note }[], // low ratings + sentiment, sorted |
| 25 | + * registries: { kind: "skill"|"loop"|"workflow", name, description }[], // for dedup; bodies read by the LLM step |
| 26 | + * warnings: string[], // malformed rows, unreadable/oversized files (non-fatal) |
| 27 | + * missing: string[], // stores that don't exist in this install |
| 28 | + * sources: Record<string,string> // resolved paths actually used |
| 29 | + * } |
| 30 | + * Exit 0 even when stores are missing (a fresh install is a valid, empty corpus); |
| 31 | + * non-zero only on an unexpected internal error. |
| 32 | + */ |
| 33 | +import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; |
| 34 | +import { join, basename } from "node:path"; |
| 35 | + |
| 36 | +const MAX_RATINGS_BYTES = 10_000_000; // guard against a huge/FIFO ratings store |
| 37 | +const MAX_NOTE_CHARS = 500; |
| 38 | + |
| 39 | +type Session = { slug: string; mtime: string }; |
| 40 | +type Frustration = { date: string; rating: number; note: string }; |
| 41 | +type RegistryEntry = { kind: "skill" | "loop" | "workflow"; name: string; description: string }; |
| 42 | +type Corpus = { |
| 43 | + window: { days: number; since: string }; |
| 44 | + sessions: Session[]; |
| 45 | + frustrations: Frustration[]; |
| 46 | + registries: RegistryEntry[]; |
| 47 | + warnings: string[]; |
| 48 | + missing: string[]; |
| 49 | + sources: Record<string, string>; |
| 50 | +}; |
| 51 | + |
| 52 | +/** Value for `flag`, or undefined. A following token that is itself a flag (starts with "-") is NOT a value. */ |
| 53 | +function arg(flag: string): string | undefined { |
| 54 | + const i = process.argv.indexOf(flag); |
| 55 | + if (i < 0 || i + 1 >= process.argv.length) return undefined; |
| 56 | + const v = process.argv[i + 1]; |
| 57 | + return v.startsWith("-") ? undefined : v; |
| 58 | +} |
| 59 | + |
| 60 | +/** Strict integer flag, clamped to [min,max]; warns (never silently accepts garbage or out-of-range). */ |
| 61 | +function intArg(flag: string, dflt: number, min: number, max: number, warnings: string[]): number { |
| 62 | + const v = arg(flag); |
| 63 | + if (v === undefined) return dflt; |
| 64 | + if (!/^-?\d+$/.test(v)) { warnings.push(`${flag}: not an integer (${v}); using ${dflt}`); return dflt; } |
| 65 | + let n = Number.parseInt(v, 10); |
| 66 | + if (n < min) { warnings.push(`${flag}: ${n} below ${min}; clamped`); n = min; } |
| 67 | + else if (n > max) { warnings.push(`${flag}: ${n} above ${max}; clamped`); n = max; } |
| 68 | + return n; |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Resolve a store path: explicit flag > env var > default under root. |
| 73 | + * An explicit path that does not exist is a warning + null (never silently falls through to a default). |
| 74 | + * `defaultRel === null` means the store has no conventional default (opt-in only, e.g. loops). |
| 75 | + */ |
| 76 | +function resolveStore( |
| 77 | + explicit: string | undefined, |
| 78 | + envVar: string, |
| 79 | + defaultRel: string | null, |
| 80 | + root: string, |
| 81 | + label: string, |
| 82 | + warnings: string[], |
| 83 | +): string | null { |
| 84 | + if (explicit !== undefined) { |
| 85 | + if (existsSync(explicit)) return explicit; |
| 86 | + warnings.push(`${label}: --${label} path ${explicit} does not exist`); |
| 87 | + return null; |
| 88 | + } |
| 89 | + const env = process.env[envVar]; |
| 90 | + if (env && existsSync(env)) return env; |
| 91 | + if (defaultRel) { const d = join(root, defaultRel); if (existsSync(d)) return d; } |
| 92 | + return null; |
| 93 | +} |
| 94 | + |
| 95 | +function parseFrustrations(file: string | null, maxRating: number, since: Date, warnings: string[], missing: string[]): Frustration[] { |
| 96 | + if (!file) { missing.push("ratings"); return []; } |
| 97 | + try { |
| 98 | + const st = statSync(file); |
| 99 | + if (!st.isFile()) { warnings.push(`ratings: not a regular file`); return []; } |
| 100 | + if (st.size > MAX_RATINGS_BYTES) { warnings.push(`ratings: too large (${st.size} bytes), skipped`); return []; } |
| 101 | + } catch (e) { warnings.push(`ratings unreadable: ${(e as Error).message}`); return []; } |
| 102 | + |
| 103 | + let raw: string; |
| 104 | + try { raw = readFileSync(file, "utf8"); } catch (e) { warnings.push(`ratings unreadable: ${(e as Error).message}`); return []; } |
| 105 | + |
| 106 | + const out: Frustration[] = []; |
| 107 | + let bad = 0; |
| 108 | + for (const line of raw.split("\n")) { |
| 109 | + const t = line.trim(); |
| 110 | + if (!t) continue; |
| 111 | + let j: unknown; |
| 112 | + try { j = JSON.parse(t); } catch { bad++; continue; } |
| 113 | + const r = j as Record<string, unknown>; |
| 114 | + const rating = typeof r.rating === "number" ? r.rating : NaN; |
| 115 | + const ts = typeof r.timestamp === "string" ? r.timestamp : ""; |
| 116 | + if (!Number.isFinite(rating) || !ts) { bad++; continue; } |
| 117 | + if (rating > maxRating) continue; |
| 118 | + const ms = new Date(ts).getTime(); |
| 119 | + if (Number.isNaN(ms)) { bad++; continue; } // unparseable timestamp = malformed, not "out of window" |
| 120 | + if (ms < since.getTime()) continue; |
| 121 | + const note = (typeof r.sentiment_summary === "string" ? r.sentiment_summary : "") |
| 122 | + .replace(/[\u0000-\u001f\u007f]/g, " ") |
| 123 | + .slice(0, MAX_NOTE_CHARS); |
| 124 | + out.push({ date: new Date(ms).toISOString().slice(0, 10), rating, note }); |
| 125 | + } |
| 126 | + if (bad > 0) warnings.push(`ratings: skipped ${bad} malformed/incomplete line(s)`); |
| 127 | + return out.sort((a, b) => a.rating - b.rating || a.date.localeCompare(b.date) || a.note.localeCompare(b.note)); |
| 128 | +} |
| 129 | + |
| 130 | +function collectSessions(dir: string | null, since: Date, warnings: string[], missing: string[]): Session[] { |
| 131 | + if (!dir) { missing.push("work-sessions"); return []; } |
| 132 | + let names: string[]; |
| 133 | + try { names = readdirSync(dir); } catch (e) { warnings.push(`work dir unreadable: ${(e as Error).message}`); return []; } |
| 134 | + const out: Session[] = []; |
| 135 | + for (const name of names) { |
| 136 | + if (name.startsWith(".") || name.startsWith("_")) continue; // skip hidden / system dirs |
| 137 | + try { |
| 138 | + const st = statSync(join(dir, name)); |
| 139 | + if (st.isDirectory() && st.mtime.getTime() >= since.getTime()) out.push({ slug: name, mtime: st.mtime.toISOString().slice(0, 10) }); |
| 140 | + } catch { /* transient race on a dir entry — skip, don't fail the run */ } |
| 141 | + } |
| 142 | + return out.sort((a, b) => b.mtime.localeCompare(a.mtime) || a.slug.localeCompare(b.slug)); |
| 143 | +} |
| 144 | + |
| 145 | +/** |
| 146 | + * name + description from a SKILL.md's YAML frontmatter block. Handles folded/block scalars |
| 147 | + * (`description: >` / `|`) by gathering the indented continuation lines. Returns null if there |
| 148 | + * is no frontmatter `name:`. |
| 149 | + */ |
| 150 | +function readMeta(skillMd: string): { name: string; description: string } | null { |
| 151 | + let txt: string; |
| 152 | + try { txt = readFileSync(skillMd, "utf8"); } catch { return null; } |
| 153 | + const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(txt); |
| 154 | + const block = fm ? fm[1] : txt; |
| 155 | + const name = /^name:\s*(.+)$/m.exec(block)?.[1]?.trim(); |
| 156 | + if (!name) return null; |
| 157 | + |
| 158 | + let desc = /^description:\s*(.+)$/m.exec(block)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; |
| 159 | + if (/^[>|][+-]?\d*$/.test(desc)) { // block scalar indicator — gather indented continuation |
| 160 | + const lines = block.split("\n"); |
| 161 | + const di = lines.findIndex((l) => /^description:\s*[>|]/.test(l)); |
| 162 | + const cont: string[] = []; |
| 163 | + for (let k = di + 1; k < lines.length; k++) { |
| 164 | + if (/^\s+\S/.test(lines[k])) cont.push(lines[k].trim()); |
| 165 | + else break; |
| 166 | + } |
| 167 | + desc = cont.join(" "); |
| 168 | + } |
| 169 | + return { name, description: desc }; |
| 170 | +} |
| 171 | + |
| 172 | +function collectRegistries(skillsDir: string | null, loopsDir: string | null, warnings: string[], missing: string[]): RegistryEntry[] { |
| 173 | + const out: RegistryEntry[] = []; |
| 174 | + if (!skillsDir) { missing.push("skills"); } |
| 175 | + else { |
| 176 | + let dirs: string[] = []; |
| 177 | + try { dirs = readdirSync(skillsDir); } catch (e) { warnings.push(`skills dir unreadable: ${(e as Error).message}`); } |
| 178 | + for (const d of dirs) { |
| 179 | + const md = join(skillsDir, d, "SKILL.md"); |
| 180 | + if (!existsSync(md)) continue; |
| 181 | + const meta = readMeta(md); |
| 182 | + if (!meta) { warnings.push(`skills: ${d}/SKILL.md unreadable or missing name:`); continue; } |
| 183 | + out.push({ kind: "skill", name: meta.name, description: meta.description }); |
| 184 | + const wf = join(skillsDir, d, "Workflows"); // workflows are reusable coverage too |
| 185 | + if (existsSync(wf)) { |
| 186 | + try { for (const f of readdirSync(wf)) if (f.endsWith(".md")) out.push({ kind: "workflow", name: `${meta.name}/${basename(f, ".md")}`, description: "" }); } catch { /* skip */ } |
| 187 | + } |
| 188 | + } |
| 189 | + } |
| 190 | + if (loopsDir) { |
| 191 | + try { for (const f of readdirSync(loopsDir)) if (f.endsWith(".md")) out.push({ kind: "loop", name: basename(f, ".md"), description: "" }); } |
| 192 | + catch (e) { warnings.push(`loops dir unreadable: ${(e as Error).message}`); } |
| 193 | + } |
| 194 | + return out.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)); |
| 195 | +} |
| 196 | + |
| 197 | +function main(): void { |
| 198 | + const warnings: string[] = []; |
| 199 | + const missing: string[] = []; |
| 200 | + |
| 201 | + const root = arg("--root") ?? process.env.SKILLSCAN_MEMORY_ROOT ?? join(process.env.HOME ?? ".", ".claude"); |
| 202 | + const days = intArg("--days", 45, 1, 3650, warnings); |
| 203 | + const maxRating = intArg("--max-rating", 4, 1, 10, warnings); |
| 204 | + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); |
| 205 | + |
| 206 | + const ratingsFile = resolveStore(arg("--ratings"), "SKILLSCAN_RATINGS_FILE", "MEMORY/LEARNING/SIGNALS/ratings.jsonl", root, "ratings", warnings); |
| 207 | + const workDir = resolveStore(arg("--work"), "SKILLSCAN_WORK_DIR", "MEMORY/WORK", root, "work", warnings); |
| 208 | + const skillsDir = resolveStore(arg("--skills"), "SKILLSCAN_SKILLS_DIR", "skills", root, "skills", warnings); |
| 209 | + const loopsDir = resolveStore(arg("--loops"), "SKILLSCAN_LOOPS_DIR", null, root, "loops", warnings); // opt-in; no personal default |
| 210 | + |
| 211 | + const corpus: Corpus = { |
| 212 | + window: { days, since: since.toISOString().slice(0, 10) }, |
| 213 | + sessions: collectSessions(workDir, since, warnings, missing), |
| 214 | + frustrations: parseFrustrations(ratingsFile, maxRating, since, warnings, missing), |
| 215 | + registries: collectRegistries(skillsDir, loopsDir, warnings, missing), |
| 216 | + warnings, |
| 217 | + missing, |
| 218 | + sources: { |
| 219 | + root, |
| 220 | + ratings: ratingsFile ?? "(none)", |
| 221 | + work: workDir ?? "(none)", |
| 222 | + skills: skillsDir ?? "(none)", |
| 223 | + loops: loopsDir ?? "(none)", |
| 224 | + }, |
| 225 | + }; |
| 226 | + |
| 227 | + process.stdout.write(JSON.stringify(corpus, null, 2) + "\n"); |
| 228 | +} |
| 229 | + |
| 230 | +main(); |
0 commit comments