#!/usr/bin/env node /** * refresh-lookups.mjs — regenerate the skill's lookup tables from the model bundle. * * The question ids, the question prose and the 41 need ids are the things an * intake session must not get wrong, so they are derived from * `packages/hoen-model/dist/model.json` rather than typed. * * Checking is the default because writing is scope-sensitive: `--check` reports * drift and touches nothing, `--write` rewrites the CSVs. * * Usage: * refresh-lookups.mjs # check the committed tables against the model * refresh-lookups.mjs --write # rewrite them after a model regeneration * refresh-lookups.mjs --check --format json * * Exit: 0 in step (or written), 1 drift found under --check, 2 usage error. * * Style: declarative/functional (see hoen-library CONVENTIONS.md → Script Conventions). */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const MODEL_REL = join("packages", "hoen-model", "dist", "model.json"); /** Prefer explicit env / cwd / transplanted layout / sibling assessment checkout. */ export const candidateModelPaths = () => [ process.env.HOEN_MODEL_PATH, process.env.HOEN_ASSESSMENT_ROOT && join(process.env.HOEN_ASSESSMENT_ROOT, MODEL_REL), join(process.cwd(), MODEL_REL), // Transplanted under consumer `.agents/skills//scripts` join(here, "..", "..", "..", "..", MODEL_REL), // Sibling: .../hoen-library/library/skills//scripts → .../hoen-assessment join(here, "..", "..", "..", "..", "..", "hoen-assessment", MODEL_REL), ].filter(Boolean); export const resolveDefaultModelPath = () => candidateModelPaths().find((path) => existsSync(path)) ?? join(process.cwd(), MODEL_REL); export const DEFAULT_MODEL_PATH = resolveDefaultModelPath(); const DEFAULT_OUT_DIR = join(here, "..", "assets"); const usage = () => `Usage: refresh-lookups.mjs [options] Regenerate assets/need-ids.csv and assets/survey-questions.csv from the model bundle. Options: --check Report drift without writing (default) --write Rewrite the CSVs --format FORMAT text (default) | json --model PATH Model bundle (default: HOEN_MODEL_PATH, HOEN_ASSESSMENT_ROOT, cwd, transplanted .agents layout, or sibling hoen-assessment checkout) --out DIR Output directory (default: the skill's assets/) -h, --help Show help Exit codes: 0 tables match the model, or were written 1 drift found under --check 2 usage error `; /** RFC 4180 quoting — question prose carries commas and apostrophes. */ export const csvCell = (value) => /[",\n]/.test(String(value ?? "")) ? `"${String(value ?? "").replace(/"/g, '""')}"` : String(value ?? ""); export const toCsv = (rows) => `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`; export const needRows = (model) => [ ["need_id", "level", "name"], ...model.needs.map((need) => [need.id, need.level, need.name]), ]; export const questionRows = (model) => [ ["question_id", "text", "priority", "feeds_needs"], ...model.surveyQuestions.map((question) => [ question.id, question.text, question.priority, Object.entries(question.needWeights) .filter(([, weight]) => weight > 0) .sort(([, a], [, b]) => b - a) .map(([needId, weight]) => `${needId}:${weight}`) .join(" | "), ]), ]; /** Filename → expected contents, for both the writer and the checker. */ export const buildTables = (model) => ({ "need-ids.csv": toCsv(needRows(model)), "survey-questions.csv": toCsv(questionRows(model)), }); const readOrNull = (path) => { try { return readFileSync(path, "utf8"); } catch { return null; } }; export const compareTables = (tables, outDir, read = readOrNull) => Object.entries(tables).map(([name, expected]) => { const actual = read(join(outDir, name)); return { file: name, status: actual === null ? "missing" : actual === expected ? "in-step" : "drifted", }; }); export const writeTables = (tables, outDir, write = writeFileSync) => Object.entries(tables).map(([name, contents]) => ( write(join(outDir, name), contents), { file: name, status: "written" } )); export const summarise = (model, results) => ({ model: model.version, needs: model.needs.length, questions: model.surveyQuestions.length, results, drifted: results.filter((result) => result.status !== "in-step" && result.status !== "written") .length, }); export const renderText = (summary) => [ `model ${summary.model}: ${summary.needs} needs, ${summary.questions} questions`, ...summary.results.map((result) => ` ${result.status.padEnd(8)} ${result.file}`), summary.drifted ? `${summary.drifted} table(s) out of step — re-run with --write` : "lookup tables are in step with the model", ].join("\n"); /* --------------------------------------------------------------------- CLI */ export const parseArgs = ( args, acc = { mode: "check", format: "text", model: DEFAULT_MODEL_PATH, out: DEFAULT_OUT_DIR }, ) => args.length === 0 ? acc : args[0] === "--check" ? parseArgs(args.slice(1), { ...acc, mode: "check" }) : args[0] === "--write" ? parseArgs(args.slice(1), { ...acc, mode: "write" }) : args[0] === "--format" ? parseArgs(args.slice(2), { ...acc, format: args[1] ?? "" }) : args[0] === "--model" ? parseArgs(args.slice(2), { ...acc, model: args[1] ?? "" }) : args[0] === "--out" ? parseArgs(args.slice(2), { ...acc, out: args[1] ?? "" }) : ["-h", "--help"].includes(args[0]) ? { ...acc, help: true } : { ...acc, error: `unknown argument '${args[0]}'` }; const fail = (message) => ( console.error(`Error: ${message}\n${usage()}`), process.exit(2) ); const main = () => { const args = parseArgs(process.argv.slice(2)); args.help && (console.log(usage()), process.exit(0)); args.error && fail(args.error); ["text", "json"].includes(args.format) || fail(`unknown --format '${args.format}' (use text|json)`); const model = JSON.parse(readFileSync(args.model, "utf8")); const tables = buildTables(model); const summary = summarise( model, args.mode === "write" ? writeTables(tables, args.out) : compareTables(tables, args.out), ); console.log(args.format === "json" ? JSON.stringify(summary, null, 2) : renderText(summary)); process.exit(summary.drifted ? 1 : 0); }; const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); isMain && main();