Files
pbakaus_impeccable/skill/scripts/design-context/store.mjs
T
Abdul WahabandClaude Fable 5 065bd4f3ca Trim dead code and tighten the branch to surgical scope
- palette-picker.js: drop the superseded APP/DOCS/INDEX copy tables, the
  disabled font-rail hoist machinery, LOREM.caption, and an orphan marker
- design-context.css + dcx-hierarchy.css: drop the demo-era specimen,
  radius, and spacing-bar rule groups nothing renders
- index.astro: drop an unreferenced boards const
- de-export module-internal names (store, bindings, portability,
  visual-cues); remove picker-server's unused doc-session kill switch and
  document --doc in its help
- palette.mjs: realpath the entry guard so symlinked installs still run
  the CLI (same guard as visual-cues.mjs)
- revert serve-question.mjs copy ride-along; drop the plan/ gitignore
  entry; dedupe a build-picker comment; file design-context with the
  system category entries; alphabetize IMPECCABLE_SUB_COMMANDS
- new-work.md: drop a same-screen restatement of the comp/world ranking
- CLAUDE.md: correct the picker gate baseline to the measured 606 and
  explain the per-element multiplication; the trims removed the two
  dead-block font findings
- DESIGN-CONTEXT-ARCHITECTURE.md: list portability.mjs in the code layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:22 +05:00

241 lines
9.3 KiB
JavaScript

/** The design-context store: the one place that knows where design context lives.
*
* Layout, under the project root:
*
* .impeccable/design-context/
* context.json { schemaVersion, modes, context } the chat half of the interview
* answers.json the questionnaire submission, flat FormData shape
* assets/ brand files the user supplied
* fonts/ font faces the user uploaded
* cue.png the chosen hero, copied at submit so the document stands alone
* runtime/ session.json, journal.jsonl, draft.json (gitignored)
* exports/ design-context.md, design-context.bundle.json (gitignored)
*
* Two rules hold this together. Every write goes through writeJsonAtomic, so a
* reader never sees a torn file. Every read comes off disk, so no process ever
* answers from a copy the file has moved past.
*
* Zero dependencies beyond node: builtins, like every other picker script.
*/
import fs from 'node:fs';
import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
const STORE_DIR = '.impeccable/design-context';
const WORKSPACE_DIR = '.impeccable/visual-cues';
/* The shape of context.json. Bump only when the shape changes, never for a release. */
export const SCHEMA_VERSION = 1;
const LEGACY_DIR = '.impeccable/design-interview';
const LEGACY_FONTS_PREFIX = `${LEGACY_DIR}/fonts/`;
export function paths(cwd = process.cwd()) {
const store = path.resolve(cwd, STORE_DIR);
const runtime = path.join(store, 'runtime');
return {
storeDir: store,
contextJson: path.join(store, 'context.json'),
answersJson: path.join(store, 'answers.json'),
assetsDir: path.join(store, 'assets'),
fontsDir: path.join(store, 'fonts'),
cuePng: path.join(store, 'cue.png'),
runtimeDir: runtime,
sessionJson: path.join(runtime, 'session.json'),
journalJsonl: path.join(runtime, 'journal.jsonl'),
draftJson: path.join(runtime, 'draft.json'),
exportsDir: path.join(store, 'exports'),
cuesJson: path.resolve(cwd, WORKSPACE_DIR, 'cues.json'),
fontsManifestJson: path.resolve(cwd, WORKSPACE_DIR, 'fonts.json'),
};
}
/** The project-relative path an uploaded font is reported by, and stored under. */
export function fontRelativePath(name) {
return path.join(STORE_DIR, 'fonts', name);
}
export async function writeJsonAtomic(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`);
await rename(temporary, filePath);
}
export async function readJsonSoft(filePath) {
try {
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
export const readContext = (cwd = process.cwd()) => readJsonSoft(paths(cwd).contextJson);
export const writeContext = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).contextJson, value);
export const readAnswers = (cwd = process.cwd()) => readJsonSoft(paths(cwd).answersJson);
export const writeAnswers = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).answersJson, value);
export const readDraft = (cwd = process.cwd()) => readJsonSoft(paths(cwd).draftJson);
export const writeDraft = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).draftJson, value);
export const clearDraft = (cwd = process.cwd()) => rm(paths(cwd).draftJson, { force: true }).catch(() => {});
/* ============================================================
The journal: append-only, replayed on every read.
============================================================ */
/** Append one event, stamped with the next seq and a timestamp. Returns the seq. */
export function appendJournal(event, cwd = process.cwd()) {
const { runtimeDir, journalJsonl } = paths(cwd);
const seq = replayJournal(cwd).lastSeq + 1;
fs.mkdirSync(runtimeDir, { recursive: true });
fs.appendFileSync(journalJsonl, `${JSON.stringify({ seq, ts: new Date().toISOString(), ...event })}\n`);
return seq;
}
/**
* Fold the journal into the state a booting session needs.
*
* Lines the fold cannot use are collected rather than thrown: a legacy
* doc-edits.jsonl record carries { at, type: 'color' } and no seq, and a torn
* final line is possible after a hard kill. Neither can move lastSeq or
* resurrect a batch, so both are diagnostics, not failures.
*/
export function replayJournal(cwd = process.cwd()) {
const { journalJsonl } = paths(cwd);
const state = { lastSeq: 0, pendingBatch: null, entries: [], diagnostics: [] };
let raw;
try {
raw = fs.readFileSync(journalJsonl, 'utf8');
} catch {
return state;
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
state.diagnostics.push({ reason: 'unparseable', line: line.slice(0, 200) });
continue;
}
if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.seq)) {
state.diagnostics.push({ reason: 'legacy-or-unsequenced', type: entry?.type || null });
continue;
}
state.entries.push(entry);
if (entry.seq > state.lastSeq) state.lastSeq = entry.seq;
if (entry.type === 'batch') {
state.pendingBatch = entry.status === 'pending' ? entry : null;
}
}
return state;
}
/* ============================================================
Migration from the pre-store layout.
============================================================ */
export function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
/* EPERM means the process exists and is not ours to signal. */
return error.code === 'EPERM';
}
}
async function moveFile(from, to) {
if (fs.existsSync(to) || !fs.existsSync(from)) return false;
await mkdir(path.dirname(to), { recursive: true });
await rename(from, to);
return true;
}
/* Directories move child by child: renaming onto an existing directory fails,
and a run interrupted halfway leaves a destination that already exists. */
async function moveDirContents(fromDir, toDir) {
if (!fs.existsSync(fromDir)) return;
await mkdir(toDir, { recursive: true });
for (const name of fs.readdirSync(fromDir)) {
await moveFile(path.join(fromDir, name), path.join(toDir, name));
}
try {
if (fs.readdirSync(fromDir).length === 0) fs.rmdirSync(fromDir);
} catch {
/* Something arrived between the read and the remove; leaving it is safe. */
}
}
/** Uploaded-face paths were recorded as strings inside the answers themselves. */
function rewriteFontSources(answers) {
if (!answers || typeof answers !== 'object') return null;
let touched = false;
for (const [key, value] of Object.entries(answers)) {
if (typeof value !== 'string' || !value.includes(LEGACY_FONTS_PREFIX)) continue;
answers[key] = value.split(LEGACY_FONTS_PREFIX).join(`${STORE_DIR}/fonts/`);
touched = true;
}
return touched ? answers : null;
}
/**
* Bring a pre-store project onto the current layout. Idempotent and silent:
* a project that is already current, or was never interviewed, does nothing.
*
* A live session of the old shape holds the old paths in its own constants, so
* migrating under it would strand its writes. That case defers to the next boot.
*/
export async function migrate(cwd = process.cwd()) {
const legacyDir = path.resolve(cwd, LEGACY_DIR);
if (!fs.existsSync(legacyDir)) {
await migrateContextFromCues(cwd);
return { migrated: false, deferred: false };
}
const legacySession = path.join(legacyDir, 'doc-session.json');
const session = await readJsonSoft(legacySession);
if (session && pidAlive(session.pid)) return { migrated: false, deferred: true };
const target = paths(cwd);
await moveFile(path.join(legacyDir, 'answers.json'), target.answersJson);
await moveFile(path.join(legacyDir, 'doc-edits.jsonl'), target.journalJsonl);
await moveDirContents(path.join(legacyDir, 'assets'), target.assetsDir);
await moveDirContents(path.join(legacyDir, 'fonts'), target.fontsDir);
const answers = await readJsonSoft(target.answersJson);
const rewritten = rewriteFontSources(answers);
if (rewritten) await writeJsonAtomic(target.answersJson, rewritten);
await rm(legacySession, { force: true }).catch(() => {});
try {
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
} catch {
/* Files the migration does not own stay where they are. */
}
await migrateContextFromCues(cwd);
return { migrated: true, deferred: false };
}
/* The chat half of the interview used to ride inside the cue manifest. It is
not a generation artifact, so it moves to the store; cues.json keeps its
cues and palette and is left untouched. */
async function migrateContextFromCues(cwd) {
const target = paths(cwd);
if (fs.existsSync(target.contextJson)) return;
const cues = await readJsonSoft(target.cuesJson);
if (!cues) return;
const hasModes = Array.isArray(cues.modes);
const hasContext = cues.context && typeof cues.context === 'object';
if (!hasModes && !hasContext) return;
await writeJsonAtomic(target.contextJson, {
schemaVersion: SCHEMA_VERSION,
...(hasModes ? { modes: cues.modes } : {}),
...(hasContext ? { context: cues.context } : {}),
});
}