#!/usr/bin/env node // classify-ref-type.mjs — Spor 1 Step 3: IO-shell CLI around lib/classify-ref-type.mjs. // // Walks skills/*/references/**/*.md (ALL five skills — advisor is classified, never // mutated), classifies each file, and persists the shared migration artifact // scripts/kb-update/data/ref-type-manifest.json on --write. // // Re-entrance guard (plan v1.8): Steps 4/6 enrich the manifest in place // (resolvedBy / source / deferred + adjudicated type/reviewFlag). A naive re-run of // --write would clobber that judgment. Therefore --write REFUSES (exit 3) when the // existing manifest carries any enriched entry, unless --merge is passed. Under // --merge: enriched entries are preserved verbatim, unenriched entries are // re-classified fresh, disk-new files are added, and manifest-only entries whose // file no longer exists are dropped (the walk is ground truth). // // Usage: // node scripts/kb-update/classify-ref-type.mjs # stdout summary // node scripts/kb-update/classify-ref-type.mjs --json # manifest to stdout // node scripts/kb-update/classify-ref-type.mjs --write [--merge] [--out ] // // Exit codes: 0 ok · 2 usage error · 3 re-entrance guard (enriched manifest, no --merge) import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs'; import { join, relative, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { classifyCorpus } from './lib/classify-ref-type.mjs'; import { atomicWriteJson } from './lib/atomic-write.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); const SKILLS_DIR = join(PLUGIN_ROOT, 'skills'); // Pinned everywhere in the plan — the single shared artifact downstream steps read. const DEFAULT_OUT = join(__dirname, 'data', 'ref-type-manifest.json'); const KNOWN_FLAGS = new Set(['--json', '--write', '--merge', '--out']); // Walk directory recursively for .md files (build-registry.mjs shape). function walkMd(dir) { const results = []; if (!existsSync(dir)) return results; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { results.push(...walkMd(full)); } else if (entry.name.endsWith('.md') && entry.name !== 'SKILL.md') { results.push(full); } } return results; } /** An entry is enriched when Step 4/6 judgment has been written into it. */ function isEnriched(entry) { return entry && (entry.resolvedBy !== undefined || entry.source !== undefined || entry.deferred !== undefined); } /** * Merge an existing (possibly enriched) manifest with a fresh classification. * Fresh walk is ground truth for WHICH files exist; enrichment is truth for WHAT * an adjudicated entry says. Key-sorted output. */ function mergeManifests(existing, fresh) { const merged = {}; for (const path of Object.keys(fresh).sort()) { const prev = existing[path]; merged[path] = isEnriched(prev) ? prev : fresh[path]; } return merged; } function main(argv) { const args = argv.slice(2); let outPath = DEFAULT_OUT; for (let i = 0; i < args.length; i++) { if (args[i] === '--out') { if (!args[i + 1]) { process.stderr.write('usage: classify-ref-type.mjs [--json] [--write] [--merge] [--out ]\n'); process.exit(2); } outPath = args[++i]; continue; } if (!KNOWN_FLAGS.has(args[i])) { process.stderr.write(`unknown flag: ${args[i]}\nusage: classify-ref-type.mjs [--json] [--write] [--merge] [--out ]\n`); process.exit(2); } } const json = args.includes('--json'); const write = args.includes('--write'); const merge = args.includes('--merge'); const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); const entries = skillDirs .flatMap((skill) => walkMd(join(SKILLS_DIR, skill, 'references'))) .map((abs) => ({ path: relative(PLUGIN_ROOT, abs), content: readFileSync(abs, 'utf8') })); let manifest = classifyCorpus(entries); if (write) { const existing = existsSync(outPath) ? JSON.parse(readFileSync(outPath, 'utf8')) : null; const enrichedCount = existing ? Object.values(existing).filter(isEnriched).length : 0; if (enrichedCount > 0 && !merge) { process.stderr.write( `refusing --write: existing manifest at ${outPath} carries ${enrichedCount} enriched ` + 'entr(y/ies) (resolvedBy/source/deferred) that a fresh write would clobber. ' + 'Re-run with --merge to preserve the enrichment.\n' ); process.exit(3); } if (existing && merge) { manifest = mergeManifests(existing, manifest); } atomicWriteJson(outPath, manifest); } if (json) { process.stdout.write(JSON.stringify(manifest, null, 2) + '\n'); } else { const values = Object.values(manifest); const flagged = values.filter((e) => e.reviewFlag).length; console.log(`Classified ${values.length} reference files (${flagged} flagged for review)` + (write ? ` → ${relative(PLUGIN_ROOT, outPath)}` : '')); } } const isMain = (() => { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })(); if (isMain) main(process.argv);