Files
gstack/bin/gstack-design-md.ts
T
Garry TanandClaude Fable 5.1 35d641b4b2 feat(design): write/read DESIGN.md in the open spec; persisted format choice
gstack's design skills now write DESIGN.md in the open DESIGN.md format and
read tokens from it. {{DESIGN_MD_CHECK}} renders the format check through
bin/gstack-design-md.ts: design-consultation's Phase 0 settles the format once
(spec → update tokens in the front matter; legacy without a marker → one
AskUserQuestion: convert with a .legacy.bak, keep the legacy file, or start
fresh; the answer is written into the file as the format marker so no skill
asks again; a marker already present is obeyed silently; unknown → prose;
missing → Phase 6 writes one). Phase 6's template is the spec form: YAML front
matter with name, description, and exactly the five token groups (colors,
typography.display/body/label/mono with fontFeature: tnum on mono, rounded,
spacing, components with {path} references), then Overview (Creative North
Star, product context, mode per surface, references, key characteristics),
Colors (opening with the Restrained / Committed / Full palette / Drenched
strategy), Typography, Layout, Elevation & Depth, Shapes, Components, Do's and
Don'ts, plus gstack's Motion and Decisions Log as extras; the template ends
with a check that the file parses as `spec`.

design-review runs the `:calibrate` form in Setup: a spec file's flat tokens
are the calibration source (a value present in the tokens is never a finding),
the marker is respected, and conversion is never offered there; its DESIGN.md
export writes the spec form. design-html's token extraction writes the spec
form and respects an existing choice. review/design-checklist.md category 5 and
ship's review-lite step 1 name `gstack-design-md tokens` as the calibration
source; plan-design-review Pass 5 cites tokens by path when front matter
exists.

The contract owns the bin's DESIGN_MD_MARKER / REASON / WRITTEN / BACKUP
lines; the contract test's pending list closes. Carve guard: design-
consultation skeleton 66,500 → 67,500 (measured 67,014; +1,508 B against the
1.5 KB cap). Codex and Factory ship goldens refreshed (review-lite step 1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:26:15 +00:00

118 lines
5.3 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* gstack-design-md — inspect, convert, and read DESIGN.md in the open format.
*
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts check [DESIGN.md]
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts convert [DESIGN.md] [--write]
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts tokens [DESIGN.md]
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts mark <spec|legacy-keep> [DESIGN.md]
*
* check DESIGN_MD_FORMAT: spec | legacy | unknown | missing (+ DESIGN_MD_REASON for unknown),
* DESIGN_MD_MARKER: spec | legacy-keep | none. Exit 0.
* convert Legacy → spec (lib/design-md.ts convertLegacy). Prints the result; with --write,
* backs the original up to DESIGN.md.legacy.bak and writes temp+rename. Refuses an
* ambiguous file (DESIGN_MD_CONVERT_REFUSED, exit 2) and a non-legacy one (exit 1).
* tokens Flat token map as JSON ({"colors.primary": "#F59E0B", ...}); {path} refs resolved;
* invalid refs listed under "errors" (DESIGN_MD_TOKEN_REF_INVALID). Exit 0.
* mark Persist the user's one-time format choice inside the file: spec files get a YAML
* comment on line 2, legacy files an HTML comment on line 1. Body bytes untouched.
*
* Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug. YAML errors never propagate: a file whose
* front matter does not parse is `unknown` with a reason.
*/
import * as fs from 'fs';
import * as path from 'path';
import { SENTINEL } from '../lib/design-detect-contract';
import {
parseDesignMd, detectFormat, convertLegacy, renderDesignMd, tokensFlat, setMarker,
type DesignMdDoc, type FormatChoice,
} from '../lib/design-md';
function resolveFile(arg?: string): string {
return path.resolve(arg && !arg.startsWith('--') ? arg : 'DESIGN.md');
}
function load(file: string): { text: string; doc: DesignMdDoc } | null {
try { const text = fs.readFileSync(file, 'utf-8'); return { text, doc: parseDesignMd(text) }; } catch { return null; }
}
function writeAtomic(file: string, content: string) {
const tmp = `${file}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, file);
}
export function main(argv = process.argv.slice(2)): number {
const verb = argv[0] ?? '';
const flags = new Set(argv.filter(a => a.startsWith('--')));
const positional = argv.slice(1).filter(a => !a.startsWith('--'));
switch (verb) {
case 'check': {
const file = resolveFile(positional[0]);
const loaded = load(file);
const { format, reason } = detectFormat(loaded?.doc ?? null);
process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: ${format}\n`);
if (reason) process.stdout.write(`${SENTINEL.DESIGN_MD_REASON}: ${reason}\n`);
process.stdout.write(`${SENTINEL.DESIGN_MD_MARKER}: ${loaded?.doc.marker ?? 'none'}\n`);
return 0;
}
case 'convert': {
const file = resolveFile(positional[0]);
const loaded = load(file);
const { format, reason } = detectFormat(loaded?.doc ?? null);
if (format === 'unknown' && reason?.startsWith('ambiguous')) {
process.stderr.write(`${SENTINEL.DESIGN_MD_CONVERT_REFUSED}: ${reason}\n`);
return 2;
}
if (format !== 'legacy' || !loaded) {
process.stderr.write(`${SENTINEL.DESIGN_MD_FORMAT}: ${format}${reason ? ` (${reason})` : ''}; convert only accepts a legacy gstack DESIGN.md\n`);
return 1;
}
const out = renderDesignMd(convertLegacy(loaded.doc), { emitFrontmatter: true });
if (flags.has('--write')) {
fs.writeFileSync(`${file}.legacy.bak`, loaded.text);
writeAtomic(file, out);
process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: spec\n${SENTINEL.DESIGN_MD_WRITTEN}: ${file}\n${SENTINEL.DESIGN_MD_BACKUP}: ${file}.legacy.bak\n`);
} else {
process.stdout.write(out);
}
return 0;
}
case 'tokens': {
const file = resolveFile(positional[0]);
const loaded = load(file);
const flat = tokensFlat(loaded?.doc.frontmatter ?? null);
process.stdout.write(JSON.stringify({ file, format: detectFormat(loaded?.doc ?? null).format, ...flat }, null, 2) + '\n');
for (const e of flat.errors) process.stderr.write(e + '\n');
return 0;
}
case 'mark': {
const choice = positional[0] as FormatChoice | undefined;
if (choice !== 'spec' && choice !== 'legacy-keep') {
process.stderr.write('usage: gstack-design-md.ts mark <spec|legacy-keep> [DESIGN.md]\n');
return 2;
}
const file = resolveFile(positional[1]);
const loaded = load(file);
if (!loaded) { process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: missing\n`); return 1; }
writeAtomic(file, renderDesignMd(setMarker(loaded.doc, choice)));
process.stdout.write(`${SENTINEL.DESIGN_MD_MARKER}: ${choice}\n`);
return 0;
}
default:
process.stderr.write('usage: gstack-design-md.ts check [file] | convert [file] [--write] | tokens [file] | mark <spec|legacy-keep> [file]\n');
return 2;
}
}
if (import.meta.main) {
try {
process.exitCode = main();
} catch (err) {
const e = err as Error;
process.stderr.write(`${SENTINEL.DESIGN_MD_INTERNAL_ERROR}: ${e?.name ?? 'Error'}: ${String(e?.message ?? e).slice(0, 300)}\n`);
process.exitCode = 3;
}
}