mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
/**
|
|
* Design Memory — extract visual language from approved mockups into DESIGN.md.
|
|
*
|
|
* After a mockup is approved, uses an OpenAI vision model to extract:
|
|
* - Color palette (hex values)
|
|
* - Typography (font families, sizes, weights)
|
|
* - Spacing patterns (padding, margins, gaps)
|
|
* - Layout conventions (grid, alignment, hierarchy)
|
|
*
|
|
* If DESIGN.md exists, merges extracted patterns with existing design system.
|
|
* If no DESIGN.md, creates one from the extracted patterns.
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { requireApiKey } from "./auth";
|
|
import { receiptedFetch } from "./receipted-fetch";
|
|
import { modelRejectionHint, visionRequestBody } from "./models";
|
|
import { parseDesignMd, detectFormat, renderDesignMd, spliceSection, specSkeleton, tokensFlat, slug, DesignMdEditRefused } from "../../lib/design-md";
|
|
import { atomicWriteSync } from "../../lib/fs-atomic";
|
|
|
|
/** The section the extraction owns in DESIGN.md (replaced on every run). */
|
|
export const EXTRACTED_SECTION_HEADING = "Extracted Design Language";
|
|
|
|
export interface ExtractedDesign {
|
|
colors: { name: string; hex: string; usage: string }[];
|
|
typography: { role: string; family: string; size: string; weight: string }[];
|
|
spacing: string[];
|
|
layout: string[];
|
|
mood: string;
|
|
}
|
|
|
|
/**
|
|
* Extract visual language from an approved mockup PNG.
|
|
*/
|
|
export async function extractDesignLanguage(imagePath: string): Promise<ExtractedDesign> {
|
|
const apiKey = requireApiKey();
|
|
const imageData = fs.readFileSync(imagePath).toString("base64");
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 60_000);
|
|
|
|
try {
|
|
const response = await receiptedFetch("memory-distill-request", "https://api.openai.com/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: visionRequestBody([{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: `data:image/png;base64,${imageData}` },
|
|
},
|
|
{
|
|
type: "text",
|
|
text: `Analyze this UI mockup and extract the design language. Return valid JSON only, no markdown:
|
|
|
|
{
|
|
"colors": [{"name": "primary", "hex": "#...", "usage": "buttons, links"}, ...],
|
|
"typography": [{"role": "heading", "family": "...", "size": "...", "weight": "..."}, ...],
|
|
"spacing": ["8px base unit", "16px between sections", ...],
|
|
"layout": ["left-aligned content", "max-width 1200px", ...],
|
|
"mood": "one sentence describing the overall feel"
|
|
}
|
|
|
|
Extract real values from what you see. Be specific about hex colors and font sizes.`,
|
|
},
|
|
],
|
|
}], 800, { response_format: { type: "json_object" } }),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
console.error(`Vision extraction failed (${response.status}): ${error.slice(0, 200)}${modelRejectionHint(response.status, error, "vision")}`);
|
|
return defaultDesign();
|
|
}
|
|
|
|
const data = await response.json() as any;
|
|
const content = data.choices?.[0]?.message?.content?.trim() || "";
|
|
// The model's JSON is unvalidated: default the arrays and coerce the strings so a null name
|
|
// cannot throw after the paid vision call.
|
|
const raw = JSON.parse(content) as Partial<Record<keyof ExtractedDesign, unknown>>;
|
|
const list = (v: unknown) => (Array.isArray(v) ? v : []);
|
|
const str = (v: unknown) => (v === null || v === undefined ? "" : String(v));
|
|
return {
|
|
colors: list(raw.colors).map((c) => ({ name: str((c as Record<string, unknown>)?.name), hex: str((c as Record<string, unknown>)?.hex), usage: str((c as Record<string, unknown>)?.usage) })),
|
|
typography: list(raw.typography).map((t) => ({ role: str((t as Record<string, unknown>)?.role), family: str((t as Record<string, unknown>)?.family), size: str((t as Record<string, unknown>)?.size), weight: str((t as Record<string, unknown>)?.weight) })),
|
|
spacing: list(raw.spacing).map(str),
|
|
layout: list(raw.layout).map(str),
|
|
mood: str(raw.mood),
|
|
};
|
|
} catch (err: any) {
|
|
console.error(`Design extraction error: ${err.message}`);
|
|
return defaultDesign();
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function defaultDesign(): ExtractedDesign {
|
|
return {
|
|
colors: [],
|
|
typography: [],
|
|
spacing: [],
|
|
layout: [],
|
|
mood: "Unable to extract design language",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Write or update DESIGN.md with extracted design patterns.
|
|
*
|
|
* Existing file (spec, legacy, or anything at all): the "## Extracted Design
|
|
* Language" section is spliced in at the text level through lib/design-md.ts,
|
|
* so every other byte of the user's file (front matter, section order, prose)
|
|
* is untouched; the section is replaced in place on a rerun, appended otherwise.
|
|
* New file: a spec-format skeleton whose tokens come from the extraction
|
|
* (colors by name, typography by role) plus the extracted section.
|
|
*/
|
|
export function updateDesignMd(
|
|
repoRoot: string,
|
|
extracted: ExtractedDesign,
|
|
sourceMockup: string,
|
|
): void {
|
|
const linkPath = path.join(repoRoot, "DESIGN.md");
|
|
const timestamp = new Date().toISOString().split("T")[0];
|
|
const body = formatExtractedSection(extracted, sourceMockup, timestamp);
|
|
|
|
if (fs.existsSync(linkPath)) {
|
|
const designPath = fs.realpathSync(linkPath); // edit the file behind a symlink, never replace the link
|
|
let next: string;
|
|
try {
|
|
next = spliceSection(fs.readFileSync(designPath, "utf-8"), EXTRACTED_SECTION_HEADING, body);
|
|
} catch (err) {
|
|
if (err instanceof DesignMdEditRefused) { console.error(`DESIGN.md not updated: ${err.message}`); return; }
|
|
throw err;
|
|
}
|
|
atomicWriteSync(designPath, next);
|
|
console.error(`Updated DESIGN.md with extracted design language`);
|
|
return;
|
|
}
|
|
const designPath = linkPath;
|
|
|
|
const colors: Record<string, string> = {};
|
|
for (const c of extracted.colors ?? []) {
|
|
const key = slug(String(c.name ?? ""));
|
|
if (key !== "token" && /^#[0-9a-fA-F]{3,8}$/.test(c.hex) && !(key in colors)) colors[key] = c.hex;
|
|
}
|
|
const typography: Record<string, Record<string, string>> = {};
|
|
for (const t of extracted.typography ?? []) {
|
|
const role = slug(String(t.role ?? ""));
|
|
if (role === "token" || typography[role]) continue;
|
|
const entry: Record<string, string> = { fontFamily: t.family };
|
|
if (t.size) entry.fontSize = t.size;
|
|
if (t.weight) entry.fontWeight = t.weight;
|
|
typography[role] = entry;
|
|
}
|
|
const frontmatter: Record<string, unknown> = {};
|
|
if (Object.keys(colors).length) frontmatter.colors = colors;
|
|
if (Object.keys(typography).length) frontmatter.typography = typography;
|
|
const doc = specSkeleton("Design System", frontmatter, [
|
|
{ heading: "Overview", body: `${extracted.mood}\n\nCreated by the gstack designer from an approved mockup (${path.basename(sourceMockup)}) on ${timestamp}.` },
|
|
{ heading: EXTRACTED_SECTION_HEADING, body },
|
|
]);
|
|
atomicWriteSync(designPath, renderDesignMd(doc));
|
|
console.error(`Created DESIGN.md with extracted design language`);
|
|
}
|
|
|
|
function formatExtractedSection(
|
|
extracted: ExtractedDesign,
|
|
sourceMockup: string,
|
|
date: string,
|
|
): string {
|
|
const lines: string[] = [
|
|
`*Auto-extracted from approved mockup on ${date}*`,
|
|
`*Source: ${path.basename(sourceMockup)}*`,
|
|
"",
|
|
`**Mood:** ${extracted.mood}`,
|
|
"",
|
|
];
|
|
|
|
if (extracted.colors.length > 0) {
|
|
lines.push("### Colors", "");
|
|
lines.push("| Name | Hex | Usage |");
|
|
lines.push("|------|-----|-------|");
|
|
for (const c of extracted.colors) {
|
|
lines.push(`| ${c.name} | \`${c.hex}\` | ${c.usage} |`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (extracted.typography.length > 0) {
|
|
lines.push("### Typography", "");
|
|
lines.push("| Role | Family | Size | Weight |");
|
|
lines.push("|------|--------|------|--------|");
|
|
for (const t of extracted.typography) {
|
|
lines.push(`| ${t.role} | ${t.family} | ${t.size} | ${t.weight} |`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (extracted.spacing.length > 0) {
|
|
lines.push("### Spacing", "");
|
|
for (const s of extracted.spacing) {
|
|
lines.push(`- ${s}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (extracted.layout.length > 0) {
|
|
lines.push("### Layout", "");
|
|
for (const l of extracted.layout) {
|
|
lines.push(`- ${l}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Read DESIGN.md and return it as a constraint string for brief construction.
|
|
* If no DESIGN.md exists, returns null (explore wide).
|
|
*/
|
|
export function readDesignConstraints(repoRoot: string): string | null {
|
|
const designPath = path.join(repoRoot, "DESIGN.md");
|
|
if (!fs.existsSync(designPath)) return null;
|
|
|
|
const content = fs.readFileSync(designPath, "utf-8");
|
|
const doc = parseDesignMd(content);
|
|
if (detectFormat(doc).format === "spec") {
|
|
// Spec file: the normative tokens first, then the Overview prose. Both fit
|
|
// the brief far better than the first 2000 bytes of YAML would.
|
|
const { tokens } = tokensFlat(doc.frontmatter);
|
|
const tokenLines = Object.entries(tokens).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
const overview = doc.sections.find((s) => s.canonical === "Overview")?.body ?? "";
|
|
return `Tokens: ${tokenLines}. ${overview}`.slice(0, 2000);
|
|
}
|
|
// Truncate to first 2000 chars to keep brief reasonable
|
|
return content.slice(0, 2000);
|
|
}
|