mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +03:00
update
This commit is contained in:
@@ -90,5 +90,9 @@
|
||||
"typeset": {
|
||||
"description": "Improves typography by fixing font choices, hierarchy, sizing, weight, and readability so text feels intentional. Use when the user mentions fonts, type, readability, text hierarchy, sizing looks off, or wants more polished, intentional typography.",
|
||||
"argumentHint": "[target]"
|
||||
},
|
||||
"design-context": {
|
||||
"description": "Reopen, revise, export, or import the design interview and its design context document",
|
||||
"argumentHint": "[open|edit|export|import] [bundle-file]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/** Write this project's design context out in two forms.
|
||||
*
|
||||
* node <scripts_path>/design-context-export.mjs [--out DIR] [--no-assets]
|
||||
*
|
||||
* design-context.md one document a reader or another tool can follow
|
||||
* design-context.bundle.json everything needed to rebuild the store elsewhere
|
||||
*
|
||||
* Prints one EXPORTED line per file written. Exit 1 when the project has no
|
||||
* design interview to export.
|
||||
*/
|
||||
|
||||
import { migrate } from './design-context/store.mjs';
|
||||
import { exportDesignContext } from './design-context/portability.mjs';
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node design-context-export.mjs [options]
|
||||
|
||||
Write the design context to a readable document and a portable bundle.
|
||||
|
||||
Options:
|
||||
--out DIR Where to write (default: .impeccable/design-context/exports)
|
||||
--no-assets Leave supplied files and the cue image out of the bundle
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
EXPORTED PATH One line per file written
|
||||
|
||||
See reference/design-context.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const readValue = (name) => {
|
||||
const exact = args.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (exact) return exact.slice(name.length + 1);
|
||||
const at = args.indexOf(name);
|
||||
return at !== -1 && args[at + 1] && !args[at + 1].startsWith('--') ? args[at + 1] : '';
|
||||
};
|
||||
|
||||
const unknown = args.find((arg) => arg.startsWith('--')
|
||||
&& !['--out', '--no-assets', '--help'].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
|
||||
if (unknown) {
|
||||
console.error(`Unknown option: ${unknown}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate(process.cwd());
|
||||
|
||||
try {
|
||||
const { markdownPath, bundlePath, skipped } = await exportDesignContext(process.cwd(), {
|
||||
outDir: readValue('--out') || undefined,
|
||||
includeAssets: !args.includes('--no-assets'),
|
||||
});
|
||||
for (const entry of skipped) {
|
||||
console.error(`Skipped ${entry.path} (${entry.bytes} bytes): ${entry.reason}`);
|
||||
}
|
||||
console.log(`EXPORTED ${markdownPath}`);
|
||||
console.log(`EXPORTED ${bundlePath}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rebuild a design context in this project from a bundle another one exported.
|
||||
*
|
||||
* node <scripts_path>/design-context-import.mjs <bundle.json>
|
||||
* [--design skip|write] [--force]
|
||||
*
|
||||
* Refuses a project that already has a design context unless --force, and
|
||||
* refuses either way while an edit session is running, because the session is
|
||||
* the only writer of the store while it lives.
|
||||
*
|
||||
* Prints IMPORTED <n> files and DESIGN_MD carried|absent for the agent to
|
||||
* branch on. Exit 1 on a bundle this release cannot read.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { migrate, paths, pidAlive, readAnswers, readJsonSoft } from './design-context/store.mjs';
|
||||
import { importDesignContext, validateBundle } from './design-context/portability.mjs';
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node design-context-import.mjs <bundle.json> [options]
|
||||
|
||||
Rebuild this project's design context from an exported bundle.
|
||||
|
||||
Options:
|
||||
--design skip|write Write DESIGN.md when the bundle carries one and this
|
||||
project has none (default: skip)
|
||||
--force Replace an existing design context
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
IMPORTED N files
|
||||
DESIGN_MD carried|absent
|
||||
|
||||
See reference/design-context.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.length || args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
process.exit(args.length ? 0 : 1);
|
||||
}
|
||||
|
||||
const source = args.find((arg) => !arg.startsWith('--'));
|
||||
if (!source) {
|
||||
console.error('Name the bundle to import.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const designAt = args.indexOf('--design');
|
||||
const design = designAt !== -1 && args[designAt + 1] ? args[designAt + 1] : 'skip';
|
||||
if (!['skip', 'write'].includes(design)) {
|
||||
console.error('--design must be skip or write');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await migrate(process.cwd());
|
||||
const target = paths(process.cwd());
|
||||
|
||||
/* A running session holds the store: importing under it would swap the run out
|
||||
from beneath the document someone is reading and the batch it may owe. */
|
||||
const session = await readJsonSoft(target.sessionJson);
|
||||
if (session && pidAlive(session.pid)) {
|
||||
console.error(`A design context document is open on http://127.0.0.1:${session.port}. Close it, then import.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!args.includes('--force') && await readAnswers(process.cwd())) {
|
||||
console.error('This project already has a design context. Re-run with --force to replace it.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let bundle;
|
||||
try {
|
||||
bundle = validateBundle(JSON.parse(await readFile(path.resolve(process.cwd(), source), 'utf8')));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await importDesignContext(process.cwd(), bundle, { design });
|
||||
console.log(`IMPORTED ${result.written} files`);
|
||||
console.log(`DESIGN_MD ${result.designCarried ? 'carried' : 'absent'}${result.designWritten ? ' written' : ''}`);
|
||||
@@ -0,0 +1,82 @@
|
||||
/** What the design context document lets a person edit, and where it lands.
|
||||
*
|
||||
* Every editable field has an id the browser sends and this file resolves into
|
||||
* a file and a path inside it. That is what makes applying a change a
|
||||
* deterministic write rather than a search: the document names the field, not
|
||||
* the text it happens to hold.
|
||||
*
|
||||
* `file` is the store file the value lives in. For `context`, the path is
|
||||
* relative to the top-level `context` object, so `product.purpose` addresses
|
||||
* `context.product.purpose` inside context.json.
|
||||
*
|
||||
* `downstream` names the document the agent reconciles afterwards. The value
|
||||
* itself is already applied by the time the agent hears about it; what needs a
|
||||
* reader is the prose around it.
|
||||
*/
|
||||
|
||||
export const BINDINGS = {
|
||||
'palette.primary': { file: 'answers', path: 'palette-primary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.secondary': { file: 'answers', path: 'palette-secondary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.tertiary': { file: 'answers', path: 'palette-tertiary', kind: 'color', downstream: 'design-md' },
|
||||
'palette.neutral': { file: 'answers', path: 'palette-neutral', kind: 'color', downstream: 'design-md' },
|
||||
|
||||
'product.purpose': { file: 'context', path: 'product.purpose', kind: 'text', maxLen: 600, downstream: 'product-md' },
|
||||
'product.positioning.not': { file: 'context', path: 'product.positioning.not', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'product.positioning.this': { file: 'context', path: 'product.positioning.this', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
|
||||
'audience.primary': { file: 'context', path: 'audience.primary', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.secondary': { file: 'context', path: 'audience.secondary', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.emotion': { file: 'context', path: 'audience.emotion', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
'audience.leaving': { file: 'context', path: 'audience.leaving', kind: 'text', maxLen: 300, downstream: 'product-md' },
|
||||
|
||||
'brand.personality': { file: 'context', path: 'brand.personality', kind: 'text', maxLen: 600, downstream: 'product-md' },
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_LEN = 2000;
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export const bindingFor = (id) => (Object.hasOwn(BINDINGS, id) ? BINDINGS[id] : null);
|
||||
|
||||
/**
|
||||
* Turn what a contenteditable produced into something safe to write.
|
||||
*
|
||||
* Everything arriving here was typed into a browser, so it is treated as text
|
||||
* and nothing else: control characters go, newlines collapse (every bound field
|
||||
* is a single line in the document), and the length is capped where the field
|
||||
* says so. A value that survives is a string; a value that cannot be one throws.
|
||||
*/
|
||||
export function sanitizeValue(binding, raw) {
|
||||
if (binding.kind === 'color') {
|
||||
const value = String(raw ?? '').trim().toUpperCase();
|
||||
if (!HEX.test(value)) throw new Error('Expected a #rrggbb color');
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = String(raw ?? '')
|
||||
/* Newlines first, because they are the one control character with a
|
||||
meaning here: a pasted paragraph becomes one line rather than nothing. */
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
if (!text) throw new Error('Expected some text');
|
||||
return text.slice(0, binding.maxLen || DEFAULT_MAX_LEN);
|
||||
}
|
||||
|
||||
/** Read a dotted path out of a plain object, without creating anything. */
|
||||
export function readPath(root, dotted) {
|
||||
return dotted.split('.').reduce((node, key) => (node && typeof node === 'object' ? node[key] : undefined), root);
|
||||
}
|
||||
|
||||
/** Write a dotted path into a plain object, creating the objects on the way. */
|
||||
export function writePath(root, dotted, value) {
|
||||
const keys = dotted.split('.');
|
||||
const last = keys.pop();
|
||||
let node = root;
|
||||
for (const key of keys) {
|
||||
if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
|
||||
node = node[key];
|
||||
}
|
||||
node[last] = value;
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/** Taking a design context out of a project, and putting one into another.
|
||||
*
|
||||
* Two shapes, because they answer different questions. `design-context.md` is
|
||||
* for a reader, human or otherwise: one document that says what was decided
|
||||
* and why, which can be handed to another tool as the rules to follow. The
|
||||
* bundle is for this toolchain: everything needed to rebuild the store
|
||||
* somewhere else, including the bytes of the files the user supplied.
|
||||
*
|
||||
* The bundle carries the schema version, not the store. A store file's era is
|
||||
* readable from its own keys, and stamping the browser's submission would mean
|
||||
* rewriting what it sent.
|
||||
*/
|
||||
|
||||
import { readFile, mkdir, readdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
paths,
|
||||
readAnswers,
|
||||
readContext,
|
||||
readJsonSoft,
|
||||
writeAnswers,
|
||||
writeContext,
|
||||
writeJsonAtomic,
|
||||
SCHEMA_VERSION,
|
||||
} from './store.mjs';
|
||||
|
||||
export const BUNDLE_KIND = 'impeccable-design-context';
|
||||
export const BUNDLE_SCHEMA = 1;
|
||||
|
||||
const MAX_FILE_BYTES = 1024 * 1024;
|
||||
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const MIME = new Map([
|
||||
['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'],
|
||||
['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
||||
]);
|
||||
|
||||
/* Exactly the three places an export puts bytes, and so exactly the three an
|
||||
import will write them back to. Anything else in a bundle is not ours. */
|
||||
const ALLOWED_FILE = /^(assets\/[^/]+|fonts\/[^/]+|cue\.png)$/;
|
||||
|
||||
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
|
||||
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
|
||||
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
|
||||
|
||||
/* ============================================================
|
||||
Export
|
||||
============================================================ */
|
||||
|
||||
async function collectFiles(cwd, { includeAssets = true } = {}) {
|
||||
const target = paths(cwd);
|
||||
const files = [];
|
||||
const skipped = [];
|
||||
let total = 0;
|
||||
|
||||
const take = async (absolute, relative) => {
|
||||
let bytes;
|
||||
try {
|
||||
bytes = await readFile(absolute);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (bytes.length > MAX_FILE_BYTES || total + bytes.length > MAX_BUNDLE_BYTES) {
|
||||
skipped.push({ path: relative, bytes: bytes.length, reason: 'too large for the bundle' });
|
||||
return;
|
||||
}
|
||||
total += bytes.length;
|
||||
files.push({
|
||||
path: relative,
|
||||
mime: MIME.get(path.extname(relative).toLowerCase()) || 'application/octet-stream',
|
||||
base64: bytes.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
if (!includeAssets) return { files, skipped };
|
||||
|
||||
for (const [dir, prefix] of [[target.assetsDir, 'assets'], [target.fontsDir, 'fonts']]) {
|
||||
let names = [];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of names.sort()) await take(path.join(dir, name), `${prefix}/${name}`);
|
||||
}
|
||||
await take(target.cuePng, 'cue.png');
|
||||
return { files, skipped };
|
||||
}
|
||||
|
||||
export async function buildBundle(cwd, { includeAssets = true, now = new Date() } = {}) {
|
||||
const target = paths(cwd);
|
||||
const answers = await readAnswers(cwd);
|
||||
if (!answers) throw new Error('No design interview found. Run /impeccable document to create one.');
|
||||
|
||||
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
|
||||
const cues = await readJsonSoft(target.cuesJson);
|
||||
const source = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
|
||||
/* A seed or custom palette names no cue, so there is no image and no dealt
|
||||
entry to carry. The hexes in the answers are the palette of record. */
|
||||
const chosenCuePalette = source && cues?.palette?.[source] ? cues.palette[source] : null;
|
||||
|
||||
const { files, skipped } = await collectFiles(cwd, { includeAssets });
|
||||
let designMd = null;
|
||||
try {
|
||||
designMd = await readFile(path.resolve(cwd, 'DESIGN.md'), 'utf8');
|
||||
} catch {
|
||||
/* Not written yet, which an import is told about rather than guessing. */
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: BUNDLE_SCHEMA,
|
||||
kind: BUNDLE_KIND,
|
||||
exportedAt: now.toISOString(),
|
||||
product: { name: stored.context?.product?.name || '' },
|
||||
context: stored,
|
||||
answers,
|
||||
/* Whole, never trimmed: the questionnaire validates the manifest by its
|
||||
pair count and quietly falls back to its own set at any other number. */
|
||||
fonts: await readJsonSoft(target.fontsManifestJson),
|
||||
chosenCue: chosenCuePalette ? { slug: source, palette: chosenCuePalette } : null,
|
||||
designMd,
|
||||
files,
|
||||
...(skipped.length ? { skipped } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
The readable compilation
|
||||
============================================================ */
|
||||
|
||||
const line = (label, value) => (value ? `- **${label}:** ${value}\n` : '');
|
||||
|
||||
function paletteTable(answers) {
|
||||
const rows = ROLES
|
||||
.map((role) => [role, String(answers[`palette-${role}`] || '')])
|
||||
.filter(([, hex]) => hex);
|
||||
if (!rows.length) return '';
|
||||
return `| Role | Value |\n| --- | --- |\n${rows.map(([role, hex]) => `| ${role} | \`${hex}\` |`).join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
function perSurfaceTable(answers, surfaces) {
|
||||
const rows = [];
|
||||
for (const key of PER_SURFACE) {
|
||||
for (const mode of surfaces) {
|
||||
const value = answers[`${key}-${mode}`];
|
||||
if (value) rows.push([key, SURFACE_LABELS[mode] || mode, String(value), answers[key] === value]);
|
||||
}
|
||||
}
|
||||
if (!rows.length) return '';
|
||||
return `| Question | Surface | Answer |\n| --- | --- | --- |\n${rows
|
||||
.map(([key, label, value, leads]) => `| ${key} | ${label}${leads ? ' (leads)' : ''} | ${value} |`)
|
||||
.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
/** One document a reader, or another tool, can follow without this toolchain. */
|
||||
export function renderMarkdown(bundle) {
|
||||
const context = bundle.context?.context || {};
|
||||
const answers = bundle.answers || {};
|
||||
const name = bundle.product?.name || 'This product';
|
||||
const surfaces = [].concat(answers['surface-modes'] || []).filter(Boolean);
|
||||
const out = [];
|
||||
|
||||
out.push(`# Design context: ${name}\n\n`);
|
||||
out.push('The decisions this product\'s design follows, and the reasoning behind them. ');
|
||||
out.push('Exported from Impeccable; treat it as the source of truth for visual and product direction.\n\n');
|
||||
|
||||
const audience = context.audience || {};
|
||||
if (Object.keys(audience).length) {
|
||||
out.push('## Audience\n\n');
|
||||
out.push(line('Primary', audience.primary));
|
||||
out.push(line('Secondary', audience.secondary));
|
||||
out.push(line('On arrival', audience.emotion));
|
||||
out.push(line('Leaving with', audience.leaving));
|
||||
if (audience.needs?.length) out.push(`- **Needs:** ${audience.needs.join('; ')}\n`);
|
||||
if (audience.trust?.length) out.push(`- **Trust triggers:** ${audience.trust.join('; ')}\n`);
|
||||
if (audience.inclusion?.length) out.push(`- **Must not exclude:** ${audience.inclusion.join('; ')}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const product = context.product || {};
|
||||
if (Object.keys(product).length) {
|
||||
out.push('## Product\n\n');
|
||||
out.push(line('Purpose', product.purpose));
|
||||
out.push(line('Success', product.success));
|
||||
out.push(line('Platform', product.platform));
|
||||
out.push(line('Primary conversion', product.conversion));
|
||||
if (product.positioning?.not) out.push(`- **Not this:** ${product.positioning.not}\n`);
|
||||
if (product.positioning?.this) out.push(`- **This:** ${product.positioning.this}\n`);
|
||||
if (product.clarities?.length) out.push(`- **Clear first:** ${product.clarities.join('; ')}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const brand = context.brand || {};
|
||||
if (Object.keys(brand).length) {
|
||||
out.push('## Brand\n\n');
|
||||
if (brand.words?.length) out.push(line('Words', brand.words.join(', ')));
|
||||
out.push(line('Personality', brand.personality));
|
||||
if (brand.commitments?.length) out.push(`- **Commitments:** ${brand.commitments.join('; ')}\n`);
|
||||
if (brand.voice?.length) {
|
||||
out.push('\nVoice, as wording rather than adjectives:\n\n');
|
||||
for (const pair of brand.voice) {
|
||||
if (pair?.say && pair?.not) out.push(`- Say: ${pair.say}\n Not: ${pair.not}\n`);
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
const interview = context.interview || {};
|
||||
if (interview.references?.length || interview.antiReference) {
|
||||
out.push('## References\n\n');
|
||||
for (const reference of interview.references || []) {
|
||||
if (typeof reference === 'string') out.push(`- ${reference}\n`);
|
||||
else if (reference?.name) out.push(`- **${reference.name}**${reference.takeaway ? `: ${reference.takeaway}` : ''}\n`);
|
||||
}
|
||||
const anti = interview.antiReference;
|
||||
if (typeof anti === 'string') out.push(`- **Anti-reference:** ${anti}\n`);
|
||||
else if (anti?.name) out.push(`- **Anti-reference:** ${anti.name}${anti.why ? ` (${anti.why})` : ''}\n`);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
out.push('## Decisions\n\n');
|
||||
if (surfaces.length) {
|
||||
out.push(`Surfaces: ${surfaces.map((mode) => SURFACE_LABELS[mode] || mode).join(', ')}. `);
|
||||
out.push('The first of these owns any answer stated once for the whole product.\n\n');
|
||||
}
|
||||
out.push('### Palette\n\n');
|
||||
out.push(paletteTable(answers));
|
||||
if (bundle.chosenCue?.slug) out.push(`Sampled from the generated cue \`${bundle.chosenCue.slug}\`.\n\n`);
|
||||
|
||||
out.push('### Typography\n\n');
|
||||
out.push(line('Heading', answers['font-heading']));
|
||||
out.push(line('Body', answers['font-body']));
|
||||
out.push(line('Type scale', answers['type-scale'] && `${answers['type-scale']} (${answers['type-scale-ratio']})`));
|
||||
out.push('\n');
|
||||
|
||||
if (answers['icon-pack-name']) {
|
||||
out.push('### Icons\n\n');
|
||||
out.push(`- **Pack:** ${answers['icon-pack-name']}${answers['icon-pack-license'] ? ` (${answers['icon-pack-license']})` : ''}\n`);
|
||||
if (answers['icon-pack-url']) out.push(`- **Source:** ${answers['icon-pack-url']}\n`);
|
||||
out.push('\nEvery icon comes from this pack; do not mix sets.\n\n');
|
||||
}
|
||||
|
||||
const perSurface = perSurfaceTable(answers, surfaces);
|
||||
if (perSurface) {
|
||||
out.push('### Per surface\n\n');
|
||||
out.push(perSurface);
|
||||
}
|
||||
if (answers['layout-structure']) out.push(`Composition: ${answers['layout-structure']}, one answer for the whole product.\n\n`);
|
||||
|
||||
if (bundle.designMd) {
|
||||
out.push('## DESIGN.md\n\n');
|
||||
out.push('The design document this context produced, verbatim.\n\n');
|
||||
out.push('<!-- begin DESIGN.md -->\n\n');
|
||||
out.push(bundle.designMd.trim());
|
||||
out.push('\n\n<!-- end DESIGN.md -->\n');
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
export async function exportDesignContext(cwd, { outDir, includeAssets = true, now } = {}) {
|
||||
const bundle = await buildBundle(cwd, { includeAssets, now });
|
||||
const destination = outDir ? path.resolve(cwd, outDir) : paths(cwd).exportsDir;
|
||||
await mkdir(destination, { recursive: true });
|
||||
|
||||
const markdownPath = path.join(destination, 'design-context.md');
|
||||
const bundlePath = path.join(destination, 'design-context.bundle.json');
|
||||
await writeFile(markdownPath, renderMarkdown(bundle));
|
||||
await writeJsonAtomic(bundlePath, bundle);
|
||||
return { markdownPath, bundlePath, skipped: bundle.skipped || [] };
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Import
|
||||
============================================================ */
|
||||
|
||||
export function validateBundle(bundle) {
|
||||
if (!bundle || typeof bundle !== 'object') throw new Error('That file is not a design context bundle');
|
||||
if (bundle.kind !== BUNDLE_KIND) throw new Error(`Expected a ${BUNDLE_KIND} bundle, found ${String(bundle.kind)}`);
|
||||
if (bundle.schemaVersion !== BUNDLE_SCHEMA) {
|
||||
throw new Error(`This bundle is schema version ${String(bundle.schemaVersion)}; this release reads ${BUNDLE_SCHEMA}. Update impeccable.`);
|
||||
}
|
||||
if (!bundle.answers || typeof bundle.answers !== 'object') throw new Error('The bundle carries no answers');
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export async function importDesignContext(cwd, bundle, { design = 'skip' } = {}) {
|
||||
validateBundle(bundle);
|
||||
const target = paths(cwd);
|
||||
|
||||
await writeAnswers(bundle.answers, cwd);
|
||||
const context = bundle.context && typeof bundle.context === 'object'
|
||||
? bundle.context
|
||||
: { schemaVersion: SCHEMA_VERSION };
|
||||
await writeContext(context, cwd);
|
||||
|
||||
let written = 0;
|
||||
for (const file of Array.isArray(bundle.files) ? bundle.files : []) {
|
||||
const relative = String(file?.path || '');
|
||||
/* Containment is not enough on its own: a bundle could otherwise name a
|
||||
store file and overwrite what was just written. Only the three places an
|
||||
export puts bytes are accepted. */
|
||||
if (!ALLOWED_FILE.test(relative)) {
|
||||
process.stderr.write(`Skipped ${relative || '(unnamed)'}: not a place a design context keeps files\n`);
|
||||
continue;
|
||||
}
|
||||
const absolute = path.resolve(target.storeDir, relative);
|
||||
if (path.relative(target.storeDir, absolute).startsWith('..')) continue;
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, Buffer.from(String(file.base64 || ''), 'base64'));
|
||||
written += 1;
|
||||
}
|
||||
|
||||
/* The questionnaire cannot run without a cue manifest: its palette screen
|
||||
loads the deck and the built-in seeds together, and neither arrives if the
|
||||
file is missing. An imported project gets a valid one either way, carrying
|
||||
the chosen cue's dealt values when the bundle brought them. */
|
||||
if (!(await readJsonSoft(target.cuesJson))) {
|
||||
await writeJsonAtomic(target.cuesJson, {
|
||||
cues: [],
|
||||
...(bundle.chosenCue?.slug ? { palette: { [bundle.chosenCue.slug]: bundle.chosenCue.palette } } : { palette: {} }),
|
||||
});
|
||||
}
|
||||
if (bundle.fonts && !(await readJsonSoft(target.fontsManifestJson))) {
|
||||
await writeJsonAtomic(target.fontsManifestJson, bundle.fonts);
|
||||
}
|
||||
|
||||
let designWritten = false;
|
||||
if (design === 'write' && typeof bundle.designMd === 'string' && bundle.designMd.trim()) {
|
||||
const designPath = path.resolve(cwd, 'DESIGN.md');
|
||||
if (!(await readFile(designPath, 'utf8').then(() => true).catch(() => false))) {
|
||||
await writeFile(designPath, bundle.designMd);
|
||||
designWritten = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { written, designWritten, designCarried: typeof bundle.designMd === 'string' && Boolean(bundle.designMd.trim()) };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/** The save flow behind the design context document.
|
||||
*
|
||||
* A person edits fields in the document; the edits stage in the browser and
|
||||
* arrive here as one batch when they press Apply. Applying is deterministic:
|
||||
* every change names a binding, the binding names a file and a path, and the
|
||||
* value is written through the store. Nothing is searched for and no model is
|
||||
* involved, which is what makes a save either complete or refused rather than
|
||||
* approximately done.
|
||||
*
|
||||
* What the agent gets afterwards is the reconciliation, not the write. The
|
||||
* values are already on disk by the time the batch reaches a poll; DESIGN.md
|
||||
* and PRODUCT.md are the agent's to bring in line with them.
|
||||
*
|
||||
* browser --POST /doc/save-------> applied here, journaled, batch queued
|
||||
* agent --GET /doc/poll-------> save_batch (leased)
|
||||
* agent --POST /doc/reply------> acknowledged, version bumped
|
||||
* browser --GET /doc/state------> version moved, so re-read and re-render
|
||||
*
|
||||
* The batch is journaled before it is offered and cleared only on an
|
||||
* acknowledgement, so a session that dies mid-flight re-offers it on the next
|
||||
* boot rather than losing the work.
|
||||
*/
|
||||
|
||||
import { bindingFor, readPath, sanitizeValue, writePath } from './bindings.mjs';
|
||||
import {
|
||||
appendJournal,
|
||||
readAnswers,
|
||||
readContext,
|
||||
replayJournal,
|
||||
writeAnswers,
|
||||
writeContext,
|
||||
SCHEMA_VERSION,
|
||||
} from './store.mjs';
|
||||
|
||||
const MAX_CHANGES = 100;
|
||||
/* Long enough that an agent doing real prose work is never raced, short enough
|
||||
that an agent that died does not hold the batch for the session's lifetime. */
|
||||
const LEASE_MS = 10 * 60_000;
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function createSaveRoutes({ cwd = process.cwd(), onChange = () => {} } = {}) {
|
||||
/* Recovered from the journal at boot: a batch the agent never acknowledged
|
||||
is still owed, whoever was running when it was made. */
|
||||
const replayed = replayJournal(cwd);
|
||||
let pending = replayed.pendingBatch
|
||||
? { ...replayed.pendingBatch, leaseUntil: 0 }
|
||||
: null;
|
||||
let counter = Number(replayed.lastSeq) || 0;
|
||||
|
||||
const summary = () => (pending
|
||||
? { id: pending.id, status: pending.status, count: pending.changes.length }
|
||||
: null);
|
||||
|
||||
function validate(body) {
|
||||
const changes = Array.isArray(body?.changes) ? body.changes : null;
|
||||
if (!changes?.length) throw httpError(400, 'changes must be a non-empty array');
|
||||
if (changes.length > MAX_CHANGES) throw httpError(400, `at most ${MAX_CHANGES} changes per save`);
|
||||
|
||||
return changes.map((change) => {
|
||||
const binding = bindingFor(String(change?.bindingId ?? ''));
|
||||
if (!binding) throw httpError(400, `Unknown field: ${String(change?.bindingId ?? '')}`);
|
||||
let value;
|
||||
try {
|
||||
value = sanitizeValue(binding, change.to);
|
||||
} catch (error) {
|
||||
throw httpError(400, `${change.bindingId}: ${error.message}`);
|
||||
}
|
||||
return {
|
||||
bindingId: String(change.bindingId),
|
||||
binding,
|
||||
from: typeof change.from === 'string' ? change.from : '',
|
||||
to: value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** One read and one write per file, so a save lands whole or not at all. */
|
||||
async function applyToStore(changes) {
|
||||
const files = new Map();
|
||||
const load = async (file) => {
|
||||
if (!files.has(file)) {
|
||||
files.set(file, file === 'answers'
|
||||
? (await readAnswers(cwd)) || {}
|
||||
: (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION });
|
||||
}
|
||||
return files.get(file);
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
const document = await load(change.binding.file);
|
||||
/* context.json wraps its payload, so a binding path addresses the
|
||||
context object rather than the file's own root. */
|
||||
const root = change.binding.file === 'context'
|
||||
? (document.context ??= {})
|
||||
: document;
|
||||
change.previous = String(readPath(root, change.binding.path) ?? '');
|
||||
writePath(root, change.binding.path, change.to);
|
||||
}
|
||||
|
||||
if (files.has('answers')) await writeAnswers(files.get('answers'), cwd);
|
||||
if (files.has('context')) await writeContext(files.get('context'), cwd);
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
hasPending: () => Boolean(pending),
|
||||
|
||||
/** POST /doc/save */
|
||||
async save(body) {
|
||||
if (pending) throw httpError(409, 'A save is already applying');
|
||||
const changes = validate(body);
|
||||
await applyToStore(changes);
|
||||
|
||||
for (const change of changes) {
|
||||
appendJournal({
|
||||
type: 'change',
|
||||
bindingId: change.bindingId,
|
||||
from: change.previous,
|
||||
to: change.to,
|
||||
}, cwd);
|
||||
}
|
||||
|
||||
counter += 1;
|
||||
const id = `batch-${String(counter).padStart(3, '0')}`;
|
||||
const recorded = changes.map(({ bindingId, previous, to, binding }) => ({
|
||||
bindingId,
|
||||
from: previous,
|
||||
to,
|
||||
downstream: binding.downstream,
|
||||
}));
|
||||
appendJournal({ type: 'batch', id, status: 'pending', changes: recorded }, cwd);
|
||||
pending = { id, status: 'pending', changes: recorded, leaseUntil: 0 };
|
||||
onChange();
|
||||
return { id, count: recorded.length };
|
||||
},
|
||||
|
||||
/** The event a polling agent is handed, or nothing when none is due. */
|
||||
takeBatchEvent(replyCommandFor) {
|
||||
if (!pending || pending.leaseUntil > Date.now()) return null;
|
||||
/* Stamped before anything awaits, so a second poll arriving in the same
|
||||
tick cannot be handed the same batch. */
|
||||
pending.leaseUntil = Date.now() + LEASE_MS;
|
||||
return {
|
||||
type: 'save_batch',
|
||||
id: pending.id,
|
||||
changes: pending.changes,
|
||||
downstream: pending.changes.filter((change) => change.downstream !== 'none'),
|
||||
replyCommand: replyCommandFor(pending.id),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /doc/reply for a batch.
|
||||
*
|
||||
* An unknown id keeps the lease and says which batch is actually owed, so
|
||||
* an agent that replied to the wrong thing can correct itself rather than
|
||||
* leaving the work stranded.
|
||||
*/
|
||||
async reply(body) {
|
||||
if (!pending) throw httpError(404, 'No save is waiting for a reply');
|
||||
if (body.id !== pending.id) {
|
||||
throw httpError(404, `Unknown save ${String(body.id)}; the one waiting is ${pending.id}`);
|
||||
}
|
||||
if (!['done', 'error', 'retry'].includes(body.status)) {
|
||||
throw httpError(400, 'status must be done, error, or retry');
|
||||
}
|
||||
|
||||
if (body.status === 'retry') {
|
||||
pending.leaseUntil = 0;
|
||||
onChange();
|
||||
return { ok: true, status: 'pending' };
|
||||
}
|
||||
|
||||
/* The agent's own follow-on writes ride here rather than going to the
|
||||
store directly, so this process stays the only writer while it runs. */
|
||||
const applied = await applyAgentUpdates(body, cwd);
|
||||
appendJournal({ type: 'batch', id: pending.id, status: body.status, message: String(body.message || '') }, cwd);
|
||||
pending = null;
|
||||
onChange();
|
||||
return { ok: true, status: body.status, applied };
|
||||
},
|
||||
|
||||
/** Journaled so the tab re-reads on a font or freeform request too. */
|
||||
noteRequest(id, status) {
|
||||
appendJournal({ type: 'request', id, status }, cwd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-value updates an agent attaches to its reply.
|
||||
*
|
||||
* Answers keys are written as given, since the questionnaire's own vocabulary
|
||||
* is wider than the bound fields; context values go through their binding when
|
||||
* one exists, so the same rules apply to both writers.
|
||||
*/
|
||||
async function applyAgentUpdates(body, cwd) {
|
||||
const applied = { answers: 0, context: 0 };
|
||||
|
||||
if (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) {
|
||||
const answers = (await readAnswers(cwd)) || {};
|
||||
for (const [key, value] of Object.entries(body.answers)) {
|
||||
if (typeof value !== 'string' && !Array.isArray(value)) continue;
|
||||
answers[key] = value;
|
||||
applied.answers += 1;
|
||||
}
|
||||
if (applied.answers) await writeAnswers(answers, cwd);
|
||||
}
|
||||
|
||||
if (body.context && typeof body.context === 'object' && !Array.isArray(body.context)) {
|
||||
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
|
||||
const root = (stored.context ??= {});
|
||||
for (const [dotted, value] of Object.entries(body.context)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const binding = bindingFor(dotted);
|
||||
let next = value;
|
||||
if (binding) {
|
||||
try {
|
||||
next = sanitizeValue(binding, value);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
writePath(root, binding ? binding.path : dotted, next);
|
||||
applied.context += 1;
|
||||
}
|
||||
if (applied.context) await writeContext(stored, cwd);
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/** 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';
|
||||
|
||||
export const STORE_DIR = '.impeccable/design-context';
|
||||
export 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'),
|
||||
workspaceDir: path.resolve(cwd, WORKSPACE_DIR),
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -8,24 +8,32 @@
|
||||
* node picker-doc-poll.mjs # block, print one event
|
||||
* node picker-doc-poll.mjs --timeout=600000 # total budget in ms
|
||||
* node picker-doc-poll.mjs --reply <id> <status> [message]
|
||||
* node picker-doc-poll.mjs --reply <id> done "msg" --answers '{"key":"value"}'
|
||||
*
|
||||
* Events printed: {"type":"edit_request","id","kind","prompt","category",
|
||||
* "payload"} for work, {"type":"timeout"} when the budget runs out (poll
|
||||
* again), {"type":"exit"} when the session ended (stop polling).
|
||||
* "payload"} for work a person asked for in words,
|
||||
* {"type":"save_batch","id","changes","downstream","replyCommand"} for edits
|
||||
* already applied to the store and owed a prose pass in DESIGN.md or
|
||||
* PRODUCT.md, {"type":"timeout"} when the budget runs out (poll again), and
|
||||
* {"type":"exit"} when the session ended (stop polling).
|
||||
*
|
||||
* Reply statuses: done (change applied; message shown to the user in the
|
||||
* document), error (could not apply; message explains), retry (release the
|
||||
* request back to pending).
|
||||
*
|
||||
* Session discovery: .impeccable/design-interview/doc-session.json, written
|
||||
* --answers and --context attach values for the session to write. The session
|
||||
* is the only writer of the store while it runs, so a value the agent settles
|
||||
* travels here rather than being written to those files directly.
|
||||
*
|
||||
* Session discovery: .impeccable/design-context/runtime/session.json, written
|
||||
* by the session process and removed when it exits; a missing file prints
|
||||
* {"type":"exit"} so a finished session never hangs the loop.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { paths } from './design-context/store.mjs';
|
||||
|
||||
const sessionPath = path.resolve(process.cwd(), '.impeccable/design-interview/doc-session.json');
|
||||
const sessionPath = paths(process.cwd()).sessionJson;
|
||||
/* Sliced under undici's 300s header timeout, same as live-poll. */
|
||||
const PER_REQUEST_MS = 270_000;
|
||||
const DEFAULT_TOTAL_MS = 600_000;
|
||||
@@ -55,17 +63,47 @@ if (!info) {
|
||||
}
|
||||
const base = `http://127.0.0.1:${info.port}`;
|
||||
|
||||
const VALUE_FLAGS = new Set(['--answers', '--context', '--timeout']);
|
||||
|
||||
/* The message is whatever positional words are left, so a flag and the value
|
||||
that belongs to it both have to come out first, or an attached JSON payload
|
||||
would be read back to the user as their confirmation line. */
|
||||
function positionalAfter(marker) {
|
||||
const words = [];
|
||||
for (let index = args.indexOf(marker) + 1; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith('--')) {
|
||||
if (VALUE_FLAGS.has(arg)) index += 1;
|
||||
continue;
|
||||
}
|
||||
words.push(arg);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
if (args.includes('--reply')) {
|
||||
const at = args.indexOf('--reply');
|
||||
const [id, status, ...rest] = args.slice(at + 1).filter((arg) => !arg.startsWith('--'));
|
||||
const [id, status, ...rest] = positionalAfter('--reply');
|
||||
if (!id || !status) {
|
||||
console.error('usage: picker-doc-poll.mjs --reply <id> <done|error|retry> [message]');
|
||||
console.error('usage: picker-doc-poll.mjs --reply <id> <done|error|retry> [message] [--answers JSON] [--context JSON]');
|
||||
process.exit(1);
|
||||
}
|
||||
/* Values the agent settled while doing the work, handed to the session to
|
||||
write. Bad JSON is a mistake worth stopping for rather than dropping. */
|
||||
const attached = {};
|
||||
for (const flag of ['answers', 'context']) {
|
||||
const raw = readFlag(`--${flag}`, '');
|
||||
if (!raw) continue;
|
||||
try {
|
||||
attached[flag] = JSON.parse(raw);
|
||||
} catch {
|
||||
console.error(`--${flag} must be a JSON object`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${base}/doc/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id, status, message: rest.join(' ') }),
|
||||
body: JSON.stringify({ token: info.token, id, status, message: rest.join(' '), ...attached }),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`);
|
||||
|
||||
@@ -7,20 +7,28 @@
|
||||
* own pre-scanned port with CORS open to the picker origin, and it mediates
|
||||
* three parties the way the live server does, scaled down to polling:
|
||||
*
|
||||
* browser --POST /doc/edit-----------> applied here (simple edits)
|
||||
* browser --POST /doc/save----------> applied to the store, batch queued
|
||||
* browser --POST /doc/request-------> queue --GET /doc/poll--> agent
|
||||
* agent --POST /doc/reply---------> queue status + version bump
|
||||
* browser --GET /doc/state (poll)--> { version, requests } -> re-render
|
||||
* browser --GET /doc/state (poll)--> { version, requests, batch } -> re-read
|
||||
*
|
||||
* Simple edits (a palette color) are deterministic: this process rewrites
|
||||
* answers.json and swaps the value in DESIGN.md itself, no model involved.
|
||||
* Anything needing judgment queues for the agent, which long-polls through
|
||||
* Edits made in the document stage in the browser and arrive here as one batch.
|
||||
* Applying them is deterministic and belongs to this process: each change names
|
||||
* a field, the field names a place in the store, and the value is written
|
||||
* there. What reaches the agent afterwards is the reconciliation the store
|
||||
* cannot do for itself, the prose in DESIGN.md and PRODUCT.md that describes
|
||||
* those values. Anything needing judgment up front, a font change or a freeform
|
||||
* ask, queues for the agent the same way, and it long-polls through
|
||||
* picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
|
||||
*
|
||||
* Session discovery for the agent CLI: .impeccable/design-interview/
|
||||
* doc-session.json { pid, port, token }. Removed on exit. Every applied
|
||||
* simple edit is journaled to doc-edits.jsonl in the same directory so the
|
||||
* agent can reconcile prose (a renamed color's description) at session end.
|
||||
* This process is the only writer of the store while it runs; the agent's own
|
||||
* follow-on values ride in on its reply. That is what keeps a save and an
|
||||
* agent working at the same time from overwriting each other.
|
||||
*
|
||||
* Session discovery for the agent CLI: .impeccable/design-context/runtime/
|
||||
* session.json { pid, port, token }. Removed on exit. Every applied change is
|
||||
* journaled to runtime/journal.jsonl beside it, so a session that dies with a
|
||||
* batch outstanding re-offers it and the agent can reconcile prose at the end.
|
||||
*
|
||||
* Usage (spawned by picker-server.mjs, not by hand):
|
||||
* node picker-doc-session.mjs --port 8501 --timeout 60
|
||||
@@ -30,14 +38,15 @@
|
||||
import http from 'node:http';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fontRelativePath, migrate, paths, readJsonSoft, writeJsonAtomic } from './design-context/store.mjs';
|
||||
import { createSaveRoutes } from './design-context/session-routes.mjs';
|
||||
|
||||
const interviewDir = path.resolve(process.cwd(), '.impeccable/design-interview');
|
||||
const answersPath = path.join(interviewDir, 'answers.json');
|
||||
const sessionPath = path.join(interviewDir, 'doc-session.json');
|
||||
const ledgerPath = path.join(interviewDir, 'doc-edits.jsonl');
|
||||
const fontsDir = path.join(interviewDir, 'fonts');
|
||||
const brandAssetsDir = path.join(interviewDir, 'assets');
|
||||
const designPath = path.resolve(process.cwd(), 'DESIGN.md');
|
||||
const store = paths(process.cwd());
|
||||
const answersPath = store.answersJson;
|
||||
const contextPath = store.contextJson;
|
||||
const sessionPath = store.sessionJson;
|
||||
const fontsDir = store.fontsDir;
|
||||
const brandAssetsDir = store.assetsDir;
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
@@ -49,7 +58,6 @@ const BRAND_ASSET_MIME = new Map([
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
const ROLES = new Set(['primary', 'secondary', 'tertiary', 'neutral']);
|
||||
const REQUEST_KINDS = new Set(['font', 'freeform']);
|
||||
/* Long polls are sliced under common proxy/undici header timeouts, the same
|
||||
270s ceiling live-poll uses. */
|
||||
@@ -78,6 +86,10 @@ if (!port || !token) {
|
||||
let version = 1;
|
||||
let requestSeq = 0;
|
||||
const requests = [];
|
||||
/* The save flow lives in its own module; this shell keeps the server, the
|
||||
timers, and the token. Every applied save bumps the same version the tab
|
||||
polls, so the document re-reads itself without a second signal. */
|
||||
const saves = createSaveRoutes({ onChange: () => { bumpVersion(); wakeParkedPolls(); } });
|
||||
let lastBrowserSeen = Date.now();
|
||||
let adopted = false;
|
||||
const parkedPolls = [];
|
||||
@@ -123,51 +135,8 @@ const summarize = (entry) => ({
|
||||
message: entry.message || '',
|
||||
});
|
||||
|
||||
async function appendLedger(entry) {
|
||||
await mkdir(interviewDir, { recursive: true });
|
||||
await writeFile(ledgerPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { flag: 'a' });
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Simple edits — deterministic, applied here.
|
||||
============================================================ */
|
||||
|
||||
async function applyColorEdit({ role, value }) {
|
||||
if (!ROLES.has(role)) throw httpError(400, 'Unknown palette role');
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(value || '')) throw httpError(400, 'Value must be a #rrggbb hex color');
|
||||
const hex = value.toUpperCase();
|
||||
|
||||
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
|
||||
const previous = String(answers[`palette-${role}`] || '').toUpperCase();
|
||||
answers[`palette-${role}`] = hex;
|
||||
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
||||
|
||||
/* DESIGN.md may not exist yet (the agent writes the seed while the user
|
||||
reads the document); the answers file is the source it will seed from,
|
||||
so an early edit is already carried. */
|
||||
let designTouched = false;
|
||||
if (previous && previous !== hex) {
|
||||
try {
|
||||
const source = await readFile(designPath, 'utf8');
|
||||
/* A hex value is regex-safe: a literal # and hex digits. */
|
||||
const swapped = source.replace(new RegExp(previous, 'gi'), hex);
|
||||
if (swapped !== source) {
|
||||
await writeFile(designPath, swapped);
|
||||
designTouched = true;
|
||||
}
|
||||
} catch {
|
||||
/* No DESIGN.md yet. */
|
||||
}
|
||||
}
|
||||
|
||||
await appendLedger({ type: 'color', role, from: previous, to: hex, designTouched });
|
||||
return { role, from: previous, to: hex, designTouched };
|
||||
}
|
||||
|
||||
const SIMPLE_EDITS = { color: applyColorEdit };
|
||||
|
||||
/* ============================================================
|
||||
Complex edits — queued for the agent.
|
||||
Requests that need judgment, queued for the agent.
|
||||
============================================================ */
|
||||
|
||||
function wakeParkedPolls() {
|
||||
@@ -198,6 +167,14 @@ async function handleDocPoll(response, query) {
|
||||
sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload });
|
||||
return;
|
||||
}
|
||||
/* The values are already in the store; what is handed over is the prose
|
||||
still owed to DESIGN.md and PRODUCT.md. The reply command travels with
|
||||
the event so the instruction cannot drift from the contract. */
|
||||
const batch = saves.takeBatchEvent((id) => `node picker-doc-poll.mjs --reply ${id} done "One line the user sees in the tab"`);
|
||||
if (batch) {
|
||||
sendJson(response, 200, batch);
|
||||
return;
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
sendJson(response, 200, { type: 'timeout' });
|
||||
@@ -256,7 +233,7 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
||||
sendJson(response, 200, { ok: true, path: path.join('.impeccable/design-interview/fonts', name) });
|
||||
sendJson(response, 200, { ok: true, path: fontRelativePath(name) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,6 +282,7 @@ async function handleRequest(request, response) {
|
||||
version,
|
||||
requests: requests.map(summarize),
|
||||
agentWaiting: parkedPolls.length > 0,
|
||||
batch: saves.summary(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -316,6 +294,24 @@ async function handleRequest(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* The chat half of the run, read fresh so an agent's rewrite reaches the tab. */
|
||||
if (request.method === 'GET' && requestPath === '/doc/context') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let stored = null;
|
||||
try {
|
||||
stored = JSON.parse(await readFile(contextPath, 'utf8'));
|
||||
} catch {
|
||||
/* A run whose chat half was never recorded still has a document. */
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
version,
|
||||
modes: stored?.modes ?? null,
|
||||
context: stored?.context ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/poll') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
await handleDocPoll(response, url.searchParams);
|
||||
@@ -326,12 +322,11 @@ async function handleRequest(request, response) {
|
||||
const body = await readJsonBody(request);
|
||||
if (body.token !== token) throw httpError(403, 'Bad token');
|
||||
|
||||
if (requestPath === '/doc/edit') {
|
||||
const apply = SIMPLE_EDITS[body.kind];
|
||||
if (!apply) throw httpError(400, `No simple edit named ${String(body.kind)}; complex changes go through /doc/request`);
|
||||
const applied = await apply(body);
|
||||
bumpVersion();
|
||||
sendJson(response, 200, { ok: true, version, applied });
|
||||
/* Everything staged in the document arrives at once. Applying is this
|
||||
process's job; reconciling the prose around it is the agent's. */
|
||||
if (requestPath === '/doc/save') {
|
||||
const applied = await saves.save(body);
|
||||
sendJson(response, 200, { ok: true, version, ...applied });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -357,11 +352,18 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
|
||||
if (requestPath === '/doc/reply') {
|
||||
// A save and a request are both replied to here, told apart by the id.
|
||||
if (saves.hasPending() && String(body.id || '').startsWith('batch-')) {
|
||||
const result = await saves.reply(body);
|
||||
sendJson(response, 200, { ok: true, version, ...result });
|
||||
return;
|
||||
}
|
||||
const entry = requests.find((item) => item.id === body.id);
|
||||
if (!entry) throw httpError(404, 'Unknown request id');
|
||||
if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry');
|
||||
entry.status = body.status === 'retry' ? 'pending' : body.status;
|
||||
entry.message = String(body.message || '');
|
||||
saves.noteRequest(entry.id, entry.status);
|
||||
bumpVersion();
|
||||
if (entry.status === 'pending') wakeParkedPolls();
|
||||
sendJson(response, 200, { ok: true, version });
|
||||
@@ -372,8 +374,8 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', async () => {
|
||||
await mkdir(interviewDir, { recursive: true });
|
||||
await writeFile(sessionPath, `${JSON.stringify({ pid: process.pid, port, token }, null, 2)}\n`);
|
||||
await migrate(process.cwd());
|
||||
await writeJsonAtomic(sessionPath, { pid: process.pid, port, token });
|
||||
});
|
||||
|
||||
server.on('error', () => process.exit(1));
|
||||
@@ -390,7 +392,13 @@ async function shutdown() {
|
||||
clearInterval(reaper);
|
||||
clearTimeout(ceiling);
|
||||
wakeParkedPolls();
|
||||
await rm(sessionPath, { force: true }).catch(() => {});
|
||||
/* Only if it is still ours. A session that outlived its tab can be shutting
|
||||
down at the moment a newer one writes the same path, and taking the file
|
||||
with it would leave the live session undiscoverable. */
|
||||
const recorded = await readJsonSoft(sessionPath);
|
||||
if (!recorded || recorded.pid === process.pid) {
|
||||
await rm(sessionPath, { force: true }).catch(() => {});
|
||||
}
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
setTimeout(() => process.exit(0), 1_000).unref();
|
||||
|
||||
+227
-10
@@ -8,16 +8,29 @@
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { copyFile, readFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SEEDS } from './palette.mjs';
|
||||
import {
|
||||
clearDraft,
|
||||
fontRelativePath,
|
||||
migrate,
|
||||
paths,
|
||||
pidAlive,
|
||||
readAnswers,
|
||||
readDraft,
|
||||
readJsonSoft,
|
||||
writeDraft,
|
||||
writeJsonAtomic,
|
||||
} from './design-context/store.mjs';
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pickerDir = path.join(scriptDir, 'picker');
|
||||
const answersPath = path.resolve(process.cwd(), '.impeccable/design-interview/answers.json');
|
||||
const fontsDir = path.resolve(process.cwd(), '.impeccable/design-interview/fonts');
|
||||
const brandAssetsDir = path.resolve(process.cwd(), '.impeccable/design-interview/assets');
|
||||
const store = paths(process.cwd());
|
||||
const answersPath = store.answersJson;
|
||||
const fontsDir = store.fontsDir;
|
||||
const brandAssetsDir = store.assetsDir;
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
const BRAND_ASSET_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'];
|
||||
@@ -46,12 +59,17 @@ Options:
|
||||
--port PORT Scan for an open port from PORT (default: 8500)
|
||||
--cues-dir PATH Visual cues directory (default: .impeccable/visual-cues)
|
||||
--timeout MINUTES Exit 2 if nothing submits (default: 60)
|
||||
--fresh Start blank, ignoring any previous answers or draft
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
PICKER_URL URL Printed when the server is ready
|
||||
ANSWERS PATH Printed after answers.json is written
|
||||
|
||||
Also served, for the design context document the questionnaire reveals:
|
||||
/context.json The chat half of the interview, from the design-context store
|
||||
/cue.png The chosen cue image, copied into the store at submit
|
||||
|
||||
See reference/visual-cues.md for the canonical agent flow.`);
|
||||
}
|
||||
|
||||
@@ -65,11 +83,21 @@ function readOption(args, index) {
|
||||
return { value: args[index + 1], next: index + 1 };
|
||||
}
|
||||
function parseArgs(args) {
|
||||
const options = { port: 8500, cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'), timeoutMinutes: 60 };
|
||||
const options = {
|
||||
port: 8500,
|
||||
cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'),
|
||||
timeoutMinutes: 60,
|
||||
fresh: false,
|
||||
doc: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--help' || arg === '-h') return { help: true };
|
||||
/* Value-less flags are read before the guard below, which would reject
|
||||
them, and before readOption, which demands a value for every flag. */
|
||||
if (arg === '--fresh') { options.fresh = true; continue; }
|
||||
if (arg === '--doc') { options.doc = true; continue; }
|
||||
if (!arg.startsWith('--port') && !arg.startsWith('--cues-dir') && !arg.startsWith('--timeout')) throw new Error(`Unknown option: ${arg}`);
|
||||
|
||||
const { value, next } = readOption(args, index);
|
||||
@@ -182,9 +210,17 @@ if (options.help) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* A project interviewed by an older release keeps its answers, assets, and
|
||||
uploaded faces under the pre-store layout. Bring them across before serving. */
|
||||
await migrate(process.cwd());
|
||||
|
||||
const port = await findOpenPort(options.port);
|
||||
let completed = false;
|
||||
let timeout;
|
||||
let docWatch;
|
||||
/* In document mode the run already happened: this process serves the document
|
||||
built from it, and the edit session is what it waits on. */
|
||||
let docSession = null;
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
@@ -201,13 +237,21 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && requestPath === '/submit') {
|
||||
/* Document mode is showing a run that already finished; there is nothing
|
||||
left to submit, and writing one would overwrite the answers it renders. */
|
||||
if (options.doc) {
|
||||
sendJson(response, 409, { error: 'The document is open; there is nothing to submit' });
|
||||
return;
|
||||
}
|
||||
if (completed) {
|
||||
sendJson(response, 409, { error: 'Submission already received' });
|
||||
return;
|
||||
}
|
||||
const answers = await readJsonBody(request);
|
||||
await mkdir(path.dirname(answersPath), { recursive: true });
|
||||
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
||||
await writeJsonAtomic(answersPath, answers);
|
||||
await copyChosenCue(answers);
|
||||
/* The run is on the record now, so the half-finished copy of it goes. */
|
||||
await clearDraft();
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -215,7 +259,7 @@ async function handleRequest(request, response) {
|
||||
detached sibling: it owns the edit endpoints on its own port, so this
|
||||
process can still exit as the agent's completion signal. The tab learns
|
||||
where to reach it from this response; the agent learns from
|
||||
doc-session.json, which the sibling writes at boot. */
|
||||
runtime/session.json, which the sibling writes at boot. */
|
||||
const doc = await spawnDocSession();
|
||||
response.once('finish', () => {
|
||||
console.log(`ANSWERS ${answersPath}`);
|
||||
@@ -226,6 +270,19 @@ async function handleRequest(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* The questionnaire posts its whole form after every screen change, so a run
|
||||
the visitor walks away from resumes where they left it instead of starting
|
||||
over. The submission supersedes the draft and removes it. */
|
||||
if (request.method === 'POST' && requestPath === '/autosave') {
|
||||
if (completed) {
|
||||
sendJson(response, 409, { error: 'Submission already received' });
|
||||
return;
|
||||
}
|
||||
await writeDraft(await readJsonBody(request));
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Uploaded faces are stored, not parsed: the questionnaire defers validation
|
||||
// to the end, so the server only needs to put the bytes where the agent can
|
||||
// reach them and hand back the path the answers will carry.
|
||||
@@ -244,7 +301,7 @@ async function handleRequest(request, response) {
|
||||
}
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
||||
sendJson(response, 200, { path: path.join('.impeccable/design-interview/fonts', name) });
|
||||
sendJson(response, 200, { path: fontRelativePath(name) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,10 +309,40 @@ async function handleRequest(request, response) {
|
||||
sendJson(response, 405, { error: 'Method not allowed' });
|
||||
return;
|
||||
}
|
||||
/* One fetch tells the client how to start: which surface it is serving, and
|
||||
the answers to restore, if any. Never cached, because the draft moves
|
||||
while the questionnaire is open and a stale copy would restore a run the
|
||||
visitor has already moved past. */
|
||||
if (requestPath === '/boot.json') {
|
||||
const { prior, priorSource } = await resolvePrior();
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
sendJson(response, 200, {
|
||||
mode: options.doc ? 'doc' : 'questionnaire',
|
||||
prior,
|
||||
priorSource,
|
||||
/* Present only where the document is live for edits. Absent leaves it
|
||||
rendering read-only, which is the honest state when no session took. */
|
||||
doc: docSession ? { base: `http://127.0.0.1:${docSession.port}`, token: docSession.token } : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/cues.json') {
|
||||
await serveFile(response, options.cuesDir, 'cues.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
/* The chat half of the interview, and the chosen cue, both live in the store
|
||||
rather than the generation workspace. The document reads them after this
|
||||
process exits, so they carry the same cache rule the cue images do. */
|
||||
if (requestPath === '/context.json') {
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, store.storeDir, 'context.json', ['.json']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/cue.png') {
|
||||
response.setHeader('Cache-Control', 'max-age=86400');
|
||||
await serveFile(response, store.storeDir, 'cue.png', ['.png']);
|
||||
return;
|
||||
}
|
||||
if (requestPath === '/fonts.json') {
|
||||
await serveFile(response, options.cuesDir, 'fonts.json', ['.json']);
|
||||
return;
|
||||
@@ -309,7 +396,35 @@ async function handleRequest(request, response) {
|
||||
await serveFile(response, pickerDir, assetPath);
|
||||
}
|
||||
|
||||
/* An unfinished run outranks a finished one: the draft is where the visitor
|
||||
actually is, the submission is where they last were. --fresh declines both. */
|
||||
async function resolvePrior() {
|
||||
if (options.fresh) return { prior: null, priorSource: null };
|
||||
const draft = await readDraft();
|
||||
if (draft) return { prior: draft, priorSource: 'draft' };
|
||||
const answers = await readAnswers();
|
||||
if (answers) return { prior: answers, priorSource: 'submitted' };
|
||||
return { prior: null, priorSource: null };
|
||||
}
|
||||
|
||||
/* The document renders the chosen cue long after this process is gone, and a
|
||||
later reopen has no generation workspace to reach into, so the one picked
|
||||
hero joins the store. A seed or custom palette names no cue: nothing to copy. */
|
||||
async function copyChosenCue(answers) {
|
||||
const slug = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
|
||||
if (!slug || slug !== path.basename(slug)) return;
|
||||
try {
|
||||
await mkdir(path.dirname(store.cuePng), { recursive: true });
|
||||
await copyFile(path.join(options.cuesDir, `${slug}.png`), store.cuePng);
|
||||
} catch {
|
||||
/* Not a cue palette, or the workspace is gone. */
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnDocSession() {
|
||||
/* The read-only path is otherwise unreachable from a test, and a document
|
||||
that renders without an edit session is a real state worth exercising. */
|
||||
if (process.env.IMPECCABLE_DOC_SESSION_DISABLE === '1') return null;
|
||||
try {
|
||||
const docPort = await findOpenPort(port + 1);
|
||||
const docToken = randomUUID();
|
||||
@@ -331,6 +446,92 @@ async function spawnDocSession() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Document mode: serving the design context document on its own.
|
||||
============================================================ */
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Does the recorded session answer for itself? Also marks it adopted. */
|
||||
async function probeSession(record) {
|
||||
if (!record?.port || !record?.token) return false;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${record.port}/doc/state?token=${encodeURIComponent(record.token)}`,
|
||||
{ signal: AbortSignal.timeout(2000) },
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionRecord(deadlineMs) {
|
||||
const until = Date.now() + deadlineMs;
|
||||
for (;;) {
|
||||
const record = await readJsonSoft(store.sessionJson);
|
||||
if (record?.port) return record;
|
||||
if (Date.now() > until) return null;
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One live session per project.
|
||||
*
|
||||
* A session that answers is rejoined, so reopening a tab closed a minute ago
|
||||
* lands back in the session the agent is already polling, and the probe itself
|
||||
* is what keeps it from being reaped. A dead record is cleared, and a recorded
|
||||
* process that will not answer is stopped and waited out before a replacement
|
||||
* is forked: two sessions would write one discovery file, and the loser's
|
||||
* shutdown would carry off the winner's record.
|
||||
*/
|
||||
async function adoptDocSession() {
|
||||
const recorded = await readJsonSoft(store.sessionJson);
|
||||
if (recorded && pidAlive(recorded.pid)) {
|
||||
if (await probeSession(recorded)) return recorded;
|
||||
try { process.kill(recorded.pid, 'SIGTERM'); } catch { /* already gone */ }
|
||||
for (let waited = 0; waited < 5000 && pidAlive(recorded.pid); waited += 200) await sleep(200);
|
||||
}
|
||||
await rm(store.sessionJson, { force: true }).catch(() => {});
|
||||
|
||||
if (!await spawnDocSession()) return null;
|
||||
/* A session forked before any tab exists has a short window to be adopted or
|
||||
it dies young, and in document mode the tab arrives only once a person
|
||||
opens the URL. This probe is the adoption. */
|
||||
const record = await waitForSessionRecord(5000);
|
||||
if (!record) return null;
|
||||
await probeSession(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/* The session ending is this process's completion signal in document mode.
|
||||
Liveness is the recorded process plus an answer from it, never the presence
|
||||
of the discovery file on its own: a session that crashes leaves the file
|
||||
behind, and a sibling shutting down can carry the file off while the real
|
||||
session is still serving. */
|
||||
function watchDocSession(record) {
|
||||
let misses = 0;
|
||||
const tick = async () => {
|
||||
if (completed) return;
|
||||
if (!pidAlive(record.pid)) return finishDocMode();
|
||||
misses = (await probeSession(record)) ? 0 : misses + 1;
|
||||
if (misses >= 2) return finishDocMode();
|
||||
docWatch = setTimeout(tick, 5000);
|
||||
};
|
||||
docWatch = setTimeout(tick, 5000);
|
||||
}
|
||||
|
||||
function finishDocMode() {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(docWatch);
|
||||
console.log('DOC_SESSION_ENDED');
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
}
|
||||
|
||||
function stopWithoutSubmission(message) {
|
||||
if (completed) return;
|
||||
clearTimeout(timeout);
|
||||
@@ -339,12 +540,28 @@ function stopWithoutSubmission(message) {
|
||||
server.closeAllConnections?.();
|
||||
}
|
||||
|
||||
/* Document mode needs a run to show and a session to keep it editable, both
|
||||
settled before the URL is printed: an agent that reads PICKER_URL is told
|
||||
the document is ready. */
|
||||
if (options.doc) {
|
||||
if (!await readAnswers()) {
|
||||
console.error('No design interview found. Run /impeccable document to create one.');
|
||||
process.exit(1);
|
||||
}
|
||||
docSession = await adoptDocSession();
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`PICKER_URL http://127.0.0.1:${port}`);
|
||||
timeout = setTimeout(
|
||||
() => stopWithoutSubmission('Picker timed out without a submission.'),
|
||||
() => stopWithoutSubmission(options.doc
|
||||
? 'Design context document closed without an edit session.'
|
||||
: 'Picker timed out without a submission.'),
|
||||
options.timeoutMinutes * 60_000,
|
||||
);
|
||||
/* With no session there is nothing to outlive, so the ceiling is the only
|
||||
limit and the document stays up read-only until it runs out. */
|
||||
if (options.doc && docSession) watchDocSession(docSession);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ const CODEX_HARNESSES = new Set(['.codex', '.agents']);
|
||||
// Valid sub-command names
|
||||
const VALID_COMMANDS = [
|
||||
'craft', 'init', 'extract', 'document', 'shape',
|
||||
'critique', 'audit',
|
||||
'critique', 'design-context', 'audit',
|
||||
'polish', 'bolder', 'quieter', 'distill', 'harden', 'onboard', 'live',
|
||||
'animate', 'colorize', 'typeset', 'layout', 'delight', 'overdrive',
|
||||
'clarify', 'adapt', 'optimize',
|
||||
|
||||
Reference in New Issue
Block a user